// Self-installer: `headless.mjs install` turns this checkout into an
// always-on peer on a Linux box — a wrapper script plus a systemd *user*
// unit (with a cron fallback when no user bus is reachable), so the service
// survives crashes and reboots without hand-written host setup.
//
// `run` exits on stdin EOF, so a daemonized instance must keep fd0 open
// forever: the wrapper opens the control FIFO read-write on fd0. The FIFO
// stays the operator surface — `echo '{"id":1,"op":"dump"}' > <storage>/control.fifo`
// — with replies in the journal (or <storage>/service.log under cron).
import { spawn, spawnSync } from 'node:child_process'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { setTimeout as delay } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
import { configPath, normalizeRole } from './config.mjs'
import { readStatus } from './status.mjs'

export const SERVICE_NAME = 'listam-headless'
// Every generated crontab line carries this marker so install/uninstall can
// replace exactly their own lines. On command lines it rides behind the shell
// as a comment (cron itself has no trailing-comment syntax).
export const CRON_MARKER = '# listam-headless-installer'

const FIFO_NAME = 'control.fifo'
const RUN_SCRIPT_NAME = 'run.sh'
const SERVICE_LOG_NAME = 'service.log'

// Generated paths land inside double quotes in shell and unit files; refuse
// metacharacters that could escape them rather than trying to quote.
export function assertShellSafe(label, value) {
    if (typeof value !== 'string' || value.length === 0 || /[\s"'`$\\]/.test(value)) {
        throw new Error(`${label} must be a non-empty path without whitespace, quotes, $, \\ or backticks (got: ${value})`)
    }
}

export function renderRunScript({ nodePath, headlessPath, storageDir, role = 'participant', baseKeyHex = null }) {
    assertShellSafe('node path', nodePath)
    assertShellSafe('headless path', headlessPath)
    assertShellSafe('storage dir', storageDir)
    const setupExtra = baseKeyHex ? ` --base-key ${baseKeyHex}` : ''
    return [
        '#!/bin/bash',
        `${CRON_MARKER}: generated by \`headless.mjs install\`; rerun install instead of editing.`,
        'set -u',
        `STORAGE="${storageDir}"`,
        `FIFO="$STORAGE/${FIFO_NAME}"`,
        'mkdir -p "$STORAGE"',
        `if [ ! -f "${configPath(storageDir)}" ]; then`,
        `    "${nodePath}" "${headlessPath}" setup --storage "$STORAGE" --role ${role}${setupExtra}`,
        'fi',
        '[ -p "$FIFO" ] || mkfifo "$FIFO"',
        '# fd0 stays open read-write on the FIFO: stdin never reaches EOF (EOF',
        '# means shutdown) while operators can echo JSON ops into it at any time.',
        `exec "${nodePath}" "${headlessPath}" run --storage "$STORAGE" 0<>"$FIFO"`,
    ].join('\n') + '\n'
}

export function renderUnitFile({ runScriptPath }) {
    assertShellSafe('run script path', runScriptPath)
    return [
        '[Unit]',
        'Description=Listam headless peer (always-on)',
        '# The restart burst must outlive the 30s storage-lease TTL: after an',
        '# unclean kill the first attempts fail with "lease held" until it expires.',
        'StartLimitIntervalSec=120',
        'StartLimitBurst=8',
        '',
        '[Service]',
        'Type=simple',
        `ExecStart=/bin/bash ${runScriptPath}`,
        'Restart=always',
        'RestartSec=10',
        '',
        '[Install]',
        'WantedBy=default.target',
    ].join('\n') + '\n'
}

export function renderGuardCrontab({ nodePath, headlessPath, storageDir, runScriptPath }) {
    const log = `${storageDir}/${SERVICE_LOG_NAME}`
    // The guard treats a missing/stale status file as "no live instance"; the
    // storage lease makes an accidental double start harmless (the loser exits).
    return [
        `@reboot setsid -f ${runScriptPath} >> ${log} 2>&1 ${CRON_MARKER}`,
        `*/5 * * * * ${nodePath} ${headlessPath} status --storage ${storageDir} > /dev/null 2>&1 || setsid -f ${runScriptPath} >> ${log} 2>&1 ${CRON_MARKER}`,
    ]
}

export function mergeCrontabLines(existing, newLines) {
    const kept = (existing ?? '')
        .split('\n')
        .filter((line) => line.trim() !== '' && !line.includes(CRON_MARKER))
    return [...kept, ...newLines].join('\n') + '\n'
}

function runCommand(command, args, { env = process.env, input } = {}) {
    const result = spawnSync(command, args, { encoding: 'utf8', env, input, timeout: 30_000 })
    return {
        code: result.status ?? -1,
        stdout: result.stdout ?? '',
        stderr: result.stderr ?? '',
        error: result.error?.message ?? null,
    }
}

// Over Tailscale SSH (no PAM session) XDG_RUNTIME_DIR is often unset even
// though the user manager is running (linger); point systemctl at the bus.
function systemctlEnv() {
    const runtimeDir = process.env.XDG_RUNTIME_DIR ?? `/run/user/${process.getuid()}`
    return {
        ...process.env,
        XDG_RUNTIME_DIR: runtimeDir,
        DBUS_SESSION_BUS_ADDRESS: process.env.DBUS_SESSION_BUS_ADDRESS ?? `unix:path=${runtimeDir}/bus`,
    }
}

function enableLinger() {
    const user = os.userInfo().username
    const probe = runCommand('loginctl', ['show-user', user, '--property=Linger'])
    if (probe.code === 0 && probe.stdout.includes('Linger=yes')) return { method: 'already-enabled' }
    if (runCommand('loginctl', ['enable-linger', user]).code === 0) {
        const verify = runCommand('loginctl', ['show-user', user, '--property=Linger'])
        if (verify.code === 0 && verify.stdout.includes('Linger=yes')) return { method: 'self' }
    }
    const sudo = runCommand('sudo', ['-n', 'loginctl', 'enable-linger', user])
    if (sudo.code === 0) return { method: 'sudo' }
    return { method: 'none', detail: (sudo.stderr || sudo.error || 'linger unavailable').trim() }
}

async function startUnderSystemd({ lingerJustEnabled }) {
    const env = systemctlEnv()
    let reload = runCommand('systemctl', ['--user', 'daemon-reload'], { env })
    if (reload.code !== 0 && lingerJustEnabled) {
        // A freshly lingered user manager can take a moment to expose its bus.
        await delay(2_000)
        reload = runCommand('systemctl', ['--user', 'daemon-reload'], { env })
    }
    if (reload.code !== 0) {
        return { ok: false, detail: (reload.stderr || reload.error || 'daemon-reload failed').trim() }
    }
    const enable = runCommand('systemctl', ['--user', 'enable', `${SERVICE_NAME}.service`], { env })
    const restart = runCommand('systemctl', ['--user', 'restart', `${SERVICE_NAME}.service`], { env })
    if (restart.code !== 0) {
        return { ok: false, detail: (restart.stderr || restart.error || 'restart failed').trim() }
    }
    return { ok: true, enabled: enable.code === 0 }
}

function installCrons({ nodePath, headlessPath, storageDir, runScriptPath }) {
    const current = runCommand('crontab', ['-l'])
    const existing = current.code === 0 ? current.stdout : ''
    const merged = mergeCrontabLines(existing, renderGuardCrontab({ nodePath, headlessPath, storageDir, runScriptPath }))
    const write = runCommand('crontab', ['-'], { input: merged })
    return write.code === 0
        ? { ok: true }
        : { ok: false, detail: (write.stderr || write.error || 'crontab write failed').trim() }
}

function startDetached(fs, runScriptPath, storageDir) {
    const log = fs.openSync(path.join(storageDir, SERVICE_LOG_NAME), 'a')
    try {
        const child = spawn('/bin/bash', [runScriptPath], { detached: true, stdio: ['ignore', log, log] })
        child.unref()
    } finally {
        fs.closeSync(log)
    }
}

async function waitForStatus(fs, storageDir, predicate, { timeoutMs, pollMs = 500 }) {
    const deadline = Date.now() + timeoutMs
    while (Date.now() < deadline) {
        const snapshot = readStatus(fs, storageDir)
        if (snapshot && predicate(snapshot)) return snapshot
        await delay(pollMs)
    }
    return null
}

// O_NONBLOCK makes a reader-less FIFO fail with ENXIO instead of blocking
// this process forever; the running service holds its FIFO open read-write,
// so a live instance always accepts the write (atomic under PIPE_BUF).
function writeFifoLine(fs, fifoPath, payload) {
    const fd = fs.openSync(fifoPath, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK)
    try {
        fs.writeSync(fd, JSON.stringify(payload) + '\n')
    } finally {
        fs.closeSync(fd)
    }
}

async function joinViaFifo(fs, storageDir, inviteKey) {
    // The join reply itself lands in the journal/log.
    writeFifoLine(fs, path.join(storageDir, FIFO_NAME), { id: 1, op: 'join', invite: inviteKey })
    const started = Date.now()
    // The join op allows 180s for blind-pairing DHT delays; mirror that here.
    const joined = await waitForStatus(fs, storageDir, (s) => s.joined === true && !s.stale, { timeoutMs: 200_000, pollMs: 1_000 })
    return joined
        ? { ok: true, waitedMs: Date.now() - started }
        : { ok: false, detail: 'join did not complete within 200s; check the service journal' }
}

export async function installService({ fs, storageDir, role = 'participant', baseKeyHex = null, inviteKey = null }) {
    if (process.platform !== 'linux') {
        return { ok: false, reason: 'install targets Linux hosts (systemd user unit with cron fallback)' }
    }
    if (!normalizeRole(role)) {
        return { ok: false, reason: `role must be participant or blind-storage (got: ${role})` }
    }
    const storage = path.resolve(storageDir)
    const nodePath = process.execPath
    const headlessPath = fileURLToPath(new URL('../headless.mjs', import.meta.url))
    const runScriptPath = path.join(storage, RUN_SCRIPT_NAME)

    fs.mkdirSync(storage, { recursive: true })
    fs.writeFileSync(runScriptPath, renderRunScript({ nodePath, headlessPath, storageDir: storage, role, baseKeyHex }), { mode: 0o755 })

    const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user')
    const unitPath = path.join(unitDir, `${SERVICE_NAME}.service`)
    fs.mkdirSync(unitDir, { recursive: true })
    fs.writeFileSync(unitPath, renderUnitFile({ runScriptPath }))

    const linger = enableLinger()
    const systemd = await startUnderSystemd({ lingerJustEnabled: linger.method === 'self' || linger.method === 'sudo' })

    let serviceMethod = 'systemd'
    let cron = null
    if (!systemd.ok || linger.method === 'none') {
        // No user bus (cron carries both boot and crash recovery) or no boot
        // persistence (cron carries @reboot; the guard is harmless alongside
        // systemd thanks to the storage lease).
        cron = installCrons({ nodePath, headlessPath, storageDir: storage, runScriptPath })
        serviceMethod = systemd.ok ? 'systemd+cron' : (cron.ok ? 'cron' : 'failed')
        if (!systemd.ok && cron.ok && !readStatus(fs, storage)) startDetached(fs, runScriptPath, storage)
    }

    const ready = await waitForStatus(fs, storage, (s) => !s.stale && s.stopped !== true, { timeoutMs: 60_000 })
    let join = null
    if (inviteKey && ready) {
        join = ready.joined === true ? { ok: true, alreadyJoined: true } : await joinViaFifo(fs, storage, inviteKey)
    }

    return {
        ok: ready !== null && (!inviteKey || join?.ok === true),
        storage,
        runScript: runScriptPath,
        unitPath,
        serviceMethod,
        systemd: systemd.ok ? 'active' : systemd.detail,
        linger,
        ready: ready !== null,
        joined: join ? join.ok : ready?.joined === true,
        ...(join && !join.ok ? { joinError: join.detail } : {}),
        ...(ready ? {} : { readyError: `service did not report a fresh status within 60s (check journalctl --user -u ${SERVICE_NAME} or ${storage}/${SERVICE_LOG_NAME})` }),
    }
}

export function uninstallService({ fs, storageDir }) {
    if (process.platform !== 'linux') {
        return { ok: false, reason: 'uninstall targets Linux hosts' }
    }
    const storage = path.resolve(storageDir)
    const env = systemctlEnv()
    runCommand('systemctl', ['--user', 'disable', '--now', `${SERVICE_NAME}.service`], { env })

    const unitPath = path.join(os.homedir(), '.config', 'systemd', 'user', `${SERVICE_NAME}.service`)
    let removedUnit = false
    if (fs.existsSync(unitPath)) {
        fs.rmSync(unitPath)
        removedUnit = true
    }
    runCommand('systemctl', ['--user', 'daemon-reload'], { env })

    let removedCrons = 0
    const current = runCommand('crontab', ['-l'])
    if (current.code === 0) {
        const lines = current.stdout.split('\n')
        const kept = lines.filter((line) => !line.includes(CRON_MARKER))
        removedCrons = lines.length - kept.length
        if (removedCrons > 0) runCommand('crontab', ['-'], { input: kept.join('\n').replace(/\n+$/, '') + '\n' })
    }

    // A cron-started instance is not under systemd's control; ask it to stop
    // through its own FIFO if it still looks alive.
    const snapshot = readStatus(fs, storage)
    if (snapshot && !snapshot.stale && snapshot.stopped !== true) {
        try {
            writeFifoLine(fs, path.join(storage, FIFO_NAME), { id: 1, op: 'shutdown' })
        } catch {}
    }

    return { ok: true, removedUnit, removedCrons, storageKept: storage }
}
