// What an admin should be told about one installed module, and what they may do
// to it — derived, not spelled out at each button.
//
// Phase 4, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.2. Plain JS rather than
// a hook or a chunk of JSX, for the same reason `lib/adminNav.js` is: the test
// runner here has no DOM, and this is the part of the Modules screen that is
// actually worth testing.
//
// **The screen has four sources of truth and they are allowed to disagree**
// (MODULE_SYSTEM.md §2.4, and slice 3 for the fourth):
//
//   state      what the DATABASE row records — what the operator decided, and
//              what the last boot ended up doing
//   liveState  what the LOADER has mounted in this process and is answering with
//   onVolume   whether there is still a directory there at all
//   declared   what this container's MODULES variable asks for — the only one of
//              the four that no button on this screen can change
//
// Picking one and rendering it would be simpler and would lie. The case that
// makes this concrete is the one decision 3 creates on purpose: an operator
// disables a module (its onShutdown runs, its routes 404) and then enables it
// again. The row says `enabled`; the loader still says `disabled`, because
// there is no `onBoot` re-dispatch and nothing can start it before a restart.
// It is neither running nor off, and the honest thing to show is "enabled —
// restart to start it".

/**
 * The one-line status of a module, and whether that status is waiting on a
 * restart.
 *
 * Ordering matters here. The checks run most-alarming first, so a module whose
 * directory has been deleted is described that way rather than by whatever its
 * row happens to still say.
 *
 * @param {object} m a row from GET /admin/modules
 * @returns {{ label: string, tone: 'ok'|'warn'|'bad'|'idle', pending: boolean, detail: string }}
 */
export function statusOf(m) {
  // Declared by the environment and not there at all: no row, no directory,
  // nothing mounted. Every other branch below reads one of those three, so
  // without this the screen would describe a module it has never had as though
  // a row had gone stale — and the one thing the operator needs, the reason
  // resolution failed, would be nowhere.
  if (m.declared && !m.onVolume && m.state === null) {
    return {
      label: 'Declared, not installed',
      tone: 'bad',
      pending: false,
      detail: m.declaredError
        ? `MODULES asks for v${m.declaredVersion}; the last start could not install it: ${m.declaredError}`
        : `MODULES asks for v${m.declaredVersion}. It will be installed when the server next starts.`,
    }
  }

  // Gone from the volume, but still known. Either a hand-deleted directory (the
  // boot reconcile marks that `startup_failed`) or an uninstall waiting for its
  // restart. Both are "there is nothing to run here".
  if (!m.onVolume) {
    return {
      label: m.state === 'disabled' ? 'Uninstalled' : 'Missing from the volume',
      tone: m.state === 'disabled' ? 'idle' : 'bad',
      pending: m.liveState !== null,
      detail: m.state === 'disabled'
        ? 'The files are gone. Its data was kept, and reinstalling brings it back.'
        : 'A row exists but there is no module directory. Reinstall it, or uninstall to clear the row.',
    }
  }

  // **Installed since this process booted**, and this check has to come before
  // the failure one. `liveState` is the loader's record, and the loader scans
  // the volume once at require time — so a module that is on the volume NOW and
  // has no live record was put there after the scan. Anything the row still says
  // about it therefore predates the install and is stale by definition.
  //
  // Found by the §7.7 browser smoke, and no unit test here had modelled it:
  // installing over a row left `startup_failed` by the previous boot rendered
  // "Failed at the require stage: module directory not present on the volume"
  // one second after the file had been written to the volume — and, because that
  // branch is not pending, suppressed the restart banner the install had just
  // told the operator to use.
  if (m.liveState === null) {
    return {
      label: 'Restart to start',
      tone: 'warn',
      pending: true,
      detail: 'Installed. It mounts when the server next starts.',
    }
  }

  if (m.state === 'startup_failed' || m.liveState === 'startup_failed') {
    return {
      label: 'Failed to start',
      tone: 'bad',
      pending: false,
      detail: m.failureReason
        ? `Failed at the ${m.failureStage || 'unknown'} stage: ${m.failureReason}`
        : 'It failed to start and recorded no reason.',
    }
  }

  if (m.state === 'disabled') {
    return {
      label: 'Disabled',
      tone: 'idle',
      pending: false,
      detail: 'Stopped and switched off. Its routes answer 404 and it stays off across restarts.',
    }
  }

  // The row has been switched on but the loader has not started it — the
  // decision-3 case: disable ran its onShutdown, and nothing can start it again
  // before a restart.
  if (m.liveState !== 'started') {
    return {
      label: 'Restart to start',
      tone: 'warn',
      pending: true,
      detail: m.liveState === 'disabled'
        ? 'Enabled, but still stopped in the running server — it cannot be restarted in place.'
        : 'Enabled. It mounts when the server next starts.',
    }
  }

  // Running, but not the version that is installed. An upgrade writes new files
  // and a new row while the old code stays loaded, so the row's `version` is a
  // promise about the next boot rather than a description of this one — and
  // "Running v2.0.0" beside a process serving v1.0.0 is the same lie as the
  // stale-failure one above, in a different place.
  if (m.liveVersion && m.liveVersion !== m.version) {
    return {
      label: 'Restart to finish upgrading',
      tone: 'warn',
      pending: true,
      detail: `v${m.version} is installed; v${m.liveVersion} is still running.`,
    }
  }

  return {
    label: 'Running',
    tone: 'ok',
    pending: false,
    detail: 'Mounted and serving.',
  }
}

