/**
 * Build-in UnityBase endpoints. Are registered during {@link module:@unitybase/ub~start UB.start}
 *
 * In addition to endpoints documented below endpoints `ubql`, `stat`, `auth`, `logout` and `timeStamp` are implemented
 * inside native code (will be moved to JavaScript in future releases).
 *
 * @module endpoints
 * @memberOf module:@unitybase/ub
 */
// author pavel.mash on 13.10.2016
const fs = require('fs')
const path = require('path')
const _ = require('lodash')
const mime = require('mime-types')
const WebSockets = require('./web-sockets')
const App = require('./App')
const Session = require('./Session')
const { PROXY_SEND_FILE_HEADER, PROXY_SEND_FILE_LOCATION_ROOT } = require('./httpUtils')
const ubErrors = require('./ubErrors')
// eslint-disable-next-line camelcase
const { uba_common, GC_KEYS } = require('@unitybase/base')
// eslint-disable-next-line n/no-deprecated-api
const appBinding = process.binding('ub_app')
const options = require('@unitybase/base').options
const { formatByPattern } = require('@unitybase/cs-shared')
const AUTH_MOCK = options.switchIndex('-authMock') >= 0

// init zonesAuthenticationMethods
const ZONES_AUTH_MAP = {}
let USE_ZONE_AUTH = false
const zonesAuthenticationMethods = App.serverConfig.security.zonesAuthenticationMethods
if (zonesAuthenticationMethods && zonesAuthenticationMethods.length) {
  const availableAuthMethods = App.serverConfig.security.authenticationMethods
  zonesAuthenticationMethods.forEach(z => {
    const zam = ZONES_AUTH_MAP[z.name] = []
    z.authenticationMethods.forEach(am => {
      if (availableAuthMethods.indexOf(am) !== -1) {
        zam.push(am)
        USE_ZONE_AUTH = true
      } else {
        console.warn(`Authentication method ${am} for security zone ${z.name} ignored because it is not found in the security.authenticationMethods`)
      }
    })
  })
}
/**
 * @param {string} reqPath
 * @param {THTTPResponse} resp
 * @private
 */
function resolveModelFile (reqPath, resp) {
  let entry = {
    fullPath: ''
  }
  // cache actual file path & type for success models/* request
  const cached = App.memCacheGet(`${GC_KEYS.UB_MODELS_REQ_}${reqPath}`, { ignoreTenants: true })
  if (!cached) {
    const parts = reqPath.replace(/\\/g, '/').split('/')
    const modelName = parts.shift()
    if (!modelName) {
      return resp.badRequest('first part of path must be model name')
    }
    const model = App.domainInfo.models[modelName]
    if (!model) {
      return resp.badRequest('no such model ' + modelName)
    }
    entry.fullPath = path.normalize(path.join(model.realPublicPath, parts.join('/')))
    if (!entry.fullPath) {
      return resp.badRequest('cant resolve relative path')
    }
    if (!entry.fullPath.startsWith(model.realPublicPath)) {
      return resp.badRequest(`resolved path "${entry.fullPath}" is not inside model folder ${model.realPublicPath}`)
    }
    if (!fs.existsSync(entry.fullPath)) {
      return resp.notFound(`"${entry.fullPath}"`)
    }
    const stat = fs.statSync(entry.fullPath)
    if (stat.isDirectory()) {
      return resp.badRequest(`Prevent access to folder "${entry.fullPath}"`)
    }
    if (PROXY_SEND_FILE_HEADER) {
      entry.fullPath = path.relative(process.configPath, entry.fullPath)
    } else {
      const ct = mime.contentType(parts.pop())
      if (ct) {
        entry.mimeHead = 'Content-Type: ' + ct
      }
    }
    App.memCachePut(`${GC_KEYS.UB_MODELS_REQ_}${reqPath}`, JSON.stringify(entry), { ignoreTenants: true })
  } else {
    console.debug('model file is resolved from cache')
    entry = JSON.parse(cached)
  }
  if (PROXY_SEND_FILE_HEADER) {
    const head = `${PROXY_SEND_FILE_HEADER}: /${PROXY_SEND_FILE_LOCATION_ROOT}/app/${entry.fullPath}`
    console.debug('<- ', head)
    resp.writeHead(head)
    resp.writeEnd('')
  } else {
    resp.writeEnd(entry.fullPath)
    resp.writeHead('Content-Type: !STATICFILE')
    if (entry.mimeHead) {
      resp.writeHead(entry.mimeHead)
    }
  }
  resp.statusCode = 200
}

