// =================================================================================
// File:    helper-rest.ts
//
// Author:  Jarle Elshaug
//
// Purpose: HelperRest class for executing REST calls supporting various auth types
//          Plugins may use this class: import { HelperRest } from 'scimgateway'
// =================================================================================

import { HttpsProxyAgent } from 'https-proxy-agent'
import { URL } from 'url'
import { Buffer } from 'node:buffer'
import { samlAssertion } from './samlAssertion.ts'
import * as jsonwebtoken from 'jsonwebtoken'
import fs from 'node:fs'
import querystring from 'querystring'
import * as utils from './utils.ts'

/**
 * HelperRest includes function doRequest() for executing REST calls
 */
export class HelperRest {
  private lock = new utils.Lock()
  private _serviceClient: Record<string, any> = {}
  private config_entity: any
  private scimgateway: any
  private idleTimeout: number
  private graphUrl = 'https://graph.microsoft.com/beta' // beta instead of 'v1.0' gives all user attributes when no $select
  private googleUrl = 'https://www.googleapis.com'

  constructor(scimgateway: any, optionalEntities?: Record<string, any>) {
    if (!scimgateway || !scimgateway.gwName) throw new Error('HelperRest initialization error: argument scimgateway is not of type ScimGateway')
    this.scimgateway = scimgateway
    this.idleTimeout = (scimgateway as any)?.config?.scimgateway.idleTimeout || 120
    this.idleTimeout = this.idleTimeout - 1
    if (optionalEntities && optionalEntities.entity) this.config_entity = utils.copyObj(optionalEntities.entity)
    else this.config_entity = utils.copyObj(scimgateway.getConfig())?.entity
    let entityFound = false
    let connectionFound = false
    for (const baseEntity in this.config_entity) {
      entityFound = true
      if (this.config_entity[baseEntity]?.connection) {
        connectionFound = true
        const type = this.config_entity[baseEntity].connection?.auth?.type
        if (type === 'oauthJwtBearer' || type === 'oauth') {
          // set default baseUrls for Entra ID and Google if not already defined
          if (this.config_entity[baseEntity]?.connection?.auth?.options?.tenantIdGUID) { // Entra ID, setting baseUrls to graph
            if (!this.config_entity[baseEntity].connection.baseUrls) {
              this.config_entity[baseEntity].connection.baseUrls = [this.graphUrl]
            } else if (this.config_entity[baseEntity].connection.baseUrls?.length < 1) {
              this.config_entity[baseEntity].connection.baseUrls = [this.graphUrl]
            }
          } else if (this.config_entity[baseEntity]?.connection?.auth?.options?.serviceAccountKeyFile) { // Google, setting baseUrls to googleapis
            if (!this.config_entity[baseEntity].connection.baseUrls) {
              this.config_entity[baseEntity].connection.baseUrls = [this.googleUrl]
            } else if (this.config_entity[baseEntity].connection.baseUrls?.length < 1) {
              this.config_entity[baseEntity].connection.baseUrls = [this.googleUrl]
            }
          }
        }
      }
    }
    let errMsg = ''
    if (!entityFound) errMsg = 'HelperRest initialization error: missing configuration \'endpoint.entity.<name>\''
    else if (!connectionFound) errMsg = 'HelperRest initialization error: missing configuration \'endpoint.entity.<name>.connection\''
    if (errMsg) this.scimgateway.logError('undefined', errMsg)
  }