/**
 * What the environment's declaration means for this module, as one sentence — or
 * null if nothing declares it.
 *
 * Kept out of `statusOf` on purpose. A module can be running perfectly while its
 * declared upgrade is failing, and collapsing both into one label would have to
 * pick which of the two is "the" status. This is a second line, beside the first.
 *
 * The sentence an operator most needs is the uninstall one: MODULES owns what is
 * on the volume and the row owns whether it runs, so uninstalling a declared
 * module puts its files back at the next start and leaves it switched off. Files
 * reappearing unexplained is exactly the kind of thing that gets debugged for an
 * afternoon.
 *
 * @param {object} m a row from GET /admin/modules
 * @returns {{ text: string, tone: 'warn'|'idle' }|null}
 */
export function declarationNoteFor(m) {
  if (!m.declared) return null

  if (m.declaredError) {
    return {
      text: `MODULES asks for v${m.declaredVersion} and the last start could not install it: ${m.declaredError}`,
      tone: 'warn',
    }
  }
  if (!m.onVolume) {
    return {
      text:
        `MODULES declares v${m.declaredVersion}, so its files come back when the server next starts`
        + (m.state === 'disabled' ? ' — switched off, until you enable it.' : '.'),
      tone: 'warn',
    }
  }
  return { text: `Declared by this deployment's MODULES variable at v${m.declaredVersion}.`, tone: 'idle' }
}

/**
 * Which actions are offered for a module, and why the others are not.
 *
 * Returned as a map of `{ shown, reason }` rather than a list of shown actions,
 * so a disabled button can say what would make it available. Every rule here
 * mirrors one the server enforces — this is presentation, never the boundary.
 *
 * @param {object} m a row from GET /admin/modules
 */
export function actionsFor(m) {
  const running = m.liveState === 'started'
  const disabled = m.state === 'disabled'

  return {
    // Only offered while something is actually running: disabling a module that
    // is already stopped has nothing to stop and no guard to flip.
    disable: {
      shown: !disabled && m.onVolume,
      reason: disabled ? 'Already disabled.' : 'Nothing is running to stop.',
    },
    enable: {
      shown: disabled && m.onVolume,
      reason: 'Only a disabled module can be enabled.',
    },
    uninstall: {
      shown: m.onVolume,
      reason: 'There are no files left to remove.',
    },
    // The server refuses a standalone purge unless the module is disabled, so
    // the button says so rather than offering a click that 409s.
    purge: {
      shown: m.onVolume && m.canPurge,
      enabled: disabled,
      reason: !m.canPurge
        ? 'This module ships no purge.sql, so its data cannot be deleted.'
        : 'Disable it first, so nothing is serving out of the tables being dropped.',
    },
    // A row with no directory is the one thing an uninstall cannot tidy through
    // the normal path — offer clearing it instead.
    forget: {
      shown: !m.onVolume && m.state !== null,
      reason: 'The module is still installed.',
    },
    running,
  }
}

/**
 * Does anything on this list need a restart before it matches what is running?
 *
 * Drives the one banner at the top of the screen rather than a badge per row:
 * the restart is a property of the SERVER, not of a module, and offering it
 * five times would suggest otherwise.
 */
export const needsRestart = (modules) => modules.some((m) => statusOf(m).pending)

/**
 * Split a hosts string the way the server will.
 *
 * Duplicated from `install.parseHosts` deliberately — it is four lines, and the
 * alternative is an API round trip to preview what the field is going to mean.
 * The server remains the one that decides; this only shows the operator how
 * their typing will be read.
 */
export function parseHosts(value) {
  return String(value || '')
    .split(/[,\s]+/)
    .map((h) => h.trim().toLowerCase())
    .filter(Boolean)
}
