// ---------------------------------------------------------------------------
// settings-store.ts — File-backed credential store for Skill Studio.
//
// Single on-disk file at `<configDir>/keys.env` (default configDir:
// `~/.vskill`, overridable via VSKILL_CONFIG_DIR). KEY=VALUE dotenv format.
// Atomic write via temp-file + rename; POSIX writes explicit 0600.
//
// Contract:
//   - Raw keys NEVER appear in any log, error message, toast, or stdout.
//     Only `redactKey()` output (`****<last-4>`) may be emitted.
//   - `readKey()` consults `process.env` FIRST, then the raw-key `memoryMap`.
//     After `mergeStoredKeysIntoEnv()` the `memoryMap` is cleared; subsequent
//     reads hit process.env directly, minimizing plaintext dwell time.
//   - `listKeys()` returns { stored, updatedAt } metadata only. A parallel
//     `metadataMap` holds `{ updatedAt }` and SURVIVES merge, so listKeys()
//     stays truthful across a boot cycle without keeping key bytes around.
//   - `removeKey()` is idempotent; clears memoryMap, metadataMap, and the
//     process.env entry (post-merge, env is where the live key lives).
//   - Malformed lines in keys.env never crash parse — they are skipped with
//     exactly ONE aggregated warning.
//
// Tests inject `{logger, fs, configDir}` via `resetSettingsStore(opts)`.
// ---------------------------------------------------------------------------
import * as nodeFs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { PROVIDERS, } from "./providers.js";
// ---------------------------------------------------------------------------
// Module-level state. Tests reset via `resetSettingsStore()`.
// ---------------------------------------------------------------------------
const defaultLogger = {
    warn: (m) => console.warn(m),
    error: (m) => console.error(m),
};
// Wrappers read through the nodeFs namespace at CALL time so test files that
// mock `node:fs` without exporting every member (e.g. comparison-sse-events
// which only mocks existsSync/readFileSync/writeFileSync) don't fail at
// module-load time. Direct property access happens only inside the call.
const defaultFs = {
    writeFileSync: (...args) => nodeFs.writeFileSync(...args),
    renameSync: (...args) => nodeFs.renameSync(...args),
    chmodSync: (...args) => nodeFs.chmodSync(...args),
    readFileSync: ((...args) => nodeFs.readFileSync(...args)),
    existsSync: (...args) => nodeFs.existsSync(...args),
    unlinkSync: (...args) => nodeFs.unlinkSync(...args),
    mkdirSync: ((...args) => nodeFs.mkdirSync(...args)),
    statSync: ((...args) => nodeFs.statSync(...args)),
};
let logger = defaultLogger;
let fsImpl = defaultFs;
let configDirOverride = null;
const memoryMap = new Map();
// 0682 F-002 (review iter 2): tracks env-var names mutated by
// mergeStoredKeysIntoEnv(). removeKey() only deletes process.env entries
// that are in this set, preserving real env vars set by the operator's
// shell. Pre-fix removeKey() unconditionally wiped process.env even when
// the live value originated from a real env (not the file store) — clicking
// Remove in Settings could mute a key the operator deliberately exported.
const envVarsMutatedByMerge = new Set();
// metadataMap retains `{ updatedAt }` after mergeStoredKeysIntoEnv() clears
// the raw-key memoryMap. Keeps listKeys() truthful across a boot cycle without
// extending plaintext dwell time.
const metadataMap = new Map();
let loaded = false;
// ---------------------------------------------------------------------------
// Path resolution
// ---------------------------------------------------------------------------
function resolveConfigDir() {
    if (configDirOverride)
        return configDirOverride;
    const fromEnv = process.env.VSKILL_CONFIG_DIR;
    if (fromEnv && fromEnv.length > 0)
        return fromEnv;
    return path.join(os.homedir(), ".vskill");
}
export function getKeysFilePath() {
    return path.join(resolveConfigDir(), "keys.env");
}
// ---------------------------------------------------------------------------
// Redaction
// ---------------------------------------------------------------------------
export function redactKey(key) {
    if (!key || key.length <= 4)
        return "****";
    return "****" + key.slice(-4);
}
// ---------------------------------------------------------------------------
// Parser — lenient, single aggregated warning
// ---------------------------------------------------------------------------
function parseFile(contents) {
    const entries = new Map();
    let malformedCount = 0;
    // Strip BOM.
    const stripped = contents.replace(/^\uFEFF/, "");
    const lines = stripped.split(/\r?\n/);
    const envToId = new Map(PROVIDERS.map((p) => [p.envVarName, p.id]));
    for (const rawLine of lines) {
        const line = rawLine.trim();
        if (line.length === 0)
            continue;
        if (line.startsWith("#"))
            continue;
        try {
            const eq = line.indexOf("=");
            if (eq <= 0) {
                malformedCount++;
                continue;
            }
            const name = line.slice(0, eq).trim();
            const value = line.slice(eq + 1).trim();
            if (name.length === 0 || value.length === 0) {
                malformedCount++;
                continue;
            }
            const pid = envToId.get(name);
            if (!pid) {
                // Unknown env var name — skip silently (not "malformed", just
                // unrelated). Do NOT count toward malformedCount.
                continue;
            }
            entries.set(pid, { key: value, updatedAt: new Date().toISOString() });
        }
        catch {
            malformedCount++;
        }
    }
    return { entries, malformedCount };
}
function loadIfNeeded() {
    if (loaded)
        return;
    loaded = true;
    const filePath = getKeysFilePath();
    if (!fsImpl.existsSync(filePath))
        return;
    try {
        const contents = fsImpl.readFileSync(filePath, "utf8");
        const { entries, malformedCount } = parseFile(contents);
        for (const [pid, entry] of entries)
            memoryMap.set(pid, entry);
        if (malformedCount > 0) {
            logger.warn(`[settings-store] ${malformedCount} malformed line(s) in ${filePath} were skipped`);
        }
    }
    catch (err) {
        logger.warn(`[settings-store] failed to read ${filePath}: ${err.message}`);
    }
}
// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------
function serialize(entries) {
    const lines = [
        "# vskill credentials — do not commit. Managed by `vskill keys` or Skill Studio Settings.",
    ];
    for (const p of PROVIDERS) {
        const entry = entries.get(p.id);
        if (entry)
            lines.push(`${p.envVarName}=${entry.key}`);
    }
    return lines.join("\n") + "\n";
}
function atomicWrite(contents) {
    const filePath = getKeysFilePath();
    const dir = path.dirname(filePath);
    fsImpl.mkdirSync(dir, { recursive: true });
    const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
    try {
        fsImpl.writeFileSync(tmpPath, contents, "utf8");
        if (process.platform !== "win32") {
            try {
                fsImpl.chmodSync(tmpPath, 0o600);
            }
            catch {
                /* non-fatal; rename will still proceed */
            }
        }
        fsImpl.renameSync(tmpPath, filePath);
    }
    catch (err) {
        // Clean up tmp if present.
        try {
            if (fsImpl.existsSync(tmpPath))
                fsImpl.unlinkSync(tmpPath);
        }
        catch {
            /* swallow */
        }
        throw err;
    }
}
// ---------------------------------------------------------------------------
// Public API — save / read / remove / list
// ---------------------------------------------------------------------------
export async function saveKey(provider, key) {
    if (!key || typeof key !== "string" || key.trim().length === 0) {
        throw new Error("key must be non-empty string");
    }
    loadIfNeeded();
    // Build the full entry set: current memory snapshot + new entry.
    const updatedAt = new Date().toISOString();
    const next = new Map(memoryMap);
    next.set(provider, { key, updatedAt });
    try {
        atomicWrite(serialize(next));
    }
    catch (err) {
        // NEVER include the raw key in the error message.
        const redacted = redactKey(key);
        logger.error(`[settings-store] saveKey(${provider}, ${redacted}) failed: ${err.message}`);
        throw new Error(`saveKey(${provider}, ${redacted}) failed: ${err.message}`);
    }
    memoryMap.set(provider, { key, updatedAt });
    metadataMap.set(provider, { updatedAt });
    return { updatedAt };
}
export function readKey(provider) {
    loadIfNeeded();
    const descriptor = PROVIDERS.find((p) => p.id === provider);
    if (descriptor) {
        const fromEnv = process.env[descriptor.envVarName];
        if (fromEnv && fromEnv.length > 0)
            return fromEnv;
    }
    return memoryMap.get(provider)?.key ?? null;
}
export function readKeySync(provider) {
    return readKey(provider);
}
export function hasKeySync(provider) {
    return readKey(provider) !== null;
}
export async function removeKey(provider) {
    loadIfNeeded();
    // Idempotent: short-circuit only if neither map has the provider AND the
    // on-disk file won't have it either. Post-merge, memoryMap is empty but
    // metadataMap is the source of truth for "was it stored?".
    if (!memoryMap.has(provider) && !metadataMap.has(provider))
        return;
    const next = new Map(memoryMap);
    next.delete(provider);
    try {
        atomicWrite(serialize(next));
    }
    catch (err) {
        logger.error(`[settings-store] removeKey(${provider}) failed: ${err.message}`);
        throw err;
    }
    memoryMap.delete(provider);
    metadataMap.delete(provider);
    // Clear the process.env copy too — post-merge, this is where the live key
    // lives. Without this, a "remove" followed by a readKey() would still
    // resolve the removed provider from process.env.
    // 0682 F-002 (review iter 2): only delete process.env entries that
    // mergeStoredKeysIntoEnv() actually mutated. Real env vars set by the
    // operator's shell must survive removeKey() — they belong to the user's
    // environment, not the vskill file store.
    const descriptor = PROVIDERS.find((p) => p.id === provider);
    if (descriptor && envVarsMutatedByMerge.has(descriptor.envVarName)) {
        delete process.env[descriptor.envVarName];
        envVarsMutatedByMerge.delete(descriptor.envVarName);
    }
}
export function listKeys() {
    loadIfNeeded();
    const read = (p) => {
        // memoryMap wins if present (freshest), else fall back to retained
        // metadata from a prior mergeStoredKeysIntoEnv() call.
        const live = memoryMap.get(p);
        if (live)
            return { stored: true, updatedAt: live.updatedAt };
        const meta = metadataMap.get(p);
        if (meta)
            return { stored: true, updatedAt: meta.updatedAt };
        return { stored: false, updatedAt: null };
    };
    return {
        anthropic: read("anthropic"),
        openai: read("openai"),
        openrouter: read("openrouter"),
    };
}
// ---------------------------------------------------------------------------
// Boot-time env merge
// ---------------------------------------------------------------------------
export function mergeStoredKeysIntoEnv() {
    loadIfNeeded();
    for (const p of PROVIDERS) {
        const entry = memoryMap.get(p.id);
        if (!entry)
            continue;
        // Nullish coalescing: real env vars always win.
        if (process.env[p.envVarName] === undefined) {
            process.env[p.envVarName] = entry.key;
            // 0682 F-002 (review iter 2): track that this env var came from the
            // file store, so removeKey() can safely delete it later WITHOUT
            // touching real env vars set by the operator's shell.
            envVarsMutatedByMerge.add(p.envVarName);
        }
        // Retain metadata so listKeys() can report `stored: true` post-merge
        // without keeping the raw key plaintext around.
        metadataMap.set(p.id, { updatedAt: entry.updatedAt });
    }
    // Shrink plaintext dwell time.
    memoryMap.clear();
}
export function resetSettingsStore(opts = {}) {
    memoryMap.clear();
    metadataMap.clear();
    envVarsMutatedByMerge.clear();
    loaded = false;
    logger = opts.logger ?? defaultLogger;
    fsImpl = opts.fs ?? defaultFs;
    configDirOverride = opts.configDir ?? null;
}
//# sourceMappingURL=settings-store.js.map