// cli-ui/env-file.ts — read / write key=value pairs in ~/.im-hub/env.
//
// The env file is sourced by the systemd unit (`EnvironmentFile=`) AND by
// `agim start --bg` via process.env when we re-exec ourselves. It's
// chmod 600 because it holds SMTP passwords and Baidu Maps AK.
//
// Format: simple `KEY=value` per line. No interpolation, no quoting required.
// Lines starting with # are preserved as comments. Empty lines preserved.
import { readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync, existsSync, realpathSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, sep } from 'node:path';
import { AGIM_HOME } from '../core/agim-paths.js';
export const ENV_FILE = join(AGIM_HOME, 'env');
/**
 * Defense-in-depth against the v1.0.14 class of incident: a `bun test` run
 * on a box that doubles as the agim host silently clobbering the operator's
 * real ~/.agim/env — observed as `IMHUB_ADMIN_USERS=telegram:123` (the
 * admin-bootstrap test fixture) overwriting the real admin list and locking
 * the operator out of every admin command.
 *
 * The test preload (test/setup.ts, wired via bunfig.toml) sandboxes
 * AGIM_HOME to a tmpdir so these writes land harmlessly. But that preload is
 * a single point of failure: if it doesn't run (a stray `bun test <file>`
 * from the wrong cwd, a `--preload` override, a future/other runner), the
 * sandbox evaporates and writes hit the live file again.
 *
 * bun / vitest / jest all set NODE_ENV=test intrinsically (verified: bun
 * 1.3 sets it even without the preload). So we gate on that signal —
 * independent of the preload — and refuse any env-file mutation whose target
 * is outside the OS tmpdir while under test. A botched test run now throws
 * loudly instead of silently wiping the operator's config. Production never
 * sets NODE_ENV=test, so this is a no-op there.
 *
 * Exported for the regression test in test/unit/env-file-guard.test.ts.
 */
export function assertEnvWriteAllowed(target = ENV_FILE) {
    const underTest = process.env.NODE_ENV === 'test' ||
        !!process.env.VITEST ||
        !!process.env.JEST_WORKER_ID;
    if (!underTest)
        return;
    // Resolve through symlinks so /tmp vs /private/tmp (macOS) and similar
    // don't produce false negatives. The file itself may not exist yet, so
    // resolve its parent dir; fall back to the raw path if resolution fails
    // (a missing dir resolves to itself → correctly fails the tmpdir check).
    let base;
    try {
        base = realpathSync(tmpdir());
    }
    catch {
        base = tmpdir();
    }
    let dir = dirname(target);
    try {
        dir = realpathSync(dir);
    }
    catch { /* keep raw — non-existent dir */ }
    if (dir === base || dir.startsWith(base + sep))
        return;
    throw new Error(`refusing to write env file at ${target} while NODE_ENV=test: the path ` +
        `is outside the OS tmpdir (${base}), which means the test sandbox ` +
        `(test/setup.ts preload) is NOT active. This guard stops a stray test ` +
        `run from clobbering the operator's real ~/.agim/env — the v1.0.14 ` +
        `admin-lockout incident. Run tests via 'bun test' from the repo root, ` +
        `or pin AGIM_HOME to a tmpdir before writing.`);
}
/** Read existing env as a plain Record. Unknown lines (blank / comment)
 *  are dropped from the returned map but PRESERVED on write via merge. */
export function readEnvFile() {
    if (!existsSync(ENV_FILE))
        return {};
    const result = {};
    try {
        const raw = readFileSync(ENV_FILE, 'utf-8');
        for (const line of raw.split('\n')) {
            const trimmed = line.trim();
            if (!trimmed || trimmed.startsWith('#'))
                continue;
            const eq = trimmed.indexOf('=');
            if (eq <= 0)
                continue;
            const key = trimmed.slice(0, eq).trim();
            const val = trimmed.slice(eq + 1);
            result[key] = val;
        }
    }
    catch { /* return what we have */ }
    return result;
}
/** Keys the wizard prefills. `readEffectiveEnv()` checks the env file
 *  first, then falls back to `process.env` for these — so SMTP/Baidu set
 *  via the systemd unit's `Environment=` lines (the install pattern used
 *  before the wizard existed) still appear in the prefill UI. */
const WIZARD_ENV_KEYS = [
    'IMHUB_SMTP_HOST',
    'IMHUB_SMTP_PORT',
    'IMHUB_SMTP_USER',
    'IMHUB_SMTP_PASS',
    'IMHUB_SMTP_FROM',
    'IMHUB_SMTP_SECURE',
    'IMHUB_BAIDU_MAP_AK',
];
const SYSTEMD_UNIT_PATH = '/etc/systemd/system/im-hub.service';
/** Parse `Environment="KEY=value"` and `Environment=KEY=value` lines out
 *  of the im-hub systemd unit. Used as a fallback for `readEffectiveEnv()`
 *  on machines where the wizard hasn't yet written to ~/.im-hub/env AND
 *  the user's interactive shell doesn't export the keys — the unit file
 *  is world-readable on standard installs, so this works for `agim
 *  config` invoked as the user. Returns {} if the file is missing/unreadable. */