/**
 * The `models` endpoint. Responsible for return a static files content from a model publicPath folders
 *
 * For example request `GET http://host:port/models/modelName/fileName`
 * will return a file from a public folder of a model `modelName`
 *
 * @param {THTTPRequest} req
 * @param {THTTPResponse} resp
 */
function modelsEp (req, resp) {
  if ((req.method !== 'GET') && (req.method !== 'HEAD')) {
    return resp.badRequest('invalid request method ' + req.method)
  }
  const reqPath = req.decodedUri
  if (!reqPath || !reqPath.length || (reqPath.length > 250)) {
    return resp.badRequest('path too long (max is 250) ' + reqPath.length)
  }
  resolveModelFile(reqPath, resp)

  // cache forever - do not cache index*.html
  // resp.writeHead('Content-Type: text/html\r\nCache-Control: no-cache, no-store, max-age=0, must-revalidate\r\nPragma: no-cache\r\nExpires: Fri, 01 Jan 1990 00:00:00 GMT');
}

const MODULES_ROOT = path.join(process.configPath, 'node_modules')

/**
 * The `clientRequire` endpoint. Used by client side loaders (SystemJS for example) to emulate commonJS require
 *
 * **Security note**: It is __very important__ to prevent loading a server-side logic to the client - server-side logic MUST be hidden from clients
 *
 * To do this `clientRequire` endpoint:
 *
 *   - allow access only to modules inside application `node_modules` folder
 *   - in case requested module is a UnityBase model (present in ubConfig.json) then restrict access to non-public part of such model
 *
 * So developer should list all the modules that contain a sensitive server-side business logic inside the application
 * config and set a `moduleName` parameter correctly for such models.
 *
 * On the production where nginx is used as a reverse proxy requests to /clientRequire is intercepted by nginx and
 * returns a static file previously linked by `ubcli linkStatic` command, which uses the same resolve algorithm as described above
 *
 * @param {THTTPRequest} req
 * @param {THTTPResponse} resp
 */
