/**
 * Global botmux configuration stored at `~/.botmux/config.json`.
 *
 * This is a single place for "machine-wide, non-bot-specific" settings. The
 * first field is `lang` (UI language). Future settings (log level, dashboard
 * defaults, etc.) can extend the same file without proliferating env vars or
 * sidecar files.
 *
 * Read path is forgiving: missing file → empty config (callers fall back to
 * code defaults). Malformed JSON → empty config + a single stderr warning.
 * Write path is conservative: only the keys the caller actually passes get
 * touched; unknown keys in the on-disk file are preserved across writes so
 * a future client that adds a setting we don't know about doesn't lose it.
 */
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { isLocale } from './i18n/types.js';
/** Loosely validate a `voice` block: keep it only if it's an object with a
 *  recognizable engine or engine-specific creds. Deep validation (usable
 *  creds) happens in resolveVoiceConfig; here we just gate obvious garbage. */
function readVoice(raw) {
    if (!raw || typeof raw !== 'object')
        return undefined;
    const v = raw;
    const engineOk = v.engine === 'sami' || v.engine === 'openai' || v.engine === undefined;
    if (!engineOk)
        return undefined;
    if (!v.sami && !v.openai && !v.engine)
        return undefined;
    return v;
}
/** True when `s` is a valid 24h "HH:MM" (leading zero optional on hours).
 *  Shared by the config reader and the dashboard PUT validator. */
export function isValidHhMm(s) {
    return /^([01]?\d|2[0-3]):[0-5]\d$/.test(s);
}
function readMaintenanceTask(raw) {
    if (!raw || typeof raw !== 'object')
        return undefined;
    const r = raw;
    const out = {};
    if (typeof r.enabled === 'boolean')
        out.enabled = r.enabled;
    if (typeof r.time === 'string' && isValidHhMm(r.time))
        out.time = r.time;
    return Object.keys(out).length > 0 ? out : undefined;
}
function readMaintenanceToggle(raw) {
    if (!raw || typeof raw !== 'object')
        return undefined;
    const r = raw;
    if (typeof r.enabled !== 'boolean')
        return undefined;
    return { enabled: r.enabled };
}
/** Validate a maintenance patch from the dashboard PUT. Type-strict on enabled
 *  (both keys) and on autoUpdate's time. autoRestart is a toggle — any `time`
 *  on it is ignored (it reuses autoUpdate's schedule). */
export function parseMaintenancePatch(body) {
    if (!body || typeof body !== 'object' || Array.isArray(body))
        return { ok: false, error: 'empty' };
    const b = body;
    const patch = {};
    if ('autoUpdate' in b) {
        const raw = b.autoUpdate;
        if (!raw || typeof raw !== 'object' || Array.isArray(raw))
            return { ok: false, error: 'invalid_task' };
        const t = raw;
        const task = {};
        if ('enabled' in t) {
            if (typeof t.enabled !== 'boolean')
                return { ok: false, error: 'invalid_enabled' };
            task.enabled = t.enabled;
        }
        if ('time' in t) {
            if (typeof t.time !== 'string' || !isValidHhMm(t.time))
                return { ok: false, error: 'invalid_time' };
            task.time = t.time;
        }
        patch.autoUpdate = task;
    }
    if ('autoRestart' in b) {
        const raw = b.autoRestart;
        if (!raw || typeof raw !== 'object' || Array.isArray(raw))
            return { ok: false, error: 'invalid_task' };
        const t = raw;
        const toggle = {};
        if ('enabled' in t) {
            if (typeof t.enabled !== 'boolean')
                return { ok: false, error: 'invalid_enabled' };
            toggle.enabled = t.enabled;
        }
        patch.autoRestart = toggle;
    }
    if (Object.keys(patch).length === 0)
        return { ok: false, error: 'empty' };
    return { ok: true, patch };
}
function readMaintenance(raw) {
    if (!raw || typeof raw !== 'object')
        return undefined;
    const m = raw;
    const out = {};
    const au = readMaintenanceTask(m.autoUpdate);
    if (au)
        out.autoUpdate = au;
    const ar = readMaintenanceToggle(m.autoRestart);
    if (ar)
        out.autoRestart = ar;
    return Object.keys(out).length > 0 ? out : undefined;
}
function readRepoPickerMode(raw) {
    return raw === 'all' || raw === 'repos' ? raw : undefined;
}
function readDashboard(raw) {
    if (!raw || typeof raw !== 'object')
        return undefined;
    const d = raw;
    const out = {};
    if (typeof d.publicReadOnly === 'boolean')
        out.publicReadOnly = d.publicReadOnly;
    if (typeof d.openTerminalInFeishu === 'boolean')
        out.openTerminalInFeishu = d.openTerminalInFeishu;
    return Object.keys(out).length > 0 ? out : undefined;
}
function readPositiveInteger(raw) {
    if (typeof raw !== 'number' || !Number.isInteger(raw) || raw <= 0)
        return undefined;
    return raw;
}
function readWorker(raw) {
    if (!raw || typeof raw !== 'object')
        return undefined;
    const v = raw;
    const worker = {};
    const maxLiveWorkers = readPositiveInteger(v.maxLiveWorkers);
    const idleSuspendMs = readPositiveInteger(v.idleSuspendMs);
    if (maxLiveWorkers !== undefined)
        worker.maxLiveWorkers = maxLiveWorkers;
    if (idleSuspendMs !== undefined)
        worker.idleSuspendMs = idleSuspendMs;
    return Object.keys(worker).length > 0 ? worker : undefined;
}
export function globalConfigPath() {
    return join(homedir(), '.botmux', 'config.json');
}
let warnedOnce = false;
/** Load `~/.botmux/config.json`. Returns `{}` when the file is missing or
 *  unreadable. The raw JSON is also returned (untyped) so writers can
 *  preserve unknown keys round-trip — see `mergeGlobalConfig`. */