export function readSystemdUnitEnv() {
    if (!existsSync(SYSTEMD_UNIT_PATH))
        return {};
    const result = {};
    try {
        const raw = readFileSync(SYSTEMD_UNIT_PATH, 'utf-8');
        for (const line of raw.split('\n')) {
            const trimmed = line.trim();
            if (!trimmed.startsWith('Environment'))
                continue;
            // Strip leading `Environment=` (with optional whitespace after =).
            const rhs = trimmed.replace(/^Environment\s*=\s*/, '');
            // Unit lines can be `Environment="K=V"` or `Environment=K=V` or
            // even `Environment="K1=V1" "K2=V2"`. systemd doesn't support
            // multi-line values via continuation that we care about here.
            // Split on whitespace OUTSIDE quotes, then strip the surrounding
            // quotes if present.
            const tokens = rhs.match(/"[^"]*"|\S+/g) ?? [];
            for (const tok of tokens) {
                const unq = tok.startsWith('"') && tok.endsWith('"') ? tok.slice(1, -1) : tok;
                const eq = unq.indexOf('=');
                if (eq <= 0)
                    continue;
                const key = unq.slice(0, eq);
                const val = unq.slice(eq + 1);
                if (key)
                    result[key] = val;
            }
        }
    }
    catch { /* permissions / parse error — best-effort */ }
    return result;
}
/** Read priority:
 *    1. ~/.im-hub/env  (canonical, what the wizard writes)
 *    2. process.env     (current shell / inherited)
 *    3. systemd unit's Environment= lines (what's actually in effect for
 *       the running service when the wizard is invoked from a shell that
 *       doesn't export the keys — most common case after a manual install)
 *
 *  Writes still go to the env file only — see `updateEnvFile()`. Callers
 *  that need to warn about shadowing should use `hasProcessEnvOverride()`. */
export function readEffectiveEnv() {
    const fromFile = readEnvFile();
    const fromUnit = readSystemdUnitEnv();
    const merged = { ...fromFile };
    for (const k of WIZARD_ENV_KEYS) {
        if (merged[k] !== undefined)
            continue;
        if (typeof process.env[k] === 'string' && process.env[k] !== '') {
            merged[k] = process.env[k];
            continue;
        }
        if (typeof fromUnit[k] === 'string' && fromUnit[k] !== '') {
            merged[k] = fromUnit[k];
        }
    }
    return merged;
}
/** True when a wizard-managed key is set in the running env (process.env
 *  or systemd unit) but NOT in the env file — i.e. the wizard's writes
 *  won't be the source of truth at runtime. Used to print a warning at
 *  SMTP/Baidu save. */
export function hasProcessEnvOverride(key) {
    const file = readEnvFile();
    if (file[key] !== undefined)
        return false;
    if (typeof process.env[key] === 'string' && process.env[key] !== '')
        return true;
    const unit = readSystemdUnitEnv();
    return typeof unit[key] === 'string' && unit[key] !== '';
}
/** Merge-update: keep existing keys, overwrite the supplied ones, REMOVE
 *  any keys whose value in `updates` is explicitly null/undefined. Writes
 *  the file with chmod 600. */
export function updateEnvFile(updates) {
    assertEnvWriteAllowed();
    const current = readEnvFile();
    for (const [k, v] of Object.entries(updates)) {
        if (v === null || v === undefined)
            delete current[k];
        else
            current[k] = v;
    }
    mkdirSync(dirname(ENV_FILE), { recursive: true });
    const lines = Object.entries(current).map(([k, v]) => `${k}=${v}`);
    writeFileSync(ENV_FILE, lines.join('\n') + (lines.length ? '\n' : ''));
    chmodSync(ENV_FILE, 0o600);
}
/** Wipe a single key. No-op if missing. */
export function unsetEnvKey(key) {
    assertEnvWriteAllowed();
    if (!existsSync(ENV_FILE))
        return;
    const current = readEnvFile();
    if (!(key in current))
        return;
    delete current[key];
    const lines = Object.entries(current).map(([k, v]) => `${k}=${v}`);
    writeFileSync(ENV_FILE, lines.join('\n') + (lines.length ? '\n' : ''));
    chmodSync(ENV_FILE, 0o600);
}
/** Remove the env file entirely. Used by `uninstall`. */
export function removeEnvFile() {
    assertEnvWriteAllowed();
    try {
        unlinkSync(ENV_FILE);
    }
    catch { /* already gone */ }
}
//# sourceMappingURL=env-file.js.map