function clientRequireEp (req, resp) {
  if ((req.method !== 'GET') && (req.method !== 'HEAD')) {
    return resp.badRequest('invalid request method ' + req.method)
  }
  const reqPath = req.decodedUri
  // cache actual file path & type for success clientRequire/* request
  const cached = App.memCacheGet(`${GC_KEYS.UB_CLIENT_REQ_}${reqPath}`, { ignoreTenants: true })
  let entry = {
    fullPath: ''
  }
  if (!cached) {
    if (!reqPath || !reqPath.length || (reqPath.length > 250)) {
      return resp.badRequest('path too long (max is 250) ' + reqPath.length)
    }

    if (reqPath.startsWith('models/')) {
      resolveModelFile(reqPath.slice('models/'.length), resp)
      return
    }

    if (reqPath.indexOf('..') !== -1) { // prevent clientRequire/../../../secret.txt attack
      return resp.badRequest(`Relative path (${reqPath}) not allowed`)
    }
    if (path.isAbsolute(reqPath)) { // prevent clientRequire/d:/secret.txt attack
      return resp.badRequest(`Absolute path (${reqPath}) not allowed`)
    }
    let resolvedPath
    try {
      console.debug(`Trying to resolve ${reqPath}`)
      // preventSymlinks emulation. code below will resolve to realPath
      // what can be outside the application folder if packages are symlinked
      // resolvedPath = require.resolve(reqPath, {
      //   paths: [MODULES_ROOT]
      // })
      resolvedPath = path.resolve(MODULES_ROOT, reqPath)
      if (!fs.existsSync(resolvedPath)) { // try js file
        resolvedPath = resolvedPath + '.js'
      }
      const stat = fs.statSync(resolvedPath)
      if (stat.isDirectory()) {
        const pkgName = path.join(resolvedPath, 'package.json')
        if (fs.existsSync(pkgName)) {
          const pkgMain = JSON.parse(fs.readFileSync(pkgName, 'utf8')).main || './index.js'
          resolvedPath = path.join(resolvedPath, pkgMain)
        } else {
          return resp.badRequest(`package.json not found in folder "${resolvedPath}"`)
        }
      }
    } catch (e) {
      console.error(e)
      resolvedPath = undefined
    }
    if (!resolvedPath) {
      return resp.notFound(`Package ${reqPath} not found`)
    }
    if (!resolvedPath.startsWith(MODULES_ROOT)) {
      return resp.badRequest(`Path (${reqPath}) must be inside application node_modules folder but instead resolved to ${resolvedPath}`)
    }

    const models = App.domainInfo.models
    let restrictAccess = false
    // allow access to package.json for dynamically load a module from UI
    if (!reqPath.endsWith('/package.json')) {
      // in case this is request to UnityBase model - check resolved file is inside model public folder
      _.forEach(models, (model) => {
        if (model.moduleName &&
          // do not compare req @unitybase/ub-pub with module @unitybase/ub
          ((reqPath === model.moduleName) || reqPath.startsWith(model.moduleName + '/')) &&
          !resolvedPath.startsWith(model.realPublicPath)
        ) {
          restrictAccess = true
          return false
        }
      })
    }

    if (restrictAccess) {
      return resp.badRequest(`Request to UnityBase model ${reqPath} resolved to (${resolvedPath}) which is not inside any of public models folder`)
    }
    entry.fullPath = resolvedPath
    if (PROXY_SEND_FILE_HEADER) {
      entry.fullPath = path.relative(process.configPath, entry.fullPath)
    } else {
      const ct = mime.contentType(path.extname(resolvedPath))
      if (ct) {
        entry.mimeHead = 'Content-Type: ' + ct
      }
    }
    App.memCachePut(`${GC_KEYS.UB_CLIENT_REQ_}${reqPath}`, JSON.stringify(entry), { ignoreTenants: true })
    console.debug(`Resolved ${reqPath} -> ${resolvedPath}`)
  } else {
    entry = JSON.parse(cached)
    console.debug(`Resolved from cache ${reqPath} -> ${entry.fullPath}`)
  }
  if (PROXY_SEND_FILE_HEADER) {
    const head = `${PROXY_SEND_FILE_HEADER}: /${PROXY_SEND_FILE_LOCATION_ROOT}/app/${entry.fullPath}`
    console.debug('<- ', head)
    resp.writeHead(head)
    resp.writeEnd('')
  } else {
    resp.writeEnd(entry.fullPath)
    resp.writeHead('Content-Type: !STATICFILE')
    if (entry.mimeHead) {
      resp.writeHead(entry.mimeHead)
    }
  }
  resp.statusCode = 200
}

/**
 * The `getAppInfo` endpoint. Responsible for return an information about application required for an
 * initial client side connectivity and UI setup
 *
 * Models can extend response by subscribes to App.on('getAppInfo') event and modify
 * a payload object - see example in `getAppInfo` event doc
 *
 * @param {THTTPRequest} req
 * @param {THTTPResponse} resp
 */
