// @ts-check

import fs from 'node:fs'

import Path from 'node:path'
import { promisify, callbackify } from 'node:util'
import Settings from '@overleaf/settings'
import Views from './Views.mjs'
import _ from 'lodash'
import Metrics from '@overleaf/metrics'

/** @import { WebModule } from "../../../types/web-module" */
/** @import { RequestHandler } from "express" */

/**
 * @import { HookName, HookParameters, HookReturnType } from './Modules'
 */

const MODULE_BASE_PATH = Path.join(import.meta.dirname, '/../../../modules')

/** @type {WebModule[]} */
const _modules = []
let _modulesLoaded = false
/** @type {Partial<Record<HookName, Function[]>>} */
const _hooks = {}

/** @type {Record<string, RequestHandler[]>} */
const _middleware = {}
/** @type {Record<string, any>} */
let _viewIncludes = {}

async function modules() {
  if (!_modulesLoaded) {
    const beforeLoadModules = performance.now()
    await loadModules()
    Metrics.gauge('web_startup', performance.now() - beforeLoadModules, 1, {
      path: 'loadModules',
    })
  }
  return _modules
}

async function loadModulesImpl() {
  const settingsCheckModule = Path.join(
    MODULE_BASE_PATH,
    'settings-check',
    'index.mjs'
  )
  if (fs.existsSync(settingsCheckModule)) {
    await import(settingsCheckModule)
  }
  for (const moduleName of Settings.moduleImportSequence || []) {
    const typescriptModule = Path.join(
      MODULE_BASE_PATH,
      moduleName,
      'index.mts'
    )
    let module
    if (fs.existsSync(typescriptModule)) {
      module = await import(typescriptModule)
    } else {
      module = await import(
        Path.join(MODULE_BASE_PATH, moduleName, 'index.mjs')
      )
    }

    /** @type {WebModule & {name: string}} */
    const loadedModule = module.default || module

    loadedModule.name = moduleName
    _modules.push(loadedModule)
    if (loadedModule.viewIncludes) {
      throw new Error(
        `${moduleName}: module.viewIncludes moved into Settings.viewIncludes`
      )
    }
    if (loadedModule.dependencies) {
      for (const dependency of loadedModule.dependencies) {
        if (!Settings.moduleImportSequence.includes(dependency)) {
          throw new Error(
            `Module '${dependency}' listed as a dependency of '${moduleName}' is missing in the moduleImportSequence. Please also verify that it is available in the current environment.`
          )
        }
      }
    }
  }
  _modulesLoaded = true
  await attachHooks()
  await attachMiddleware()
}

const loadModules = _.memoize(loadModulesImpl)

/**
 * @param {any} webRouter
 * @param {any} privateApiRouter
 * @param {any} publicApiRouter
 */
async function applyRouter(webRouter, privateApiRouter, publicApiRouter) {
  for (const module of await modules()) {
    if (module.router && module.router.apply) {
      await module.router.apply(webRouter, privateApiRouter, publicApiRouter)
    }
  }
}

/**
 * @param {any} webRouter
 * @param {any} privateApiRouter
 * @param {any} publicApiRouter
 */
async function applyNonCsrfRouter(
  webRouter,
  privateApiRouter,
  publicApiRouter
) {
  for (const module of await modules()) {
    if (module.nonCsrfRouter != null) {
      module.nonCsrfRouter.apply(webRouter, privateApiRouter, publicApiRouter)
    }
    if (module.router && module.router.applyNonCsrfRouter) {
      module.router.applyNonCsrfRouter(
        webRouter,
        privateApiRouter,
        publicApiRouter
      )
    }
  }
}

async function start() {
  for (const module of await modules()) {
    await module.start?.()
  }
}

/**
 * @param {any} app
 */
function loadViewIncludes(app) {
  _viewIncludes = Views.compileViewIncludes(app)
}

/**
 * @param {any} appOrRouter
 * @param {any} middlewareName
 * @param {any} [options]
 */
async function applyMiddleware(appOrRouter, middlewareName, options) {
  if (!middlewareName) {
    throw new Error(
      'middleware name must be provided to register module middleware'
    )
  }
  for (const module of await modules()) {
    /** @type {Record<string, any>} */
    const typedModule = module
    if (typedModule[middlewareName]) {
      typedModule[middlewareName](appOrRouter, options)
    }
  }
}

/**
 * @param {any} view
 * @param {any} locals
 */
function moduleIncludes(view, locals) {
  const compiledPartials = _viewIncludes[view] || []
  let html = ''
  for (const /** @type {any} */ compiledPartial of compiledPartials) {
    html += compiledPartial(locals)
  }
  return html
}

/**
 * @param {any} view
 */
function moduleIncludesAvailable(view) {
  return (_viewIncludes[view] || []).length > 0
}

async function linkedFileAgentsIncludes() {
  /** @type {Record<string, any>} */
  const agents = {}
  for (const module of await modules()) {
    for (const name in module.linkedFileAgents) {
      const agentFunction = /** @type {Record<string, any>} */ (
        module.linkedFileAgents
      )[name]
      agents[name] = agentFunction()
    }
  }
  return agents
}

async function attachHooks() {
  for (const module of await modules()) {
    const { promises, ...hooks } = module.hooks || {}
    for (const [hook, method] of Object.entries(promises || {})) {
      attachHook(hook, method)
    }
    for (const hook in hooks || {}) {
      const method = /** @type {Record<string, any>} */ (hooks)[hook]
      attachHook(hook, promisify(method))
    }
  }
}

/**
 * @template {HookName} K
 * @param {K} name
 * @param {(...args: HookParameters<K>) => HookReturnType<K>| Promise<HookReturnType<K>>} method
 */
function attachHook(name, method) {
  if (_hooks[name] == null) {
    _hooks[name] = []
  }
  _hooks[name].push(method)
}

async function attachMiddleware() {
  for (const module of await modules()) {
    if (module.middleware) {
      for (const middleware in module.middleware) {
        const method = /** @type {Record<string, any>} */ (module.middleware)[
          middleware
        ]
        if (_middleware[middleware] == null) {
          _middleware[middleware] = []
        }
        _middleware[middleware].push(method)
      }
    }
  }
}

/**
 * @template {HookName} K
 * @param {K} name
 * @param {HookParameters<K>} args
 * @returns {Promise<HookReturnType<K>[]>}
 */
async function fireHook(name, ...args) {
  // ensure that modules are loaded if we need to fire a hook
  // this can happen if a script calls a method that fires a hook
  if (!_modulesLoaded) {
    await loadModules()
  }
  const methods = _hooks[name] || []
  const results = []
  for (const method of methods) {
    const result = await method(...args)
    results.push(result)
  }
  return results
}

/**
 * @param {string} name
 */
async function getMiddleware(name) {
  // ensure that modules are loaded if we need to call a middleware
  if (!_modulesLoaded) {
    await loadModules()
  }
  return _middleware[name] || []
}

/**
 * @typedef {<K extends HookName>(name: K, ...args: [...HookParameters<K>, (err: any, results?: HookReturnType<K>[]) => void]) => void} CallbackFireHook
 */

export default {
  applyNonCsrfRouter,
  applyRouter,
  linkedFileAgentsIncludes,
  loadViewIncludes,
  moduleIncludes,
  moduleIncludesAvailable,
  applyMiddleware,
  start,
  hooks: {
    attach: attachHook,
    fire: /** @type {CallbackFireHook} */ (
      /** @type {unknown} */ (callbackify(/** @type {any} */ (fireHook)))
    ),
  },
  middleware: getMiddleware,
  promises: {
    hooks: {
      fire: fireHook,
    },
  },
}
