// ── The declared module set ────────────────────────────────────────────────
//
// Phase 4, slice 3 of docs/website/MODULE_SYSTEM.md §2.7.2 — decision 4. A
// compose-managed host is not driven by clicking: it declares which modules it
// runs, in the file it already edits and version-controls, and the container
// arrives at that set by itself.
//
//   MODULES: uo@0.3.0=https://<host>/…/module-uo-0.3.0.json
//
// One entry per module, `<id>@<version>=<install manifest URL>`, separated by
// whitespace or commas. The id and the version are written out rather than left
// to be discovered inside the manifest for one reason: **the no-op case must not
// need the network.** A module already unpacked at the declared version is
// answered by reading its own `module.json` off the volume, so a restart with
// the network down brings the site up exactly as it was. Only a module that is
// missing, or unpacked at some other version, reaches out — and it reaches out
// through modules/install.js, the same fetch-verify-unpack path the admin panel
// uses, under the same host allowlist.
//
// Three things this file deliberately does not do:
//
//   - **It does not decide whether a module RUNS.** Resolution owns what is on
//     the volume; `installed_modules` owns whether a mounted module answers. An
//     admin who uninstalls a declared module gets its directory back at the next
//     boot with the row still `disabled`, so it stays off until they enable it.
//     The two never fight because they are not answering the same question.
//   - **It does not fail a boot.** A module publisher's host being unreachable
//     must not take a shard's website down with it; core is built to serve with
//     a module absent (§1.6). Every failure is logged loudly and kept for the
//     admin screen, and the site comes up.
//   - **It does not mount anything.** §1.12 makes the volume the mounting source
//     of truth, read once at require time — which is why this runs before
//     `require('./app')` in server.js and not from inside it.
//
// It runs on every boot, not only in Docker: a bare `npm start` with MODULES set
// resolves the same way. The Docker path is the reason it exists, not a special
// case in it.

const fs = require('fs')
const path = require('path')

const install = require('./install')

const log = require('../utils/logger')('modules')

// The variable an operator sets. Named next to MODULES_DIR, which is the other
// half of the same story: one says where modules live, the other says which.
const VAR = 'MODULES'

// Same id rule the loader enforces when scanning and install.js enforces when
// placing, restated here so a declaration cannot name something neither would
// accept.
const ID = /^[a-z][a-z0-9-]{1,31}$/

// The outcome of the last resolution, in memory, for the admin screen. Not a
// database row: a declaration is a fact about this process's environment, and
// writing it down would put it in front of the boot reconcile, which resets
// every non-disabled row (§2.4). The screen merges it as a fourth source
// alongside the row, the loader and the volume.
let results = []

/**
 * Parse the declaration into entries.
 *
 * Malformed entries are collected rather than thrown: one operator typo should
 * cost that module, not every module on the host. A duplicate id keeps the
 * first — there is no sensible way to run two versions of one module, and
 * silently preferring the last would make the outcome depend on the order of a
 * list nobody reads as ordered.
 *
 * @param {string} value the raw variable
 * @returns {{entries: Array<{id,version,url}>, errors: string[]}}
 */
function parse(value) {
  const entries = []
  const errors = []
  const seen = new Set()

  for (const token of String(value || '').split(/[,\s]+/).filter(Boolean)) {
    const at = token.indexOf('@')
    const eq = token.indexOf('=')
    if (at < 1 || eq < at + 2) {
      errors.push(`"${token}" is not <id>@<version>=<url>`)
      continue
    }
    const id = token.slice(0, at)
    const version = token.slice(at + 1, eq)
    const url = token.slice(eq + 1)

    if (!ID.test(id)) {
      errors.push(`"${token}" names an invalid module id "${id}"`)
      continue
    }
    if (!url) {
      errors.push(`"${token}" has no install manifest URL`)
      continue
    }
    if (seen.has(id)) {
      errors.push(`"${id}" is declared more than once — keeping the first`)
      continue
    }
    seen.add(id)
    entries.push({ id, version, url })
  }

  return { entries, errors }
}

/**
 * The version currently unpacked on the volume, or null.
 *
 * Read straight out of the module's own `module.json`, which is the same file
 * the loader trusts for the same fact — and never from `installed_modules`,
 * because the row records what was installed and this has to answer what is
 * actually there. An unreadable manifest counts as absent: whatever is in that
 * directory, it is not a module at the declared version.
 */
function installedVersion(id) {
  try {
    const manifest = JSON.parse(
      fs.readFileSync(path.join(install.moduleDir(id), 'module.json'), 'utf8'),
    )
    return manifest && manifest.version ? String(manifest.version) : null
  } catch {
    return null
  }
}

/**
 * Bring the volume in line with the declaration.
 *
 * Never throws and never rejects. Returns one outcome per declared entry, and
 * remembers them for `state()`.
 *
 * @param {object} args
 * @param {string} [args.value] the raw variable (defaults to the environment)
 * @param {string[]} args.hosts the install allowlist, already parsed
 * @param {object} args.model  modules.model, for recording provenance
 * @param {object} [args.installImpl] injection seam, as everywhere else here
 * @returns {Promise<Array<{id,version,url,action,message}>>}
 */
async function resolve({ value = process.env[VAR], hosts = [], model, installImpl = install } = {}) {
  const { entries, errors } = parse(value)
  results = []

  for (const message of errors) log.error(`${VAR}: ${message}`)

  if (!entries.length) return results

  log.info(`${VAR} declares ${entries.length} module(s)`, {
    modules: entries.map((e) => `${e.id}@${e.version}`).join(' '),
  })

  for (const entry of entries) {
    const present = installedVersion(entry.id)
    if (present === entry.version) {
      // The offline path, and the common one: nothing is fetched, nothing is
      // written, and a host with no route to the internet boots unchanged.
      log.info(`module "${entry.id}" is already at the declared version ${entry.version}`)
      results.push({ ...entry, action: 'noop', message: null })
      continue
    }

    try {
      // `expect` is the declaration itself, handed down so install.js can refuse
      // a URL that resolves to another module or another version while it is
      // still only a manifest — a check made after the unpack would be made with
      // the undeclared module already on the volume.
      const result = await installImpl.install({
        url: entry.url,
        hosts,
        expect: { id: entry.id, version: entry.version },
      })

      // Provenance, written exactly as the admin route writes it — the whole
      // point of resolving in-process rather than from a script that cannot
      // reach the database. A module installed by the compose file and one
      // installed by an admin are then indistinguishable on the screen, which
      // is what makes this one feature and not two.
      if (model) {
        await model.recordInstalled({
          id: result.id,
          name: result.name,
          version: result.version,
          source: entry.url,
          sha256: result.sha256,
        })
      }

      log.warn(`installed declared module "${entry.id}" v${entry.version}`, {
        from: present || 'nothing',
        source: entry.url,
        sha256: result.sha256,
      })
      results.push({ ...entry, action: 'installed', message: null })
    } catch (err) {
      // Loud, and then onward. The site serves; this module does not, or serves
      // the version that was already there.
      log.error(
        `could not resolve declared module "${entry.id}@${entry.version}": ${err.message}` +
          (present ? ` — leaving version ${present} in place` : ''),
      )
      results.push({ ...entry, action: 'failed', message: err.message })
    }
  }

  return results
}

/** What the last resolution decided, for the admin screen. */
function state() {
  return results.map((r) => ({ ...r }))
}

/** Test seam: forget the last resolution. */
function reset() {
  results = []
}

module.exports = { VAR, parse, installedVersion, resolve, state, reset }