function getAppInfoEp (req, resp) {
  const serverConfig = App.serverConfig

  let authMethods
  if (USE_ZONE_AUTH) {
    if (!Session.zone) console.warn(`Security zone for IP ${Session.callerIP} is empty`)
    // eslint-disable-next-line no-prototype-builtins
    if (!ZONES_AUTH_MAP.hasOwnProperty(Session.zone)) {
      console.warn(`Authentication methods not configured for "${Session.zone}" security zone`)
    } else {
      authMethods = ZONES_AUTH_MAP[Session.zone]
    }
  } else {
    authMethods = serverConfig.security.authenticationMethods
  }
  // getAppInfo event handlers can mutate appInfo
  const appInfo = _.cloneDeep({
    appVersion: App.package.version,
    serverVersion: process.version,
    defaultLang: serverConfig.application.defaultLang,
    simpleCertAuth: serverConfig.security.simpleCertAuth,

    authMethods: authMethods || [],

    supportedLanguages: serverConfig.application.domain.supportedLanguages || ['en'],
    supportedIrregularLang: serverConfig.application.localization ? serverConfig.application.localization.irregularLangToLocaleMap : undefined,

    supportedWSProtocols: process.isWebSocketEnabled ? WebSockets.registeredProtocols() : [],

    uiSettings: serverConfig.uiSettings || {},
    authMock: AUTH_MOCK || undefined
  })
  /**
   * Models can modify `getAppInto` response by mutate an `appInto` parameter of `getAppInfo` App event.
   *
   * **WARNING** `getAppInto` endpoint is called often, so event handler should be as fast as possible.
   * Since `getAppInto` endpoint is called for non-authorised users, response MUST NOT contain a sensitive data
   *
   * @example
App.on('getAppInfo', function(appInfo) {
  const serverConfig = App.serverConfig
  const DSTU = serverConfig.security && serverConfig.security.dstu
  appInfo.trafficEncryption = DSTU ? DSTU.trafficEncryption : false
})
   * @event getAppInfo
   * @memberOf ServerApp
   * @param {object} appInfo
   */
  App.emit('getAppInfo', appInfo) // allow models to extend an appInfo
  if (App.isLicenseExceed) appInfo.isLicenseExceed = true
  resp.writeEnd(appInfo)
  resp.statusCode = 200
  resp.validateETag()
}

// const RESTRICTED_ENTITY_PROPS = ['connectionConfig', 'dbExtensions', 'mapping'] // adv allow mapping
// const RESTRICTED_ATTRIBUTES_PROPS = ['physicalDataType', 'generateFK', 'mapping'] // adv allow mapping
// const RESTRICTED_MODEL_PROPS = ['realPublicPath'] // adv allow realPublicPath
// const advancedDomainInfoEntityProps = []
//
// function domainReplacer (key, val) {
//   if (!this) return undefined
//   if (this instanceof UBDomain.UBEntity) {
//     // at last one of entity method is accessible
//     if (!this.haveAccessToAnyMethods(Object.keys(this.entityMethods))) return undefined
//     // serialize only allowed properties
//     return RESTRICTED_ENTITY_PROPS.find(elm => elm === key) ? undefined : val
//   } else if (this instanceof UBDomain.UBEntity.UBEntityAttribute) {
//     // skip empty string
//     if ((typeof val === 'string') && !val) return undefined
//     if ((key === 'customSettings') && !Object.keys(val).length) return undefined
//     return RESTRICTED_ATTRIBUTES_PROPS.find(elm => elm === key) ? undefined : val
//   } else if (this instanceof UBDomain.UBModel) {
//     return RESTRICTED_MODEL_PROPS.find(elm => elm === key) ? undefined : val
//   } else {
//     return val
//   }
// }

const authenticationHandled = appBinding.handleAuthentication
const nativeGetDomainInfo = appBinding.getDomainInfo
/**
 * The `getDomainInfo` endpoint.
 * Return JSON representation of application Domain according to caller rights
 *
 * @param {THTTPRequest} req
 * @param {THTTPResponse} resp
 */
