// Headless instance configuration: which role this device performs, where it
// stores data, how it bootstraps, and its storage quota. Written by `setup`,
// read by `run`/`status`. The fs module is injected for testability.
import { DEFAULT_EXEC_FLOORS } from '@listam/backend/lib/voice-feedback.mjs'

export const ROLES = Object.freeze(['participant', 'blind-storage'])
export const DEFAULT_MAX_STORAGE_BYTES = 1024 * 1024 * 1024 // 1 GiB
export const DEFAULT_VOICE_PORT = 9994
// Voice training clips kept on disk before the oldest are pruned. Sized as a
// share of DEFAULT_MAX_STORAGE_BYTES, not left at the writer's own 5000 default
// (see normalizeVoiceConfig).
export const DEFAULT_DATASET_MAX_FILES = 1500

// Per-locale initial whisper prompt, applied only when no explicit prompt is set
// AND the locale is a concrete language (never 'auto'). Two jobs: bias whisper
// toward the list-command vocabulary (anti-merge, e.g. "add milk" not "Admilk")
// and reinforce the spoken language so e.g. Italian audio stops drifting to
// English. Keep it short — verbs + a few frequent nouns; a long prompt over-anchors.
// DUPLICATED in listam-desktop/src/voice-host-worker.mjs (polyrepo; keep in sync).
export const DEFAULT_VOICE_PROMPTS = Object.freeze({
    en: 'yo petito add milk. yo petito add bread. yo petito remove eggs. grocery list: milk, bread, eggs, tomatoes, pasta, coffee.',
    it: 'yo petito aggiungi latte. yo petito aggiungi pane. yo petito togli uova. lista della spesa: latte, pane, uova, pomodori, pasta, caffè.',
    es: 'yo petito añade leche. yo petito añade pan. yo petito quita huevos. lista de la compra: leche, pan, huevos, tomates, pasta, café.',
    de: 'yo petito füge Milch hinzu. yo petito füge Brot hinzu. yo petito entferne Eier. Einkaufsliste: Milch, Brot, Eier, Tomaten, Nudeln, Kaffee.',
    fr: 'yo petito ajoute du lait. yo petito ajoute du pain. yo petito enlève les œufs. liste de courses : lait, pain, œufs, tomates, pâtes, café.',
    pt: 'yo petito adiciona leite. yo petito adiciona pão. yo petito remove ovos. lista de compras: leite, pão, ovos, tomates, massa, café.',
})