function readRawConfig() {
    const path = globalConfigPath();
    if (!existsSync(path))
        return {};
    try {
        const parsed = JSON.parse(readFileSync(path, 'utf-8'));
        return parsed && typeof parsed === 'object' ? parsed : {};
    }
    catch (err) {
        if (!warnedOnce) {
            warnedOnce = true;
            // eslint-disable-next-line no-console
            console.warn(`[botmux] Failed to parse ${path}: ${err?.message ?? err}. Ignoring file.`);
        }
        return {};
    }
}
// Short TTL cache: readGlobalConfig sits on hot paths (card-builder rebuilds
// the streaming card on every screen_update, i.e. ~per second per active
// session), and reading + parsing the file each time is wasted IO. 2s keeps
// cross-process freshness (dashboard PUT → daemon cards pick it up within 2s)
// while same-process writes invalidate immediately via mergeGlobalConfig.
// Keyed by path so tests that re-point HOME don't read a stale entry.
const READ_CACHE_TTL_MS = 2_000;
let readCache = null;
/** Typed view of the global config. Validates `lang` so a malformed file
 *  can't propagate a bad value into the i18n module. */
export function readGlobalConfig() {
    const path = globalConfigPath();
    if (readCache && readCache.path === path && Date.now() - readCache.at < READ_CACHE_TTL_MS) {
        return readCache.value;
    }
    const raw = readRawConfig();
    const out = {};
    if (isLocale(raw.lang))
        out.lang = raw.lang;
    const repoPickerMode = readRepoPickerMode(raw.repoPickerMode);
    if (repoPickerMode)
        out.repoPickerMode = repoPickerMode;
    const dashboard = readDashboard(raw.dashboard);
    if (dashboard)
        out.dashboard = dashboard;
    const voice = readVoice(raw.voice);
    if (voice)
        out.voice = voice;
    const worker = readWorker(raw.worker);
    if (worker)
        out.worker = worker;
    const maintenance = readMaintenance(raw.maintenance);
    if (maintenance)
        out.maintenance = maintenance;
    if (typeof raw.httpProxy === 'string' && raw.httpProxy.trim())
        out.httpProxy = raw.httpProxy.trim();
    readCache = { path, value: out, at: Date.now() };
    return out;
}
/** Derive repo-picker scan options from the machine-wide `repoPickerMode`.
 *  'repos' hides linked worktrees from selection cards; anything else
 *  (default 'all') lists repos + their worktrees. Shared by every scan
 *  entry point (daemon spawn paths + `/repo`) so they stay consistent. */
export function repoPickerScanOptions() {
    return { includeWorktrees: readGlobalConfig().repoPickerMode !== 'repos' };
}
/** Merge a patch into the on-disk config, preserving unknown keys. Creates
 *  the file (and parent dir) on first write. Use `null` to explicitly delete
 *  a known key from the file. */
export function mergeGlobalConfig(patch) {
    const path = globalConfigPath();
    const dir = dirname(path);
    if (!existsSync(dir))
        mkdirSync(dir, { recursive: true });
    const current = readRawConfig();
    for (const [k, v] of Object.entries(patch)) {
        if (v === null || v === undefined)
            delete current[k];
        else
            current[k] = v;
    }
    // Atomic write (tmp + rename): readers in other processes poll this file on
    // hot paths; a plain writeFileSync window could serve a torn/partial JSON,
    // which readRawConfig would silently treat as {} (settings flap to defaults
    // for one read). pid suffix keeps concurrent writers off each other's tmp.
    // mode 0600 — the file can carry voice credentials; an umask-default tmp
    // (0644) surviving the rename would widen access. Fixing the mode here also
    // tightens legacy 0644 files created by the pre-atomic writeFileSync path.
    const tmp = `${path}.${process.pid}.tmp`;
    writeFileSync(tmp, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 });
    renameSync(tmp, path);
    // Same-process read-after-write must see the new value immediately
    // (e.g. dashboard PUT /api/settings responds with the resolved config).
    readCache = null;
}
/** Merge only the dashboard sub-config, preserving unknown keys inside that
 *  object so a newer client can safely share the same config file. */
export function mergeDashboardConfig(patch) {
    const raw = readRawConfig();
    const existing = raw.dashboard && typeof raw.dashboard === 'object' && !Array.isArray(raw.dashboard)
        ? raw.dashboard
        : {};
    mergeGlobalConfig({ dashboard: { ...existing, ...patch } });
    return readGlobalConfig().dashboard ?? {};
}
/** Merge only the maintenance sub-config, preserving unknown sibling keys.
 *  Shallow-merges at the task level (autoUpdate / autoRestart): callers send
 *  the full task object, so a present key replaces it wholesale. */
export function mergeMaintenanceConfig(patch) {
    const raw = readRawConfig();
    const existing = raw.maintenance && typeof raw.maintenance === 'object' && !Array.isArray(raw.maintenance)
        ? raw.maintenance
        : {};
    mergeGlobalConfig({ maintenance: { ...existing, ...patch } });
    return readGlobalConfig().maintenance ?? {};
}
/** Convenience: set the global UI locale (or clear it when `null`). */
export function setGlobalLocale(loc) {
    mergeGlobalConfig({ lang: loc });
}
//# sourceMappingURL=global-config.js.map