function getDomainInfoEp (req, resp) {
  // implementation below is SLOW. The bottleneck is JSON.stringify() for huge JSON
  // let restrictedDomain = {
  //   domain: App.domainInfo.entities, // _.filter(App.domainInfo.entities, (e) => e.code.startsWith('ubm')),
  //   models: App.domainInfo.models
  // }
  // let res = JSON.stringify(restrictedDomain, domainReplacer)

  const params = req.parsedParameters
  const isExtended = (params.extended === 'true')
  // eslint-disable-next-line camelcase
  if (isExtended && authenticationHandled && !uba_common.isSuperUser()) {
    return resp.badRequest('Extended domain info allowed only for member of admin group of if authentication is disabled')
  }
  if (!params.userName) {
    return resp.badRequest('userName=login parameter is required')
  }
  if (params.userName !== Session.uData.login) {
    return resp.badRequest(`passed userName=${params.userName} not match current session user "${Session.uData.login}"`)
  }

  nativeGetDomainInfo(isExtended, true /* write to resp */)
  resp.statusCode = 200
  resp.validateETag()
}

/**
 * Default endpoint. Will be called in case URL do not start form a known endpoint.
 * Current implementation will handle static files from `ServerConfig.HTTPServer.inetPub` folder
 *
 * @param {THTTPRequest} req
 * @param {THTTPResponse} resp
 */
function staticEp (req, resp) {
  if (!App.staticPath) return resp.notFound('httpServer.inetPub is empty')
  if (PROXY_SEND_FILE_HEADER) { // redirect to statics endpoint handled by nginx
    const head = req.url.startsWith('statics/')
      ? `${PROXY_SEND_FILE_HEADER}: /${req.url}`
      : `${PROXY_SEND_FILE_HEADER}: /statics/${req.url}`
    console.debug('<- ', head)
    resp.writeHead(head)
    resp.writeEnd('')
    resp.statusCode = 200
  } else {
    if ((req.method !== 'GET') && (req.method !== 'HEAD')) {
      return resp.badRequest('invalid request method ' + req.method)
    }
    const reqPath = req.decodedUri
    console.log('reqPath', reqPath)
    if (!reqPath || !reqPath.length || (reqPath.length > 250)) {
      return resp.badRequest('path too long (max is 250) ' + reqPath.length)
    }
    const normalized = path.normalize(path.join(App.staticPath, reqPath))
    if (!normalized.startsWith(App.staticPath)) {
      return resp.badRequest(`statics: resolved path "${normalized}" is not inside inetPub folder ${App.staticPath}`)
    }
    if (!fs.existsSync(normalized)) {
      return resp.notFound(`"${normalized}"`)
    }
    const stat = fs.statSync(normalized)
    if (stat.isDirectory()) {
      return resp.badRequest(`Prevent access to folder "${normalized}"`)
    }

    const ext = path.extname(normalized)
    const ct = mime.contentType(ext)
    resp.writeEnd(normalized)
    resp.writeHead('Content-Type: !STATICFILE')
    if (ct) {
      resp.writeHead('Content-Type: ' + ct)
    }
    resp.statusCode = 200
  }
}

const EXPECT_RESULT_RE = /^[ \r\n\t]*(select|pragma)/i // start with optional RC LF TAB or SPACE and when select or pragma
/**
 * Run sql query on server side. Allowed from local IP's.
 *
 * Connection name is in `connection` uri parameter (or default connection if not set)
 *  - If HTTP verb is GET then allowed inline parameters only and sql is in `sql` uri parameter
 *  - If HTTP verb is POST then sql is in request body and query parameters is uri parameters except `connection`
 *
 * @param {THTTPRequest} req
 * @param {THTTPResponse} resp
 * @private
 */
function runSQLEp (req, resp) {
  if (App.localIPs.indexOf(Session.callerIP) === -1) {
    throw new Error(`Only local execution allowed. Caller remoteIP="${Session.callerIP}"`)
  }

  const parameters = req.parsedParameters
  const connectionName = parameters.connection || parameters.CONNECTION || App.domainInfo.defaultConnection.name
  const conn = App.dbConnections[connectionName]

  if (!conn) throw new Error(`runSQL: Unknown connection ${connectionName}`)

  let sql, sqlParams
  if (req.method === 'GET') {
    sql = parameters.sql
    sqlParams = null
  } else if ((req.method === 'POST') || (req.method === 'PUT')) {
    sql = req.read('utf-8')
    delete parameters.connection
    sqlParams = parameters
  } else {
    return resp.badRequest('runSQL: invalid verb ' + req.method)
  }

  if (!sql) throw new Error('runSQL: statement is empty')
  if (EXPECT_RESULT_RE.test(sql)) {
    const result = conn.run(sql, sqlParams)
    resp.writeEnd(result)
  } else {
    conn.exec(sql, sqlParams)
    resp.writeEnd('')
  }
  resp.statusCode = 200
}