// Voice assistant config (off by default). Reads an optional `voice` block from
// headless-config.json with env overrides, so a leaf can stream audio here for
// transcription + command execution. modelPath must point at a whisper.cpp GGML
// model for STT to be available.
export function normalizeVoiceConfig(raw = {}, env = {}) {
    const r = raw && typeof raw === 'object' ? raw : {}
    const port = Number(env.LISTAM_VOICE_PORT ?? r.audioPort ?? DEFAULT_VOICE_PORT)
    // STT decoder hints passed through to the whisper.cpp adapter as extraArgs.
    // An initial prompt biases whisper toward the command vocabulary — short
    // list-commands transcribe poorly without context (e.g. "add milk" merges
    // to "Admilk"); seeding the expected words fixes most of it. config.extraArgs
    // is appended verbatim for any other whisper-cli flag.
    const locale = env.LISTAM_VOICE_LOCALE || r.locale || 'auto'
    // An explicit prompt always wins; otherwise fall back to the built-in
    // per-locale default, but only for a concrete locale (never 'auto', or we'd
    // anchor auto-detected audio to one language).
    const prompt = env.LISTAM_VOICE_PROMPT ?? r.prompt ?? null
    const effectivePrompt = prompt ?? (locale !== 'auto' ? (DEFAULT_VOICE_PROMPTS[locale] ?? null) : null)
    const extraArgs = []
    if (effectivePrompt) extraArgs.push('--prompt', String(effectivePrompt))
    // Encoder audio-context cap (whisper -ac). Whisper always pads audio to a
    // fixed 30 s window (1500 audio-ctx frames); short list-commands pay that
    // full encoder cost for ~3 s of speech. -ac N caps the encoder window
    // (N/1500 × 30 s), e.g. 768 ≈ 15.4 s — a measured ~2.7x encoder cut on real
    // utterances with the transcript unchanged. Keep ≥768 and a multiple of 64
    // (upstream guidance: lower degrades accuracy). 0/absent = full window.
    const audioCtx = Number(env.LISTAM_VOICE_AUDIO_CTX ?? r.audioCtx ?? 0)
    if (Number.isInteger(audioCtx) && audioCtx > 0) extraArgs.push('-ac', String(audioCtx))
    if (Array.isArray(r.extraArgs)) extraArgs.push(...r.extraArgs.map(String))
    // Hard cap on utterance length accepted from the leaf (seconds). Bounds the
    // STT window server-side even before the leaf firmware's own capture cap is
    // reflashed: audio past the cap is dropped and transcription starts at the
    // cap. 0/absent = the assembler default (30 s).
    const maxSecs = Number(env.LISTAM_VOICE_MAX_SECONDS ?? r.maxUtteranceSeconds ?? 0)
    const serverPort = Number(env.LISTAM_VOICE_SERVER_PORT ?? r.serverPort ?? 0)
    const engine = env.LISTAM_VOICE_ENGINE || r.engine || 'whisper-cpp'
    // Training-dataset capture cap, in clips. Rotation is by file count (the
    // writer prunes oldest-first past the cap), so this is the only bound on how
    // much of the storage quota the corpus may take. The @listam/backend default
    // is 5000 — at ~107 KB per real clip that is ~536 MB, i.e. HALF of the 1 GiB
    // DEFAULT_MAX_STORAGE_BYTES, which is how the always-on peer went over quota
    // on 2026-07-28. Cap it at a share of the quota instead: 1500 clips ≈ 160 MB
    // ≈ 15%, still months of history at the observed ~10-40 clips/day.
    const datasetMaxFiles = Number(env.LISTAM_VOICE_DATASET_MAX_FILES ?? r.datasetMaxFiles ?? DEFAULT_DATASET_MAX_FILES)
    return {
        enabled: env.LISTAM_VOICE_ENABLED === '1' || r.enabled === true,
        engine,
        // The binary matching the engine: whisper-server keeps a warm model,
        // whisper-cli spawns per utterance. An explicit binPath always wins.
        binPath: env.LISTAM_VOICE_BIN || r.binPath || (engine === 'whisper-server' ? 'whisper-server' : 'whisper-cli'),
        modelPath: env.LISTAM_VOICE_MODEL || r.modelPath || null,
        audioPort: Number.isInteger(port) && port > 0 && port < 65536 ? port : DEFAULT_VOICE_PORT,
        // whisper-server engine only: local port the managed server binds on.
        serverPort: Number.isInteger(serverPort) && serverPort > 0 && serverPort < 65536 ? serverPort : undefined,
        locale,
        notesListId: r.notesListId || 'voicenotes',
        execConfidence: normalizeExecFloors(r.execConfidence, env),
        extraArgs,
        maxUtteranceSeconds: Number.isFinite(maxSecs) && maxSecs > 0 ? maxSecs : null,
        // Where captured utterances land. service.mjs already reads this key; it
        // was never produced here, so a configured `voice.datasetDir` was silently
        // ignored and capture always fell back to <storage>/voice-dataset.
        datasetDir: typeof (env.LISTAM_VOICE_DATASET_DIR ?? r.datasetDir) === 'string'
            ? String(env.LISTAM_VOICE_DATASET_DIR ?? r.datasetDir).trim() || null
            : null,
        datasetMaxFiles: Number.isInteger(datasetMaxFiles) && datasetMaxFiles > 0
            ? datasetMaxFiles
            : DEFAULT_DATASET_MAX_FILES,
    }
}

// Scheduled-backup config (rolling 15-min / daily / weekly tiers). The scheduler
// itself lives in @listam/backend and starts automatically once a backup password
// is set; this block only carries the two operator knobs a headless box needs to
// bootstrap non-interactively: whether the schedule is on, and an optional password
// to seed so a fresh box gets scheduled backups with zero interactive steps.
//   LISTAM_BACKUP_SCHEDULED (true/false) overrides scheduledEnabled.
//   LISTAM_BACKUP_PASSWORD overrides password.
// The password is the same two-factor secret as pre-join auto-backups (device key +
// this value); it is never logged (the logger deep-redacts) and only used to call
// RPC_SET_BACKUP_PASSWORD on startup when no password is set yet.
export function normalizeBackupConfig(raw = {}, env = {}) {
    const r = raw && typeof raw === 'object' ? raw : {}
    const envEnabled = env.LISTAM_BACKUP_SCHEDULED
    const scheduledEnabled = typeof envEnabled === 'string' && envEnabled.trim()
        ? !/^(0|false|no|off)$/i.test(envEnabled.trim())
        : r.scheduledEnabled !== false
    const rawPassword = env.LISTAM_BACKUP_PASSWORD ?? r.password
    const password = typeof rawPassword === 'string' && rawPassword.length > 0 ? rawPassword : null
    return { scheduledEnabled, password }
}

