// ===========================================
// Wiki.js Server
// Licensed under AGPLv3
// ===========================================

import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import semver from 'semver'
import { customAlphabet } from 'nanoid'
import { uniq } from 'es-toolkit/array'

import fastify from 'fastify'
import fastifyCompress from '@fastify/compress'
import fastifyCors from '@fastify/cors'
import fastifyCookie from '@fastify/cookie'
import fastifyFavicon from 'fastify-favicon'
import fastifyFormBody from '@fastify/formbody'
import fastifyHelmet from '@fastify/helmet'
import fastifySensible from '@fastify/sensible'
import fastifySession from '@fastify/session'
import fastifyStatic from '@fastify/static'
import fastifySwagger from '@fastify/swagger'
import fastifySwaggerUi from '@fastify/swagger-ui'
import fastifyWebsocket from '@fastify/websocket'
import gracefulServer from '@gquittet/graceful-server'
import ajvFormats from 'ajv-formats'
import Emittery from 'emittery'
import NodeCache from 'node-cache'

import collab from './core/collab.ts'
import configSvc from './core/config.ts'
import dbManager from './core/db.ts'
import logger from './core/logger.ts'
import scheduler from './core/scheduler.ts'
import { splitLocalePath, stripPageExtension } from './helpers/common.ts'
import { corsOrigin, parseCspDirectives } from './helpers/security.ts'

const nanoid = customAlphabet('1234567890abcdef', 10)

/**
 * Files a browser or a crawler asks for at the root by convention, rather than because the wiki has a
 * page there. Kept out of the page URL rules below — `txt` is a page extension on a default site, and
 * answering `/robots.txt` with a redirect to `/robots` would be answering the wrong question.
 */
const RESERVED_ROOT_FILES = new Set(['favicon.ico', 'robots.txt', 'sitemap.xml'])

/**
 * First path segments the SERVER itself answers — every prefix registered in `initHTTPServer`.
 *
 * Spelled out rather than tested with `isPageUrl`, because a leading underscore does not mean the
 * server: the frontend router owns `/_admin`, `/_profile`, `/_inbox`, `/_search`, `/_create`, `/_edit`
 * and `/_error` too, and those have to reach the app shell like any page path. The distinction the
 * shell needs is "does something here serve this", which is this list, and it has to be kept in step
 * with the registrations below.
 */
const SERVER_ROUTE_SEGMENTS = new Set([
  '_api',
  '_assets',
  '_blocks',
  '_collab',
  '_files',
  '_icons',
  '_render',
  '_site',
  '_terminal',
  '_thumb',
  '_user'
])

/**
 * Whether a URL addresses the page tree rather than the server itself.
 *
 * Everything the server mounts sits under a leading-underscore segment — `/_api`, `/_assets`,
 * `/_files`, and the rest registered in `initHTTPServer` — which is what makes the distinction a
 * prefix test rather than a list to keep in step with the routes.
 */
function isPageUrl(urlPath: string): boolean {
  const firstSegment = urlPath.split('/')[1] ?? ''
  return !firstSegment.startsWith('_') && !RESERVED_ROOT_FILES.has(firstSegment.toLowerCase())
}

/**
 * The segments a site's locale-prefixed URLs may start with, mapped to the locale each names.
 *
 * Every code a locale answers to, not only the short one it is addressed by now: an alias an
 * administrator changed leaves the links people have already saved pointing at the old segment, and
 * a wiki that answers 404 to them has broken them. `localeForShortCode` is what knows the set.
 */
function localePrefixesFor(activeCodes?: string[] | null): Map<string, string> {
  const prefixes = new Map<string, string>()
  for (const code of activeCodes ?? []) {
    const locale = WIKI.cache?.get(`locale:${code}`) as any
    for (const segment of [locale?.displayCode, locale?.derivedCode, code]) {
      if (segment) {
        prefixes.set(segment, code)
      }
    }
  }
  return prefixes
}

if (!semver.satisfies(process.version, '>=26')) {
  console.error('ERROR: Node.js 26.x or later required!')
  process.exit(1)
}