/**
 * Execute entity level method with direct access to the HTTP request and response.
 * Client calls such methods using `POST /rest/entityCode/methodCode`.
 *
 * The main purpose is to create an entity-level method what accept a binary request body or returns a binary response
 *
 * REST methods handler is called with 3 parameters: `(ctxt: null, req: THTTPRequest, resp: THTTPResponse)`
 * and should fill a resp object props properly.
 *
 * Endpoint verify user has access to method.
 *
 * @example
// define entity method what can be called either using `/rest` or usong `/ubql`
me.entity.addMethod('getCertificate')
me.getCertificate = function (ctxt, req, resp) {
  let certID
  if (req) { // endpoint is called as rest/uba_usercertificate/getCertificate?ID=1231
    if (!req.parsedParameters.ID) {
      return resp.badRequest('Missed ID; Expext URL to be rest/uba_usercertificate/getCertificate?ID=1231')
    }
    certID = req.parsedParameters.ID
  } else {
    certID = ctxt.mParams.ID
  }
  const store = UB.Repository('uba_usercertificate')
    .attrs(['ID', 'certificate'])
    .where('ID', '=', certID).select()
  if (store.eof) throw new Error('not found')

  let certificate = store.getAsBuffer('certificate')
  if (req) { // called as rest
    resp.writeEnd(certificate)
    resp.writeHead('Content-Type: application/x-x509-user-cert')
    resp.statusCode = 200
  } else {
    certificate = Buffer.from(certificate)
    certificate = certificate.toString('base64')
    ctxt.dataStore.initialize({ fieldCount: 1, values: ['certificate', certificate], rowCount: 1 })
  }
}

// on client side
// call using rest:
const certResp = await UB.connection.get('/rest/uba_usercertificate/getCertificate?ID=334607980199937')
const certBin = certResp.data
// call using ubql:
const certResp = await UB.connection.query({entity: 'uba_usercertificate', method: 'getCertificate', ID:334607980199937})
const certBase64 = certResp.resultData.data[0][0]
 *
 * @param {THTTPRequest} req
 * @param {THTTPResponse} resp
 */
function restEp (req, resp) {
  const INVALID_PARAMS = 'REST: parameters are invalid'
  if (req.uri === '') return resp.badRequest(INVALID_PARAMS)
  const parts = req.uri.split('/')
  const entity = App.domainInfo.get(parts[0], false)
  if (!entity) return resp.badRequest('REST: unknown entity ' + parts[0])
  const method = parts[1]
  if (!method) return resp.notImplemented('REST: await "entity/method" in url')
  if (!entity.haveAccessToMethod(method)) throw new ubErrors.ESecurityException(`REST: unknown method or access deny ${entity.code}.${method}`)
  // TODO - must be implemented using launchMethod to emit :before and :after events etc.
  global[entity.code][method](null, req, resp)
  return true
}

/**
 * Return a single localization script bundled from all models public/locale/lang-${Session.userLang} scripts
 * excluding adminui-pub what injected before login window
 *
 *      GET allLocales?lang=en
 *      GET allLocales?lang=en&json=1&includeDomain=1&includeData=1
 *
 * @param {THTTPRequest} req
 * @param {THTTPResponse} resp
 * @private
 */