  /**
   * getAccessToken returns oauth accesstoken object
   * @param baseEntity 
   * @param ctx 
   * @returns oauth accesstoken object
   */
  public async getAccessToken(baseEntity: string, ctx?: Record<string, any> | undefined) { // public in case token is needed for other logic e.g. sending mail
    await this.lock.acquire()
    const d = Math.floor(Date.now() / 1000) // seconds (unix time)
    if (this._serviceClient[baseEntity] && this._serviceClient[baseEntity].accessToken
      && (this._serviceClient[baseEntity].accessToken.validTo >= d + 30)) { // avoid simultaneously token requests
      this.lock.release()
      return this._serviceClient[baseEntity].accessToken
    }

    const action = 'getAccessToken'

    const serviceAccountKeyFile = this.config_entity[baseEntity]?.connection?.auth?.options?.serviceAccountKeyFile
    const tenantIdGUID = this.config_entity[baseEntity]?.connection?.auth?.options?.tenantIdGUID
    let tokenUrl: string
    let form: Record<string, any>
    let resource = ''

    try {
      const urlObj = new URL(this.config_entity[baseEntity].connection.baseUrls[0])
      resource = urlObj.origin
    } catch (err) { void 0 }
    if (tenantIdGUID) {
      tokenUrl = `https://login.microsoftonline.com/${tenantIdGUID}/oauth2/v2.0/token`
      if (resource) this.config_entity[baseEntity].connection.auth.options.scope = resource + '/.default' // "https://graph.microsoft.com/.default"
    } else tokenUrl = this.config_entity[baseEntity].connection.auth.options.tokenUrl

    try {
      switch (this.config_entity[baseEntity]?.connection?.auth?.type) {
        case 'oauth':
          form = {
            grant_type: 'client_credentials',
            client_id: this.config_entity[baseEntity].connection.auth.options.clientId,
            client_secret: this.config_entity[baseEntity].connection.auth.options.clientSecret,
          }
          if (this.config_entity[baseEntity].connection.auth.options.scope) form.scope = this.config_entity[baseEntity].connection.auth.options.scope // required using Entra ID /oauth2/v2.0/token
          if (this.config_entity[baseEntity].connection.auth.options.resource) resource = this.config_entity[baseEntity].connection.auth.options.resource // required using Entra ID /oauth2/token

          break

        case 'token':
          tokenUrl = this.config_entity[baseEntity].connection.auth.options.tokenUrl
          form = { // example username/password in body
            username: this.config_entity[baseEntity].connection.auth.options.username,
            password: this.config_entity[baseEntity].connection.auth.options.password,
          }
          break

        case 'oauthSamlBearer':
          tokenUrl = this.config_entity[baseEntity].connection.auth.options.tokenUrl
          const context = null
          const cert = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.cert).toString()
          const key = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.key).toString()

          const tokenEndpoint = tokenUrl
          const delay = 1

          // mandatory: clientId, companyId and nameId
          const clientId = this.config_entity[baseEntity].connection.auth.options.samlPayload.clientId
          const companyId = this.config_entity[baseEntity].connection.auth.options.samlPayload.companyId
          const nameId = this.config_entity[baseEntity].connection.auth.options.samlPayload.nameId
          const userIdentifierFormat = this.config_entity[baseEntity].connection.auth.options.samlPayload.userIdentifierFormat || 'userName'
          const lifetime = this.config_entity[baseEntity].connection.auth.options.samlPayload.lifetime || 3600
          const issuer = this.config_entity[baseEntity].connection.auth.options.samlPayload.clientId || `https://scimgateway.${this.scimgateway.pluginName}.com`
          const audience = this.config_entity[baseEntity].connection.auth.options.samlPayload.audience || `scimgateway/${this.scimgateway.pluginName}`

          form = {
            token_url: tokenUrl,
            grant_type: 'urn:ietf:params:oauth:grant-type:saml2-bearer',
            client_id: clientId,
            company_id: companyId,
            assertion: await samlAssertion.run(context, cert, key, issuer, lifetime, clientId, nameId, userIdentifierFormat, tokenEndpoint, audience, delay),
          }
          break

        case 'oauthJwtBearer':
          let jwtClaims: jsonwebtoken.JwtPayload | Record<string, any> = {}
          let jwtOpts: jsonwebtoken.SignOptions = {}

          if (tenantIdGUID) { // Microsoft Entra ID
            if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tls?.cert) {
              throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing options.tls.key/cert configuration`)
            }
            let privateKey = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._key || ''
            let cert = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._cert || ''
            if (!privateKey || !cert) {
              privateKey = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.key, 'utf-8') || ''
              cert = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.cert, 'utf-8') || ''
              if (privateKey) this.config_entity[baseEntity].connection.auth.options.tls._key = privateKey
              if (cert) this.config_entity[baseEntity].connection.auth.options.tls._cert = cert
            }
            if (!privateKey || !cert) {
              throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing options.tls.key/cert file content`)
            }

            const jwtPayload: jsonwebtoken.JwtPayload = {
              sub: this.config_entity[baseEntity]?.connection?.auth?.options?.clientId,
              iss: this.config_entity[baseEntity]?.connection?.auth?.options?.clientId,
              aud: `https://login.microsoftonline.com/${tenantIdGUID}/v2.0`,
              iat: Math.floor(Date.now() / 1000) - 60,
              exp: Math.floor(Date.now() / 1000) + 3600,
              jti: crypto.randomUUID(),
              nbf: Math.floor(Date.now() / 1000) - 60,
            }
            jwtClaims = {
              ...jwtPayload,
            }

            const base64Thumbprint = utils.getBase64CertificateThumbprint(cert, 'sha1') // xt5=>sha1, x5t#S256=>sha256
            jwtOpts = {
              algorithm: 'RS256',
              header: {
                typ: 'JWT',
                alg: 'RS256',
                x5t: base64Thumbprint,
              },
            }

            /* Microsoft recommended modern x5t#S256 does not work using self-signed certificate
            const base64Thumbprint = utils.getBase64CertificateThumbprint(cert, 'sha256')
            jwtOpts = {
              algorithm: 'PS256',
              header: {
                'typ': 'JWT',
                'alg': 'PS256',
                'x5t#S256': base64Thumbprint,
              },
            }
            */