if (existsSync('./package.json')) {
  console.error('ERROR: Must run server from the parent directory!')
  process.exit(1)
}

// The global is assembled progressively: the literal below holds what is known at startup, and
// preBoot()/initHTTPServer() fill in db, models, cache, scheduler, events, app and server.
const WIKI = {
  IS_DEBUG: process.env.NODE_ENV === 'development',
  ROOTPATH: process.cwd(),
  INSTANCE_ID: nanoid(10),
  SERVERPATH: path.join(process.cwd(), 'backend'),
  auth: {
    groups: {},
    strategies: {}
  },
  collab,
  configSvc,
  sites: {},
  sitesMappings: {},
  startedAt: Temporal.Now.instant()
} as unknown as WikiGlobal
global.WIKI = WIKI

if (WIKI.IS_DEBUG) {
  process.on('warning', (warning: Error) => {
    console.log(warning.stack)
  })
}

await WIKI.configSvc.init()

// ----------------------------------------
// Init Logger
// ----------------------------------------

WIKI.logger = logger.init()

// ----------------------------------------
// Init Server
// ----------------------------------------

WIKI.logger.info('=======================================')
WIKI.logger.info(`= Wiki.js ${(WIKI.version + ' ').padEnd(29, '=')}`)
WIKI.logger.info('=======================================')
WIKI.logger.info('Initializing...')
WIKI.logger.info(`Running node.js ${process.version} [ OK ]`)

// ----------------------------------------
// Pre-Boot Sequence
// ----------------------------------------

async function preBoot() {
  WIKI.dbManager = (await import('./core/db.ts')).default
  WIKI.db = await dbManager.init()
  WIKI.models = (await import('./models/index.ts')).default

  try {
    if (await WIKI.configSvc.loadFromDb()) {
      WIKI.logger.info('Settings merged with DB successfully [ OK ]')
    } else {
      WIKI.logger.warn('No settings found in DB. Initializing with defaults...')
      await WIKI.configSvc.initDbValues()

      if (!(await WIKI.configSvc.loadFromDb())) {
        throw new Error('Settings table is empty! Could not initialize [ ERROR ]')
      }
    }
  } catch (err: any) {
    WIKI.logger.error('Database Initialization Error: ' + err.message)
    if (WIKI.IS_DEBUG) {
      WIKI.logger.error(err)
    }
    process.exit(1)
  }

  WIKI.cache = new NodeCache({ checkperiod: 0 })
  WIKI.scheduler = await scheduler.init()
  WIKI.events = {
    inbound: new Emittery(),
    outbound: new Emittery()
  }
}

// ----------------------------------------
// Post-Boot Sequence
// ----------------------------------------

async function postBoot() {
  await WIKI.models.locales.refreshFromDisk()

  await WIKI.models.authentication.refreshStrategiesFromDisk()

  await WIKI.models.authentication.activateStrategies()
  await WIKI.models.locales.reloadCache()
  await WIKI.models.sites.reloadCache()
  // -> Page access is decided from these on every request, so they are in memory from the start
  await WIKI.models.groups.reloadCache()
  // -> Likewise: every page view asks whether the page takes suggestions and who reviews it
  await WIKI.models.approvals.reloadCache()

  // -> Must follow the sites cache: every site gets a row per installed block
  await WIKI.models.blocks.refreshFromDisk()
  await WIKI.models.blocks.syncAllSites()

  // -> Same: every site gets a row per installed storage module
  await WIKI.models.storage.refreshFromDisk()
  await WIKI.models.storage.syncAllSites()

  // -> Optional third-party tooling: report what is available, since features silently degrade
  //    without it
  await WIKI.models.extensions.refreshFromDisk()
  await WIKI.models.extensions.logState()

  // -> The icon cache is derived from the db and starts empty on a fresh instance
  await WIKI.models.icons.ensureCacheDir()

  await WIKI.dbManager.subscribeToNotifications()
  // -> Its own postgres listener, on its own channel: collaboration traffic is far heavier than the
  //    event bus's and has nothing to do with it. Must follow the sites cache, which the websocket
  //    handshake reads the per-site feature toggle from.
  await WIKI.collab.init()
  await WIKI.scheduler.start()

  // -> A page queued for rendering when this instance went down is still queued, and nothing looks at
  //    that table until somebody asks for another render. Costs one query when there is nothing to do.
  await WIKI.scheduler.addJob({ task: 'renderPages', maxRetries: 0 })
}