function allLocalesEp (req, resp) {
  const { lang, json, includeData, includeDomain } = req.parsedParameters
  if (!lang || lang.length > 5) return resp.badRequest('lang parameter required')
  const supportedLanguages = App.serverConfig.application.domain.supportedLanguages || ['en']
  if (supportedLanguages.indexOf(lang) === -1) return resp.badRequest('unsupported language')

  if (json) {
    // do not move a metadataTransformation require from here - this file required by native!!
    const loadDomainIntoJS = require('./metadataTransformation')
    const resources = {}

    const addDescriptionAndDocumentation = (resourceKey, meta) => {
      if (meta.captionSingular) {
        resources[resourceKey + '#captionSingular'] = meta.captionSingular
      }
      if (meta.description) {
        resources[resourceKey + '#description'] = meta.description
      }
      if (meta.documentation) {
        resources[resourceKey + '#documentation'] = meta.documentation
      }
    }

    if (includeDomain) {
      if (App.localIPs.indexOf(Session.callerIP) === -1) {
        resp.writeEnd('includeDomain is not allowed for remote clients')
        resp.statusCode = 403
        return
      }

      const rawDomain = loadDomainIntoJS(true)

      for (const [entityCode, { langs }] of Object.entries(rawDomain)) {
        const meta = rawDomain[entityCode].meta
        const i18n = langs && langs[lang]
        const localizedEntity = i18n ? _.merge(meta, i18n) : meta // merge locale into entity definition = the same as in UBEntity constructor

        if (localizedEntity.caption) {
          resources[entityCode] = localizedEntity.caption
        }
        addDescriptionAndDocumentation(entityCode, localizedEntity)
        if (localizedEntity.attributes) {
          for (const attr of localizedEntity.attributes) {
            const attrResourceKey = entityCode + '.' + attr.name
            resources[attrResourceKey] = attr.caption
            addDescriptionAndDocumentation(attrResourceKey, attr)
          }
        }
      }
    }

    const mergeFileIntoResources = (...dirComponents) => {
      if (!dirComponents[0]) return

      const localeFile = path.join(...dirComponents, `locale/lang-${lang}.json`)
      if (fs.existsSync(localeFile)) {
        _.merge(resources, require(localeFile))
      }
    }

    for (const model of App.domainInfo.orderedModels) {
      mergeFileIntoResources(model.realPublicPath)
      if (includeData) mergeFileIntoResources(model.realPath, '_data')
    }

    resp.writeEnd(resources)
  } else {
    // Get lang-<xx>.js and lang-<xx>.json together and wrap as executable client-side code
    let cached = App.globalCacheGet(`${GC_KEYS.UB_LOCALE_REQ_}${lang}`)
    if (!cached) {
      cached = ' '
      for (const model of App.domainInfo.orderedModels) {
        if (!model.realPublicPath) continue
        let localeFile = path.join(model.realPublicPath, 'locale', `lang-${lang}.json`)
        if (fs.existsSync(localeFile)) { // JSON localization (since UB 5.19.3)
          const content = fs.readFileSync(localeFile, 'utf-8')
          cached += `\n// ${model.name} localization\nUB.i18nExtend(\n${content}\n)`
        }
        localeFile = path.join(model.realPublicPath, 'locale', `lang-${lang}.js`)
        if (fs.existsSync(localeFile)) { // JS localization
          const content = fs.readFileSync(localeFile, 'utf-8')
          cached += `\n// ${model.name} localization\n${content}`
        }
        // get locale-specific translations for the server-side code
        const localeCode = formatByPattern.lang2locale(lang)
        if (localeCode !== lang) {
          localeFile = path.join(model.realPublicPath, 'locale', `lang-${localeCode}.json`)
          if (fs.existsSync(localeFile)) {
            const content = fs.readFileSync(localeFile, 'utf-8')
            cached += `\n// ${model.name} localization\nUB.i18nExtend(\n${content}\n)`
          }
        }
      }
      App.globalCachePut(`${GC_KEYS.UB_LOCALE_REQ_}${lang}`, cached)
    }
    resp.writeEnd(cached)
  }

  resp.statusCode = 200
  resp.writeHead('Content-Type: application/javascript')
  resp.validateETag()
}

module.exports = {
  modelsEp,
  getAppInfoEp,
  clientRequireEp,
  getDomainInfoEp,
  staticEp,
  runSQLEp,
  restEp,
  allLocalesEp
}