            form = {
              grant_type: 'client_credentials',
              scope: this.config_entity[baseEntity].connection.auth.options.scope, // "https://graph.microsoft.com/.default"
              client_id: this.config_entity[baseEntity]?.connection?.auth?.options?.clientId,
              client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
              client_assertion: jsonwebtoken.sign(jwtClaims, privateKey, jwtOpts),
            }
          } else if (serviceAccountKeyFile) { // Google - using Service Account key json-file
            if (!this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.scope || !this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.subject) {
              const err = new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - using auth.options 'serviceAccountKeyFile' requires mandatory configuration entity.${baseEntity}.connection.auth.options.jwtPayload.scope/subject`)
              throw err
            }
            let gkey: Record<string, any> = this.config_entity[baseEntity]?.connection?.auth?.options?._gkey
            if (!gkey) {
              gkey = await (async () => {
                try {
                  const jsonObject = await import(serviceAccountKeyFile, { assert: { type: 'json' } })
                  return jsonObject.default // access the object via the `default` property
                } catch (err: any) {
                  throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - serviceAccountKeyFile error: ${err.message}`)
                }
              })()
              this.config_entity[baseEntity].connection.auth.options._gkey = gkey
            }

            tokenUrl = gkey.token_uri // https://oauth2.googleapis.com/token
            const privateKey = gkey.private_key
            const jwtPayload: jsonwebtoken.JwtPayload = {
              sub: this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.subject, // gmail sender mail-address: noreply@mycompany.com
              iss: gkey.client_email, // service account email/user
              aud: gkey.token_uri,
              iat: Math.floor(Date.now() / 1000) - 60, // issued at
              exp: Math.floor(Date.now() / 1000) + 3600, // expiration time
            }
            jwtClaims = {
              ...jwtPayload,
              scope: this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.scope, // https://www.googleapis.com/auth/gmail.send
            }
            jwtOpts = {
              algorithm: 'RS256',
              header: {
                typ: 'JWT',
                alg: 'RS256',
                kid: gkey.client_id,
              },
            }
            form = {
              grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
              assertion: jsonwebtoken.sign(jwtClaims, privateKey, jwtOpts),
            }
          } else {
            // standard JWT - requires all configuation: tokenUrl, jwtPayload and tls.key
            if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tokenUrl
              || !this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload
              || typeof this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload !== 'object') {
              throw new Error(`auth.type '${this.config_entity[baseEntity]?.connection?.auth?.type}' (no tenantIdGUID/serviceAccountKeyFile using raw) - missing configuration entity.${baseEntity}.connection.auth.options.tokenUrl/jwtPayload`)
            }
            if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tls?.key) {
              throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' (no tenantIdGUID/serviceAccountKeyFile using raw) - missing options.tls.key configuration`)
            }
            tokenUrl = this.config_entity[baseEntity].connection.auth.options.tokenUrl
            let privateKey = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._key || ''
            if (!privateKey) {
              privateKey = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.key, 'utf-8') || ''
              if (privateKey) this.config_entity[baseEntity].connection.auth.options.tls._key = privateKey
            }

            let jwtPayload = this.config_entity[baseEntity].connection.auth.options.jwtPayload
            if (!jwtPayload.iat) jwtPayload.iat = Math.floor(Date.now() / 1000) - 60
            if (!jwtPayload.exp) jwtPayload.exp = Math.floor(Date.now() / 1000) + 3600

            jwtClaims = {
              ...jwtPayload,
            }
            jwtOpts = {
              algorithm: 'RS256',
              header: {
                typ: 'JWT',
                alg: 'RS256',
              },
            }

            form = {
              grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
              assertion: jsonwebtoken.sign(jwtClaims, privateKey, jwtOpts),
            }
          }

          break

        default:
          throw new Error(`getAccessToken() none supported entity.${baseEntity}.connection.auth.type: '${this.config_entity[baseEntity]?.connection?.auth?.type}'`)
      }

      if (!tokenUrl) {
        throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing tokenUrl`)
      }

      this.scimgateway.logDebug(baseEntity, `${action}: Retrieving accesstoken`)
      const method = 'POST'
      let connOpt: any = {}
      if (this.config_entity[baseEntity].connection.options && typeof this.config_entity[baseEntity].connection.options === 'object') {
        connOpt = utils.copyObj(this.config_entity[baseEntity].connection.options)
      }
      if (!connOpt.headers) connOpt.headers = {}
      connOpt.headers['Content-Type'] = 'application/x-www-form-urlencoded' // body must be query string formatted (no JSON)

      const response = await this.doRequest(baseEntity, method, tokenUrl, form, ctx, connOpt)
      if (!response.body) {
        const err = new Error(`[${action}] No data retrieved from: ${method} ${tokenUrl}`)
        throw (err)
      }
      const jbody = response.body
      if (jbody.error) {
        const err = new Error(`[${action}] Error message: ${jbody.error_description}`)
        throw (err)
      }
      if (this.config_entity[baseEntity]?.connection?.auth?.type === 'token') { // in case response using token instead of access_token
        if (jbody.token) jbody.access_token = jbody.token
        else if (jbody.accessToken) jbody.access_token = jbody.accessToken
      }
      if (!jbody.access_token) {
        const err = new Error(`[${action}] Error message: Retrieved invalid token response`)
        throw (err)
      }

      const d = Math.floor(Date.now() / 1000) // seconds (unix time)
      jbody.validTo = d + parseInt(jbody.expires_in) // instead of using expires_on (clock may not be in sync with NTP, AAD default expires_in = 3600 seconds)

      this.lock.release()
      return jbody
    } catch (err) {
      this.lock.release()
      throw (err)
    }
  }

  /**
   * getServiceClient creates and return client.options on first call, successive calls returns already existing client.options
   * @param baseEntity baseEntity
   * @param method GET/PATCH/PUT/DELETE
   * @param path e.g., /Users having baseUrl from configuration added, or full url e.g. https://mycompany.com/Users
   * @param opt optional, connection options
   * @param ctx optional, ctx included if using Auth PassThrough
   * @returns client.options needed for connect
   */
  private async getServiceClient(baseEntity: string, method: string, path: string, opt?: any, ctx?: any) {
    const action = 'getServiceClient'

    let urlObj: any
    if (!path) path = ''
    try {
      urlObj = new URL(path)
    } catch (err) {
      //
      // path (no url) - default approach and client will be cached based on config
      //
      if (this._serviceClient[baseEntity]) { // serviceClient already exist - token specific
        this.scimgateway.logDebug(baseEntity, `${action}: Using existing client`)
        if (this._serviceClient[baseEntity].accessToken) {
          // check if token refresh is needed when using oauth
          const d = Math.floor(Date.now() / 1000) // seconds (unix time)
          if (this._serviceClient[baseEntity].accessToken.validTo < d + 30) { // less than 30 sec before token expiration
            this.scimgateway.logDebug(baseEntity, `${action}: Accesstoken about to expire in ${this._serviceClient[baseEntity].accessToken.validTo - d} seconds`)
            try {
              const accessToken = await this.getAccessToken(baseEntity, ctx)
              this._serviceClient[baseEntity].accessToken = accessToken
              this._serviceClient[baseEntity].options.headers['Authorization'] = ` Bearer ${accessToken.access_token}`
            } catch (err) {
              delete this._serviceClient[baseEntity]
              const newErr = err
              throw newErr
            }
          }
        }
      } else {
        this.scimgateway.logDebug(baseEntity, `${action}: Client have to be created`)
        let client = null
        if (this.config_entity && this.config_entity[baseEntity]) client = this.config_entity[baseEntity]
        if (!client) {
          const err = new Error(`unsupported baseEntity: ${baseEntity}`)
          throw err
        }
        if (!this.config_entity[baseEntity]?.connection?.baseUrls || !Array.isArray(this.config_entity[baseEntity].connection.baseUrls) || this.config_entity[baseEntity].connection.baseUrls.length < 1) {
          const err = new Error(`missing configuration entity.${baseEntity}.connection.baseUrls`)
          throw err
        }
        urlObj = new URL(this.config_entity[baseEntity].connection.baseUrls[0])
        const param: any = {
          baseUrl: this.config_entity[baseEntity].connection.baseUrls[0],
          options: {
            json: true, // json-object response instead of string
            headers: {
              Accept: 'application/json',
            },
            host: urlObj.hostname,
            port: urlObj.port, // null if https and 443 defined in url
            protocol: urlObj.protocol, // http: or https:
            // 'method' and 'path' added at the end
          },
        }

        // Support  no auth, header based auth (e.g., config {"options":{"headers":{"APIkey":"123"}}}),
        // basicAuth, bearerAuth, oauth, tokenAuth, oauthSamlBearer, oauthJwtBearer and auth PassTrough using request header authorization

        let orgConnection: any
        if (opt?.connection) { // allow overriding/extending configuration connection by caller argument opt.connection
          let org = this.config_entity[baseEntity]?.connection
          orgConnection = utils.copyObj(org)
          if (!org) org = {}
          org = utils.extendObj(org, opt.connection)
        }

        // may use configuration type='oauth' and auto corrected to 'oauthJwtBearer'
        if (this.config_entity[baseEntity]?.connection?.auth?.type == 'oauth') {
          if (this.config_entity[baseEntity].connection.auth?.options?.tenantIdGUID) {
            if (this.config_entity[baseEntity].connection.auth.options?.tls?.cert
              && this.config_entity[baseEntity].connection.auth.options?.tls?.key
              && this.config_entity[baseEntity].connection.auth.options.clientId
            ) this.config_entity[baseEntity].connection.auth.type = 'oauthJwtBearer'
          } else if (this.config_entity[baseEntity]?.connection?.auth?.options?.serviceAccountKeyFile) {
            this.config_entity[baseEntity].connection.auth.type = 'oauthJwtBearer'
          }
        }

        switch (this.config_entity[baseEntity]?.connection?.auth?.type) {
          case 'basic':
            if (!this.config_entity[baseEntity]?.connection?.auth?.options?.username || !this.config_entity[baseEntity]?.connection?.auth?.options?.password) {
              const err = new Error(`auth.type 'basic' - missing configuration entity.${baseEntity}.connection.auth.options.username/password`)
              throw err
            }
            param.options.headers['Authorization'] = 'Basic ' + Buffer.from(`${this.config_entity[baseEntity].connection.auth.options.username}:${this.config_entity[baseEntity].connection.auth.options.password}`).toString('base64')
            break
          case 'oauth':
            if (!this.config_entity[baseEntity]?.connection?.auth?.options?.clientId || !this.config_entity[baseEntity]?.connection?.auth?.options?.clientSecret) {
              const err = new Error(`auth.type 'oauth' - missing configuration entity.${baseEntity}.connection.auth.options.clientId/clientSecret`)
              throw err
            }
            param.accessToken = await this.getAccessToken(baseEntity, ctx)
            param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
            break
          case 'token':
            if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tokenUrl || !this.config_entity[baseEntity]?.connection?.auth?.options?.password) {
              const err = new Error(`missing configuration entity.${baseEntity}.connection.auth.options.tokenUrl/password`)
              throw err
            }
            param.accessToken = await this.getAccessToken(baseEntity, ctx)
            param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
            break
          case 'bearer':
            if (!this.config_entity[baseEntity]?.connection?.auth?.options?.token) {
              const err = new Error(`missing configuration entity.${baseEntity}.connection.auth.options.token`)
              throw err
            }
            param.options.headers['Authorization'] = 'Bearer ' + Buffer.from(this.config_entity[baseEntity].connection.auth.options.token).toString('base64')
            break
          case 'oauthSamlBearer':
            if (!this.config_entity[baseEntity]?.connection?.auth?.options?.samlPayload?.clientId || !this.config_entity[baseEntity]?.connection?.auth?.options?.samlPayload?.companyId
              || !this.config_entity[baseEntity]?.connection?.auth?.options?.tls?.key) {
              const err = new Error(`auth.type 'oauthSamlBearer' - missing configuration entity.${baseEntity}.connection.auth.options.tls and/or options.samlPayload.clientId/companyId`)
              throw err
            }
            param.accessToken = await this.getAccessToken(baseEntity, ctx)
            param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
            break
          case 'oauthJwtBearer':
            // auth.options.tenantIdGUID => Microsoft Entra ID
            // auth.options.serviceAccountKeyFile => Google Service Account
            // also support custom using tokenUrl/jwtPayload
            param.accessToken = await this.getAccessToken(baseEntity, ctx)
            param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
            break

          default:
            // no auth or PassTrough
        }

        if (orgConnection) {
          this.config_entity[baseEntity].connection = orgConnection // reset back to original
          if (opt?.connection) delete opt.connection
        }

        // proxy
        if (this.config_entity[baseEntity]?.connection?.proxy?.host) {
          const agent = new HttpsProxyAgent(this.config_entity[baseEntity].connection.proxy.host)
          param.options.agent = agent // proxy
          if (this.config_entity[baseEntity].connection.proxy.username && this.config_entity[baseEntity].connection.proxy.password) {
            param.options.headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(`${this.config_entity[baseEntity].connection.proxy.username}:${this.config_entity[baseEntity].connection.proxy.password}`).toString('base64') // using proxy with auth
          }
        }

        if (this.config_entity[baseEntity]?.connection?.options) { // http connect options
          const connOpt: any = utils.copyObj(this.config_entity[baseEntity].connection.options)
          try {
            // using fs.readFileSync().toString() instead of Bun.file().text() for nodejs compability
            if (connOpt?.tls?.key) connOpt.tls.key = fs.readFileSync(connOpt.tls.key).toString()
            if (connOpt?.tls?.cert) connOpt.tls.cert = fs.readFileSync(connOpt.tls.cert).toString()
            if (connOpt?.tls?.ca) connOpt.tls.ca = [fs.readFileSync(connOpt.tls.ca).toString()]
          } catch (err: any) {
            throw new Error(`tls configuration error: ${err.message}`)
          }
          if (connOpt.tls && Object.prototype.hasOwnProperty.call(connOpt.tls, 'rejectUnauthorized')) {
            if (connOpt.tls.rejectUnauthorized !== false && connOpt.tls.rejectUnauthorized !== true) {
              delete connOpt.tls.rejectUnauthorized
            }
          }
          // currently nodejs do not support fetch using tls options
          // connOpt.agent = new Agent({key/cert/ca/rejectUnauthorized: <>})
          // for tls and nodejs, environment must instead be used and set before started, e.g.,:
          //   export NODE_EXTRA_CA_CERTS=/plugin-path/config/certs/ca.pem
          //   export NODE_TLS_REJECT_UNAUTHORIZED=0
          param.options = utils.extendObj(param.options, connOpt)
        }

        if (!this._serviceClient[baseEntity]) this._serviceClient[baseEntity] = {}
        this._serviceClient[baseEntity] = param // serviceClient created

        // OData support
        this._serviceClient[baseEntity].nextLink = {} // OData pagination (Entra ID)
        this._serviceClient[baseEntity].nextLink.users = null
        this._serviceClient[baseEntity].nextLink.groups = null
      }

      if (ctx?.headers?.get) { // Auth PassThrough using ctx header
        this._serviceClient[baseEntity].options.headers['Authorization'] = ctx.headers.get('authorization')
      }
      const cli: any = utils.copyObj(this._serviceClient[baseEntity]) // client ready

      // failover support
      path = this._serviceClient[baseEntity].baseUrl + path
      urlObj = new URL(path)
      cli.options.host = urlObj.hostname
      cli.options.port = urlObj.port
      cli.options.protocol = urlObj.protocol

      // adding none static
      cli.options.method = method
      cli.options.path = `${urlObj.pathname}${urlObj.search}`
      if (opt) {
        if (opt?.connection) delete opt.connection // only used for internal connection options
        cli.options = utils.extendObj(cli.options, opt) // merge with argument options
      }

      return cli // final client
    }
    //
    // url path - none config based (enpoint.entity) and used as is (no cache)
    //
    this.scimgateway.logDebug(baseEntity, `${action}: Using raw client`)
    let options: any = {
      json: true,
      headers: {
        Accept: 'application/json',
      },
      host: urlObj.hostname,
      port: urlObj.port,
      protocol: urlObj.protocol,
      method: method,
      path: urlObj.pathname + urlObj.search,
    }

    // proxy
    if (this.config_entity[baseEntity]?.connection?.proxy?.host) {
      const agent = new HttpsProxyAgent(this.config_entity[baseEntity].connection.proxy.host)
      options.agent = agent // proxy
      if (this.config_entity[baseEntity].connection.proxy.username && this.config_entity[baseEntity].connection.proxy.password) {
        options.headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(`${this.config_entity[baseEntity].connection.proxy.username}:${this.config_entity[baseEntity].connection.proxy.password}`).toString('base64') // using proxy with auth
      }
    }

    // merge any argument options - support basic auth using {auth: {username: "username", password: "password"} }
    if (opt) {
      const o: any = utils.copyObj(opt)
      if (o.auth) {
        options.headers['Authorization'] = 'Basic ' + Buffer.from(`${o.auth.username}:${o.auth.password}`).toString('base64')
        delete o.auth
      }
      options = utils.extendObj(options, o)
    }

    const cli: any = {}
    cli.options = options
    return cli // final client
  }

  /**
   * updateServiceClient merges obj with _serviceClient
   * @param baseEntity 
   * @param obj 
   */
  private updateServiceClient(baseEntity: string, obj: any) {
    if (this._serviceClient[baseEntity]) this._serviceClient[baseEntity] = utils.extendObj(this._serviceClient[baseEntity], obj)
  }

  /**
  * doRequestHandler executes REST request and returns response  
  * started by public doRequest() and includes param retryCount
  * @param baseEntity baseEntity
  * @param method GET, PATCH, PUT, DELETE
  * @param path path e.g., /Users (baseUrls configuration will automatically be included) or use full url e.g., https://my-company.com/Users 
  * @param body body
  * @param ctx ctx when using Auth PassThrough
  * @param opt web-standard fetch client options, e.g., options not defined as general options in configuration file
  * @param retryCount internal use only - internal counter for retry and failover logic to other baseUrls defined
  **/
  private async doRequestHandler(baseEntity: string, method: string, path: string, body?: any, ctx?: any, opt?: any, retryCount?: number): Promise<any> {
    let retryAfter = 0
    try {
      const cli = await this.getServiceClient(baseEntity, method, path, opt, ctx)
      const options = cli.options
      let dataString = ''
      if (body) {
        if (options.headers['Content-Type']) {
          const type: string = options.headers['Content-Type'].toLowerCase().trim()
          if (type.startsWith('application/x-www-form-urlencoded')) {
            if (typeof body === 'string') dataString = body
            else dataString = querystring.stringify(body) // JSON to query string syntax + URL encoded
          } else {
            if (typeof body === 'string') dataString = body
            else dataString = JSON.stringify(body)
          }
        } else {
          options.headers['Content-Type'] = 'application/json; charset=utf-8'
          if (typeof body === 'string') dataString = body
          else dataString = JSON.stringify(body)
        }
        options.headers['Content-Length'] = Buffer.byteLength(dataString, 'utf8')
        options.body = dataString
      } else delete options.headers['Content-Type']
      const controller = new AbortController()
      const signal = controller.signal
      const timeout = setTimeout(() => controller.abort(), options.abortTimeout ? options.abortTimeout * 1000 : this.idleTimeout * 1000) // 120 seconds default abort timeout
      options.signal = signal
      const url = `${options.protocol}//${options.host}${options.port ? ':' + options.port : ''}${options.path}`
      // execute request
      const f = await fetch(url, options)
      clearTimeout(timeout)
      if (!f.status) throw new Error('response missing statusCode header')
      const result: any = {
        statusCode: f.status,
        statusMessage: f.statusText,
        body: null,
      }
      const contentType = f.headers.get('content-type')
      if (contentType) {
        if (contentType.includes('json')) result.body = await f.json()
        else {
          result.body = await f.text()
          try {
            result.body = JSON.parse(result)
          } catch (err) { void 0 }
        }
      }
      if (f.status < 200 || f.status > 299) {
        if (f.status === 429) { // throttle
          const v = f.headers.get('retry-after')
          if (v) retryAfter = parseInt(v, 10) + 1
          else retryAfter = 10
        }
        throw new Error(JSON.stringify(result))
      }
      this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${options.protocol}//${options.host}${(options.port ? `:${options.port}` : '')}${options.path} Body = ${JSON.stringify(body)} Response = ${JSON.stringify(result)}`)
      if (result.body && typeof result.body === 'object' && result.body['@odata.nextLink']) { // {"@odata.nextLink": "https://graph.microsoft.com/beta/users?$top=100&$skiptoken=xxx"}
        // OData paging
        const nextUrl = result.body['@odata.nextLink'].split('?')[1] // keep search query
        const arr = result['@odata.nextLink'].split('?')[0].split('/')
        const objType = (arr[arr.length - 1]) // users
        let startIndexNext = ''
        if (this._serviceClient[baseEntity].nextLink[objType]) {
          for (const k in this._serviceClient[baseEntity].nextLink[objType]) {
            if (this._serviceClient[baseEntity].nextLink[objType][k] === nextUrl) return result // repetive startIndex=1
            startIndexNext = k
            break
          }
        }
        const a = result.body['@odata.nextLink'].split('top=')
        let top = '0'
        if (a.length > 1) {
          top = a[1].split('&')[0]
        }
        if (!startIndexNext) startIndexNext = (Number(top) + 1).toString()
        else startIndexNext = (Number(startIndexNext) + Number(top) + 1).toString()
        // reset and set new nextLink
        this._serviceClient[baseEntity].nextLink[objType] = {}
        this._serviceClient[baseEntity].nextLink[objType][startIndexNext] = nextUrl
      }
      return result
    } catch (err: any) { // includes failover/retry logic based on config baseUrls array
      let statusCode
      try { statusCode = JSON.parse(err.message).statusCode } catch (e) { void 0 }
      if (err.message.includes('ratelimit')) { // have seen throttling not follow standard 429/retry-after, but instead using 500 and error message only
        if (!retryAfter) retryAfter = 60
      }
      if (!retryCount) retryCount = 0
      let urlObj
      try { urlObj = new URL(path) } catch (err) { void 0 }
      let isServiceClient = !urlObj && this._serviceClient[baseEntity] && !this.lock.isLocked() // !isLocked to avoid retry ongoing doRequest with failing getAccessToken()
      let oAuthTokeErr = statusCode === 401 && this.config_entity[baseEntity].connection?.auth?.type && this.config_entity[baseEntity].connection.auth.type.startsWith('oauth')
      if (isServiceClient && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ABORT_ERR' || err.code === 'ETIMEDOUT' || oAuthTokeErr || retryAfter)) {
        this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
        if (retryAfter) {
          this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} throttle/ratelimit error - awaiting ${retryAfter} seconds before automatic retry`)
          await new Promise(resolve => setTimeout(function () {
            resolve(null)
          }, retryAfter * 1000))
        }
        if (retryCount < this.config_entity[baseEntity].connection.baseUrls.length) {
          retryCount++
          this.updateServiceClient(baseEntity, { baseUrl: this.config_entity[baseEntity].connection.baseUrls[retryCount - 1] })
          this.scimgateway.logDebug(baseEntity, `${(this.config_entity[baseEntity].connection.baseUrls.length > 1) ? 'failover ' : ''}retry[${retryCount}] using baseUrl = ${this._serviceClient[baseEntity].baseUrl}`)
          if (oAuthTokeErr) {
            delete this._serviceClient[baseEntity] // ensure new getAccessToken request - token used should not have been expired, but rejected for other reason e.g. token server restart and no persistent token store?
          }
          const ret = await this.doRequestHandler(baseEntity, method, path, body, ctx, opt, retryCount) // retry
          return ret // problem fixed
        } else {
          if (statusCode === 404) { // not logged as error e.g. getUser-manager
            this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
          } else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
          throw err
        }
      } else {
        if (statusCode === 404) { // not logged as error e.g. getUser-manager
          this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
        } else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
        if (statusCode === 401) delete this._serviceClient[baseEntity]
        throw err
      }
    }
  }

  /**
  * doRequest executes REST request and return response 
  * @param baseEntity baseEntity
  * @param method GET, PATCH, PUT, DELETE
  * @param path path e.g., /Users (baseUrls configuration will be used), optional use full url e.g., https://my-company.com/Users 
  * @param body optional, body
  * @param ctx optional, ctx when using Auth PassThrough
  * @param opt optional, web-standard fetch client options, e.g., using custom options not defined as general options in configuration file
  * @remarks
  * configuration file description  
  * ```
  * {
  *   "scimgateway": { ... }
  *   "endpoint": {
  *     "entity": {
  *       "undefined": {
  *         "connection": {
  *           "baseUrls": [  // ignored when using option tenantIdGUID
  *             "<baseUrl>", // "https://host1.company.com:8880",
  *             "<baseUrl2>" // optional using several baseUrls for failover
  *           ],
  *          "auth": {
  *            "type": "<type>",
  *            "options": { <auth.options> }
  *           },
  *           "options": { <connection.options> }
  *           "proxy": {
  *             "host": "<host>", // http://proxy-host:1234
  *             "username": "<username>", // username if authentication is required
  *             "password": "<password>" // password if authentication is required
  *           }
  *         }
  *       }
  *     }
  *   }
  * }
  * ```
  * type defines authentication being used  
  * if type not defined, no authentication used  
  * valid type is: `basic`, `oauth`, `token`, `bearer`, `oauthSamlBearer` or `oauthJwtBearer`  
  * 
  * for each valid type there are different auth.options  
  * 
  * type=**"basic"** having auth.options:
  * ```
  * {
  *   "options": {
  *      "username": "<username>",
  *      "password": "<password>"
  *    }
  * }
  * ```
  * 
  * type=**"oauth"** having auth.options:
  * ```
  * {
  *   "options": {
  *     "tenantIdGUID": "<Entra ID tenantIdGUID", // Entra ID authentication - if baseUrls not defined, baseUrls automatically set to [https://graph.microsoft.com/beta]
  *     "tokenUrl": "<tokenUrl>", // must be set if not using tenantIdGUID
  *     "clientId": "<clientId>",
  *     "clientSecret": "<clientSecret>"
  *   }
  * }
  * ```
  * 
  * type=**"token"** having auth.options:
  * ```
  * {
  *   "options": {
  *     "tokenUrl": "<url for requesting token">
  *     "username": "<user name for token request>"
  *     "password": "<password for token request>"
  *   }
  * }
  * ```
  * 
  * type=**"bearer"** having auth.options:
  * ```
  * {
  *   "options": {
  *     "token": "<bearer token to be used">
  *   }
  * }
  * ```
  * 
  * type=**"oauthSamlBearer"** having auth.options:
  * ```
  * {
  *   "options": {
  *     "tokenUrl": "<tokenUrl>",
  *     "samlPayload": {
  *       "clientId": "<clientId>",
  *       "companyId": "<companyId>",
  *       "nameId": "<nameId>",
  *       "lifetime": "<optional>"
  *       "issuer": "<optional>",
  *       "userIdentifierFormat": "<optional>",
  *       "audience": "<optional>"
  *     },
  *     "tls": {
  *       "key": "<key-file-name>", // location: config/certs
  *       "cert": "<cert-file-name>", // location: config/certs
  *     }
  *   }
  * }
  * ```
  * 
  * type=**"oauthJwtBearer"** having auth.options:
  * ```
  * // Microsoft Entra ID
  * {
  *   "options": {
  *     "tenantIdGUID": "<Entra ID tenantIdGUID", // Entra ID authentication, if baseUrls not defined, baseUrls automatically set to [https://graph.microsoft.com/beta]
  *     "clientId": "<clientId>",
  *     "tls": { // files located in ./config/certs
  *       "key": "key.pem",
  *       "cert": "cert.pem"
  *     }
  *   }
  * }
  * 
  * // Google Cloud Platform - GCP
  * {
  *   "options": {
  *     "serviceAccountKeyFile": "<Google Service Account key file name>", // located in ./config/certs. If baseUrls not defined, baseUrls automatically set to [https://www.googleapis.com]
  *     "scope": "<jwt-scope>",
  *     "subject": "<jwt-subject>
  *   }
  * }
  * 
  * // General JWT API
  * {
  *   "options": {
  *     "tokenUrl":  "<tokenUrl",
  *     "tls": {
  *       "key": "<signing-key-file-name>" // key.pem file located in ./config/certs
  *      },
  *     "jwtPayload": {
  *       "sub": "<subject>",
  *       "iss": "<issuer>",
  *       "aud": "<audience>",
  *       ...
  *     }
  *   }
  * }
  * ```
  * 
  * **connection.options** can be set according to web-standard fetch client options  
  * examples:  
  * ```
  * {
  *   "options": {
  *     "tls": {
  *       "key": "<key-file-name>", // location: config/certs
  *       "cert": "<cert-file-name>", // location: config/certs
  *       "ca": "<ca-file-name>", // location: config/certs
  *       "rejectUnauthorized": <true/false>
  *      },
  *     "headers": {
  *       "<header1>", "<key1>",
  *       "<header2>", "<key2>"
  *      }
  *   }
  * }
  * ```
  * 
  **/
  public async doRequest(baseEntity: string, method: string, path: string, body?: any, ctx?: any, opt?: any) {
    return await this.doRequestHandler(baseEntity, method, path, body, ctx, opt)
  }

  /**
  * nextLinkPaging returns paging url when using OData e.g., Entra ID 
  * @param baseEntity baseEntity
  * @param objType e.g., 'users' or 'groups', a type that corresponds with what's being used by endpoint url request
  * @param startIndex SCIM startIndex paramenter
  * @returns paging url to be used
  **/
  public nextLinkPaging(baseEntity: string, objType: string, startIndex: number) {
    objType = objType.toLowerCase() // users or groups
    let nextPath = ''
    if (!startIndex || !this._serviceClient[baseEntity]) return ''
    if (startIndex < 2) {
      if (this._serviceClient[baseEntity].nextLink[objType]) {
        this._serviceClient[baseEntity].nextLink[objType] = null
      }
      return ''
    }
    if (this._serviceClient[baseEntity].nextLink[objType]) {
      if (this._serviceClient[baseEntity].nextLink[objType][startIndex]) {
        nextPath = `/users?${this._serviceClient[baseEntity].nextLink[objType][startIndex]}`
      } else {
        this._serviceClient[baseEntity].nextLink[objType] = null
        return ''
      }
    } else {
      return ''
    }
    return nextPath
  }

  /**
  * getGraphUrl returns Microsoft Graph API url used for Entra ID 
  * @returns Microsoft Graph API url
  **/
  public getGraphUrl(): string {
    return this.graphUrl
  }
} // class HelperRest