// ----------------------------------------
// Init HTTP Server
// ----------------------------------------

async function initHTTPServer() {
  // ----------------------------------------
  // Load core modules
  // ----------------------------------------

  // WIKI.auth = auth.init()
  // WIKI.mail = mail.init()
  // WIKI.system = system.init()

  // ----------------------------------------
  // Initialize Fastify App
  // ----------------------------------------

  const app = fastify({
    ajv: {
      // -> `ajv-formats` is CJS: the default import resolves to `module.exports`, so the callable
      //    plugin is reached via `.default` (verified identical at runtime: `f === f.default`).
      //    The tuple assertion is load-bearing twice over: it stops the element from widening
      //    (which makes fastify's overload resolution fall through to the HTTP/2 signature), and
      //    it bridges an upstream variance mismatch — @fastify/ajv-compiler declares plugin
      //    options as `unknown`, while ajv-formats declares its own narrower options type, and the
      //    two are contravariantly incompatible. (`ajv` itself is only a nested dependency here, so
      //    its `Plugin` type is not importable to state this more precisely.)
      plugins: [[ajvFormats.default, {}] as any],
      onCreate: (ajv: any) => {
        // -> Accepts the shorthand, alpha and full forms a color picker can produce:
        //    #RGB, #RGBA, #RRGGBB and #RRGGBBAA
        ajv.addFormat('hexcolor', (data: unknown) => {
          return (
            typeof data === 'string' &&
            /^#(?:[a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/.test(data)
          )
        })
      }
    },
    bodyLimit: WIKI.config.bodyParserLimit || 5242880, // 5mb
    logger: {
      level: 'error'
    },
    // -> `securityTrustProxy` was the 2.x name: the setting is `trustProxy`, so this read never
    //    matched and the option was permanently off no matter what the admin area showed
    trustProxy: WIKI.config.security.trustProxy ?? false,
    routerOptions: {
      ignoreTrailingSlash: true
    }
  })
  WIKI.app = app
  WIKI.server = gracefulServer(app.server, {
    livenessEndpoint: '/_live',
    readinessEndpoint: '/_ready',
    kubernetes: Boolean(process.env.KUBERNETES_SERVICE_HOST)
  })

  app.register(fastifySensible)
  app.register(fastifyCompress, { global: true })
  /*
    Websocket upgrades, for live collaborative editing (`controllers/collab.ts`) and the admin
    terminal's log stream (`controllers/terminal.ts`). Registered on the root instance because the
    upgrade handler is installed on the HTTP server itself, and before the routes below because a
    route declaring `websocket: true` needs it already there.

    `maxPayload` bounds a single frame: these carry keystrokes and cursor positions, and the largest
    legitimate one is a client handing over a document it edited while offline.
  */
  app.register(fastifyWebsocket, { options: { maxPayload: 5242880 } })

  // ----------------------------------------
  // Handle graceful server shutdown
  // ----------------------------------------

  WIKI.server.on(gracefulServer.SHUTTING_DOWN, () => {
    WIKI.logger.info('Shutting down HTTP Server... [ STOPPING ]')
    WIKI.dbManager.unsubscribeFromNotifications()
    // -> Closes every editing socket with a going-away code, so the editors reconnect to whichever
    //    instance takes over rather than sitting on a dead connection
    WIKI.collab.shutdown()
  })

  WIKI.server.on(gracefulServer.SHUTDOWN, (err: Error) => {
    WIKI.logger.info(`HTTP Server has exited: [ STOPPED ] (${err.message})`)
    if (err.message !== 'SIGINT') {
      WIKI.logger.warn(err)
    }
  })

  // ----------------------------------------
  // Security
  // ----------------------------------------

  // -> Every setting below comes from the admin area's security view. They are read once, here, so a
  //    change takes effect on the next restart — the view says as much.
  const security = WIKI.config.security

  app.register(fastifyHelmet, {
    contentSecurityPolicy:
      security.enforceCsp && security.cspDirectives
        ? { directives: parseCspDirectives(security.cspDirectives), useDefaults: false }
        : false,
    strictTransportSecurity:
      security.enforceHsts && security.hstsDuration > 0
        ? {
            maxAge: security.hstsDuration,
            includeSubDomains: true
          }
        : false,
    // -> Helmet's own default is `sameorigin`, which is also what this setting turned off means
    xFrameOptions: { action: security.disallowIframe ? 'deny' : 'sameorigin' },
    referrerPolicy: security.enforceSameOriginReferrerPolicy
      ? { policy: 'same-origin' }
      : { policy: 'no-referrer' }
  })

  app.register(fastifyCors, {
    origin: corsOrigin(security),
    methods: ['GET', 'HEAD', 'POST', 'OPTIONS']
  })

  // ----------------------------------------
  // Public Assets
  // ----------------------------------------

  app.register(fastifyFavicon, {
    path: path.join(WIKI.ROOTPATH, 'assets'),
    name: 'favicon.ico'
  })
  app.register(fastifyStatic, {
    prefix: '/_assets/',
    root: path.join(WIKI.ROOTPATH, 'assets/_assets'),
    index: false,
    maxAge: '7d',
    decorateReply: false
  })

  // ----------------------------------------
  // Blocks
  // ----------------------------------------

  app.register(fastifyStatic, {
    prefix: '/_blocks/',
    root: path.join(WIKI.ROOTPATH, 'blocks/compiled'),
    index: false,
    maxAge: '1h'
  })

  // ----------------------------------------
  // Sessions
  // ----------------------------------------

  app.register(fastifyCookie, {
    secret: WIKI.config.auth.secret,
    hook: 'onRequest'
  })
  app.register(fastifySession, {
    secret: WIKI.config.auth.secret,
    cookieName: 'wikiSession',
    cookie: {
      httpOnly: true,
      maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
      secure: 'auto'
    },
    saveUninitialized: false,
    store: {
      async get(sessionId: string, clb: (err: any, result?: any) => void) {
        try {
          clb(null, await WIKI.models.sessions.get(sessionId))
        } catch (err: any) {
          clb(err, null)
        }
      },
      async set(sessionId: string, sessionData: any, clb: (err: any, result?: any) => void) {
        try {
          clb(null, await WIKI.models.sessions.set(sessionId, sessionData))
        } catch (err: any) {
          clb(err, null)
        }
      },
      async destroy(sessionId: string, clb: (err: any, result?: any) => void) {
        try {
          clb(null, await WIKI.models.sessions.destroy(sessionId))
        } catch (err: any) {
          clb(err, null)
        }
      }
    }
  })

  // ----------------------------------------
  // API Routes
  // ----------------------------------------

  app.register(fastifySwagger, {
    hideUntagged: true,
    openapi: {
      openapi: '3.1.0',
      info: {
        title: 'Wiki.js API',
        version: WIKI.version
      },
      components: {
        securitySchemes: {
          apiKeyAuth: {
            type: 'apiKey',
            in: 'header',
            name: 'X-API-Key'
          },
          bearerAuth: {
            type: 'http',
            scheme: 'bearer',
            bearerFormat: 'JWT'
          }
        }
      },
      security: [{ apiKeyAuth: [] }, { bearerAuth: [] }]
    },
    transform: ({ schema, url, route }: any) => {
      // Add permissions to the route schema description
      const permissions = route?.config?.permissions ?? []
      const transformedSchema = { ...schema }
      const currentDescription = transformedSchema.description || ''

      if (permissions?.length > 0) {
        const nestedPermissions: string[] = []
        for (const perm of permissions) {
          if (Array.isArray(perm)) {
            nestedPermissions.push(`\`${perm.join(' + ')}\``)
          } else {
            nestedPermissions.push(`\`${perm}\``)
          }
        }
        nestedPermissions.push('`manage:system`')
        transformedSchema.description =
          `${currentDescription}\n\n**Required Permissions:** ${uniq(nestedPermissions).join(' or ')}`.trim()
        transformedSchema['x-permissions'] = permissions
      } else if (route?.config?.publicAccess) {
        transformedSchema.description =
          `${currentDescription}\n\n**This API is public.** No special permissions required.`.trim()
      } else {
        /*
          No fixed permission is not the same as public, and saying so was wrong for most of these.
          A route without one is usually a route whose answer depends on the caller: the page rules of
          their groups, their own account, or the queue they happen to be a reviewer for. What it
          serves is scoped, not unrestricted.
        */
        transformedSchema.description =
          `${currentDescription}\n\n**No fixed permission.** What this returns, and what it acts on, is limited to what the caller is entitled to — their session, their groups' page rules, or their own account. A request that is entitled to nothing gets an empty answer or a refusal rather than an error about permissions.`.trim()
      }

      return { schema: transformedSchema, url }
    }
  })
  app.register(fastifySwaggerUi, {
    routePrefix: '/_api',
    /*
      Swagger UI's own sorters, applied in the browser: tags down the page, and the operations inside
      each tag by path. Neither is on by default — the order is otherwise the order the routes were
      registered in, which is meaningful to `api/index.ts` and arbitrary to anyone reading the docs.

      `operationsSorter: 'alpha'` sorts on the path, not the summary, so the several methods of one
      path stay together and keep their registration order relative to each other.
    */
    uiConfig: {
      tagsSorter: 'alpha',
      operationsSorter: 'alpha'
    },
    // -> Left empty so the plugin inlines neither its own logo nor one of ours; the stylesheet below
    //    is what puts the site's logo in the topbar
    logo: {} as any,
    theme: {
      css: [
        {
          filename: 'wiki.css',
          /*
            The site's own logo in the topbar, as a background on the link swagger draws its wordmark
            in.

            A stylesheet rather than the plugin's `logo` option, which takes a buffer and base64-inlines
            it into the page when the server boots. This documentation is served for whichever site the
            request arrived at, and an administrator can change that site's logo at any time — a URL
            resolves both of those per request, and a buffer chosen at boot resolves neither.

            `contain` in a box wider than it is tall, so a square mark and a wordmark both sit sensibly
            without the logo being distorted to fit.
          */
          content: `
            .swagger-ui .topbar-wrapper a.link > * {
              display: none;
            }
            .swagger-ui .topbar-wrapper a.link {
              display: block;
              width: 160px;
              height: 40px;
              background: url('/_site/current/logo') left center / contain no-repeat;
            }
          `
        }
      ]
    }
  })

  // ----------------------------------------
  // API Key Authentication
  // ----------------------------------------

  app.decorateRequest('apiKey', null)

  app.addHook('onRequest', async (req, reply) => {
    // -> Bearer tokens authenticate API calls only; everything else is cookie-authenticated. Note
    //    that the session is deliberately left untouched: writing to it would have @fastify/session
    //    persist a session row for every scraped request.
    if (!req.url.startsWith('/_api/')) {
      return
    }
    const header = req.headers.authorization
    if (!header?.startsWith('Bearer ')) {
      return
    }
    const token = header.slice('Bearer '.length).trim()
    if (!token) {
      return
    }
    try {
      req.apiKey = await WIKI.models.apiKeys.verify(token)
    } catch (err: any) {
      // -> Say why: the caller holds the credential and can act on "revoked" or "expired"
      WIKI.logger.debug(`Rejected an API key: ${err.message}`)
      return reply.unauthorized(err.message)
    }
  })

  // ----------------------------------------
  // Permissions
  // ----------------------------------------

  app.addHook('preHandler', (req, reply, done) => {
    const routePermissions = req.routeOptions.config?.permissions
    if (routePermissions && routePermissions.length > 0) {
      // -> A verified API key stands in for a session, carrying the permissions of the groups it was
      //    issued for
      const permissions = req.apiKey
        ? req.apiKey.permissions
        : req.session?.authenticated
          ? req.session.permissions
          : null
      // Unauthenticated / No Permissions
      if (!permissions || permissions.length < 1) {
        return reply.unauthorized()
      }
      // Is Root Admin?
      if (!permissions.includes('manage:system')) {
        // Check for at least 1 permission
        const isAllowed = routePermissions.some((perms) => {
          // Check for all permissions
          if (Array.isArray(perms)) {
            return perms.every((perm) => permissions.some((p) => p === perm))
          } else {
            return permissions.some((p) => p === perms)
          }
        })
        // Forbidden
        if (!isAllowed) {
          return reply.forbidden()
        }
      }
    }
    done()
  })

  // ----------------------------------------
  // SEO
  // ----------------------------------------

  app.addHook('onRequest', (req, reply, done) => {
    const [urlPath, urlQuery] = req.raw.url!.split('?')
    const withQuery = (newPath: string) => (urlQuery ? `${newPath}?${urlQuery}` : newPath)

    const trimmed = urlPath!.length > 1 && urlPath!.endsWith('/') ? urlPath!.slice(0, -1) : urlPath!

    if (isPageUrl(trimmed)) {
      // -> Straight off the site caches rather than through the model: this runs on every request, and
      //    both lookups are the ones `getSiteByHostname` would do, minus its optional reload
      const siteId = WIKI.sitesMappings[req.hostname] || WIKI.sitesMappings['*']
      const withoutExtension = stripPageExtension(
        trimmed,
        WIKI.sites[siteId]?.config?.pageExtensions
      )
      if (withoutExtension) {
        // -> Answers a trailing slash as well, rather than sending the client back for a second
        //    round trip to be told about the extension.
        //
        //    Not a 301: which extensions resolve this way is a setting, and a browser that cached a
        //    permanent redirect would go on applying it after an administrator had changed it
        reply.redirect(withQuery(withoutExtension), 302)
        return
      }

      /*
        A site that brackets its URLs by locale sends a path arriving without one to its primary
        locale, so that every page has a single address. The prefix is the locale's SHORT code — `/fr`
        for `fr-FR` — which is the same segment its content is filed under on a storage target.

        302 for the same reason as the extension above: it is a setting, and a browser holding a
        permanent redirect would go on applying it after an administrator had turned it off.
      */
      const siteLocales = WIKI.sites[siteId]?.config?.locales
      if (siteLocales?.forcePrefix) {
        const prefixes = localePrefixesFor(siteLocales.active)
        if (!splitLocalePath(trimmed, prefixes)) {
          const primary = WIKI.models.locales.shortCodeFor(siteLocales.primary)
          reply.redirect(withQuery(`/${primary}${trimmed === '/' ? '' : trimmed}`), 302)
          return
        }
      }
    }

    if (trimmed !== urlPath) {
      reply.redirect(withQuery(trimmed), 301)
      return
    }

    done()
  })

  app.register(fastifyFormBody, {
    bodyLimit: 1048576 // 1mb
  })

  // ----------------------------------------
  // Routing
  // ----------------------------------------

  // app.addHook('onRequest', async (req, reply, done) => {
  //   const currentSite = await WIKI.db.sites.getSiteByHostname({ hostname: req.hostname })
  //   if (!currentSite) {
  //     return reply.code(404).send('Site Not Found')
  //   }

  //   req.locals.siteConfig = {
  //     id: currentSite.id,
  //     title: currentSite.config.title,
  //     darkMode: currentSite.config.theme.dark,
  //     lang: currentSite.config.locales.primary,
  //     rtl: false, // TODO: handle RTL
  //     company: currentSite.config.company,
  //     contentLicense: currentSite.config.contentLicense
  //   }
  //   req.locals.theming = {

  //   }
  //   req.locals.langs = await WIKI.db.locales.getNavLocales({ cache: true })
  //   req.locals.analyticsCode = await WIKI.db.analytics.getCode({ cache: true })
  //   done()
  // })

  app.register(import('./api/index.ts'), { prefix: '/_api' })
  app.register(import('./controllers/collab.ts'), { prefix: '/_collab' })
  app.register(import('./controllers/files.ts'), { prefix: '/_files' })
  app.register(import('./controllers/site.ts'), { prefix: '/_site' })
  app.register(import('./controllers/icons.ts'), { prefix: '/_icons' })
  app.register(import('./controllers/render.ts'), { prefix: '/_render' })
  app.register(import('./controllers/terminal.ts'), { prefix: '/_terminal' })
  app.register(import('./controllers/thumb.ts'), { prefix: '/_thumb' })
  app.register(import('./controllers/user.ts'), { prefix: '/_user' })

  // ----------------------------------------
  // App Shell
  // ----------------------------------------

  const appShellPath = path.join(WIKI.ROOTPATH, 'assets/index.html')

  /*
    The compiled SPA, for every path no route above claimed.

    It has to be the fallback rather than a route of its own: a wiki page lives at any path a user cares
    to give it, and the frontend's router -- not this server -- is what resolves one. Which is also why
    the only paths held back are the segments the server itself mounts, so a mistyped `/_api/...` still
    answers as the API rather than handing back a page of HTML, and the root files a crawler asks for by
    convention, which are absent here rather than being the app.

    `no-store`: the bundles this pulls in are hashed and immutable under `/_assets`, but the document
    naming them must never be held, or a rebuilt frontend would keep booting the previous one. Read per
    request for the same reason -- `npm run build` while the server is up should be enough.
  */
  app.setNotFoundHandler(async (req, reply) => {
    const urlPath = req.raw.url!.split('?')[0]!
    const firstSegment = urlPath.split('/')[1] ?? ''
    const isSystemPath = SERVER_ROUTE_SEGMENTS.has(firstSegment)
    const isReservedRootFile = RESERVED_ROOT_FILES.has(firstSegment.toLowerCase())
    // -> HEAD as well as GET: it has to answer what GET would, or a monitor pointed at the wiki reads a
    //    404 for a page the browser beside it loads. Node drops the body for HEAD on its own.
    const isReadRequest = req.method === 'GET' || req.method === 'HEAD'
    if (!isReadRequest || isSystemPath || isReservedRootFile) {
      return reply.notFound()
    }
    try {
      const shell = await readFile(appShellPath, 'utf8')
      return reply.header('Cache-Control', 'no-store').type('text/html; charset=utf-8').send(shell)
    } catch (err: any) {
      // -> Nothing to serve means the frontend was never built, which is a setup step rather than a
      //    fault of this request: say which one, since a bare 500 sends people looking in the server
      WIKI.logger.error(`Cannot serve the app shell from ${appShellPath}: ${err.message}`)
      return reply
        .code(503)
        .type('text/plain; charset=utf-8')
        .send('The frontend has not been built yet. Run `npm run build` in frontend/.\n')
    }
  })

  // ----------------------------------------
  // Error handling
  // ----------------------------------------

  app.setErrorHandler((error: any, req, reply) => {
    if (req.url.includes('/_api/')) {
      if (error.statusCode) {
        reply.code(error.statusCode).type('application/json').send({
          ok: false,
          error: error.name,
          statusCode: error.statusCode,
          message: error.message
        })
      } else {
        WIKI.logger.warn(error)
        reply.code(500).type('application/json').send({
          ok: false,
          error: 'Internal Server Error',
          statusCode: 500,
          message: 'Internal Server error'
        })
      }
    } else {
      reply.send(error)
    }
  })

  // ----------------------------------------
  // Bind HTTP Server
  // ----------------------------------------

  try {
    WIKI.logger.info(`Starting HTTP Server on port ${WIKI.config.port} [ STARTING ]`)
    await app.listen({ port: WIKI.config.port, host: WIKI.config.bindIP })
    WIKI.logger.info('HTTP Server: [ RUNNING ]')
    WIKI.server.setReady()
  } catch (err: any) {
    WIKI.logger.error(err)
    process.exit(1)
  }
}

// ----------------------------------------
// Register exit handler
// ----------------------------------------

// process.on('SIGINT', () => {
//   WIKI.kernel.shutdown()
// })
// process.on('message', (msg) => {
//   if (msg === 'shutdown') {
//     WIKI.kernel.shutdown()
//   }
// })

// ----------------------------------------
// Initialization Sequence
// ----------------------------------------

await preBoot()
await initHTTPServer()
await postBoot()