// Per-intent write-gate confidence floors (see voice-feedback.shouldExecuteIntent).
// Confidence floors used only when a caller explicitly enables the legacy
// wake-optional policy. The default voice handler now requires the full
// "yo petito" host transcript before any mutation. Each floor is overridable
// from config.voice.execConfidence or a LISTAM_VOICE_FLOOR_* env.
function normalizeExecFloors(raw = {}, env = {}) {
    const ec = raw && typeof raw === 'object' ? raw : {}
    const floor = (envKey, value, dflt) => {
        const v = Number(env[envKey] ?? value)
        return Number.isFinite(v) && v >= 0 && v <= 1 ? v : dflt
    }
    return {
        add_item: floor('LISTAM_VOICE_FLOOR_ADD', ec.add_item, DEFAULT_EXEC_FLOORS.add_item),
        remove_item: floor('LISTAM_VOICE_FLOOR_REMOVE', ec.remove_item, DEFAULT_EXEC_FLOORS.remove_item),
        note: floor('LISTAM_VOICE_FLOOR_NOTE', ec.note, DEFAULT_EXEC_FLOORS.note),
    }
}

const HEX_32 = /^[0-9a-f]{64}$/i

export function configPath(storageDir) {
    return `${storageDir}/headless-config.json`
}

export function normalizeRole(value) {
    return ROLES.includes(value) ? value : null
}

export function parseBootstrap(value) {
    if (Array.isArray(value)) return value.length > 0 ? value : null
    if (typeof value !== 'string' || !value.trim()) return null
    const nodes = value
        .split(',')
        .map((entry) => entry.trim())
        .filter(Boolean)
        .map((entry) => {
            const at = entry.lastIndexOf(':')
            if (at <= 0) return null
            const host = entry.slice(0, at)
            const port = Number(entry.slice(at + 1))
            return host && Number.isInteger(port) && port > 0 ? { host, port } : null
        })
    return nodes.every(Boolean) && nodes.length > 0 ? nodes : null
}

export function normalizeBaseKeyHex(value) {
    const text = typeof value === 'string' ? value.trim().toLowerCase() : ''
    return HEX_32.test(text) ? text : null
}

export function buildConfig({ role, baseKeyHex, bootstrap, maxStorageBytes, leafBridgePort, name, backupScheduled, backupPassword }) {
    const normalizedRole = normalizeRole(role)
    if (!normalizedRole) {
        return { ok: false, reason: `role must be one of: ${ROLES.join(', ')}` }
    }

    const config = {
        version: 1,
        role: normalizedRole,
        maxStorageBytes: Number.isFinite(maxStorageBytes) && maxStorageBytes > 0
            ? Math.floor(maxStorageBytes)
            : DEFAULT_MAX_STORAGE_BYTES,
    }

    // Human-readable instance name advertised to peers (e.g. "Raspberry Pi").
    // A headless node has no UI, so it names itself from config; the service
    // writes a synced peer-label item on boot. Overridable at runtime with
    // LISTAM_INSTANCE_NAME. Clamped to 64 chars (matches @listam/domain MAX_LABEL_NAME).
    const cleanName = typeof name === 'string' ? name.trim().slice(0, 64) : ''
    if (cleanName) config.name = cleanName

    const parsedBootstrap = parseBootstrap(bootstrap)
    if (parsedBootstrap) config.bootstrap = parsedBootstrap

    const parsedLeafPort = Number(leafBridgePort)
    if (Number.isInteger(parsedLeafPort) && parsedLeafPort > 0 && parsedLeafPort < 65536) {
        config.leafBridgePort = parsedLeafPort
    }

    // Scheduled-backup knobs (participant-only — a blind-storage peer holds no
    // decryptable data to back up). Only persist a `backup` block when a knob is
    // actually set so existing configs stay byte-identical when nothing is asked.
    if (normalizedRole === 'participant') {
        const backup = {}
        if (backupScheduled === false) backup.scheduledEnabled = false
        if (typeof backupPassword === 'string' && backupPassword.length > 0) backup.password = backupPassword
        if (Object.keys(backup).length > 0) config.backup = backup
    }

    if (normalizedRole === 'blind-storage') {
        // A blind helper pins ciphertext cores by public key. It must never be
        // configured with (or later accept) the list encryption key — that is
        // the C2 credential boundary.
        const pin = normalizeBaseKeyHex(baseKeyHex)
        if (!pin) {
            return { ok: false, reason: 'blind-storage requires --base-key <64-hex core key> to pin' }
        }
        config.pins = [pin]
    }

    return { ok: true, config }
}

export function saveConfig(fs, storageDir, config) {
    fs.mkdirSync(storageDir, { recursive: true })
    fs.writeFileSync(configPath(storageDir), JSON.stringify(config, null, 2), { mode: 0o600 })
}

export function loadConfig(fs, storageDir) {
    try {
        const parsed = JSON.parse(fs.readFileSync(configPath(storageDir), 'utf8'))
        if (!parsed || typeof parsed !== 'object' || !normalizeRole(parsed.role)) return null
        return parsed
    } catch {
        return null
    }
}
