// ---------------------------------------------------------------------------
// keychain.ts — OS-level secure storage for the vskill CLI's GitHub token.
//
// Backend selection (in order):
//   1. @napi-rs/keyring (macOS Keychain / Windows DPAPI / libsecret on Linux)
//   2. ~/.vskill/keys.env file with mode 0600 (when keyring is unavailable —
//      headless Linux without keyring daemon, sandboxed environments, etc.)
//
// Public surface:
//   - getGitHubToken(): string | null
//   - setGitHubToken(token: string): void
//   - clearGitHubToken(): boolean   /// true if a token was removed
//
// Tokens are NEVER logged. Callers should redact via `redactToken()` before
// any user-facing output.
//
// Service identity (0836 US-006 — canonical, shared with the Rust desktop):
//   - service:  "com.verifiedskill.desktop"   (was "vskill-github")
//   - account:  "github-oauth-token"          (was "github_token")
//
// On first construction we run a one-time migration that moves any token
// from the legacy slot into the canonical slot. The legacy fallback in
// `getGitHubToken` covers users who haven't yet booted a process that ran
// the migration (e.g., the Rust desktop wrote the canonical slot, but a
// vintage CLI install reads from the old one). Both paths are scheduled
// for removal AFTER vskill 1.1.x — see keychain-migration.ts for the
// removal target.
// ---------------------------------------------------------------------------
import * as nodeFs from "node:fs";
import * as nodePath from "node:path";
import * as nodeOs from "node:os";
import { runKeychainMigration } from "./migration/keychain-migration.js";
export const SERVICE_NAME = "com.verifiedskill.desktop";
export const GITHUB_TOKEN_KEY = "github-oauth-token";
// 0839 US-005: verified-skill API token (`vsk_*`) lives in a parallel
// keychain slot so `gho_*` (GitHub OAuth, used for direct GitHub API calls)
// and `vsk_*` (our minted API token, used for verified-skill.com calls)
// coexist. See ADR-001 in 0839/plan.md. Distinct slots keep keychain
// lookups O(1) and avoid prefix-sniffing every read.
export const VSKILL_TOKEN_SERVICE = "vskill-token";
export const VSKILL_TOKEN_KEY = "vskill-api-token";
// 0836 US-006: legacy slot — read-only fallback during the one-release
// compat window. Remove after vskill 1.1.x ships.
// TODO(0836-followup): drop LEGACY_SERVICE_NAME + LEGACY_TOKEN_KEY + the
// fallback branch in getGitHubToken once 1.1.x has had one full release
// cycle to migrate users.
const LEGACY_SERVICE_NAME = "vskill-github";
const LEGACY_TOKEN_KEY = "github_token";
const DEFAULT_FALLBACK = nodePath.join(nodeOs.homedir(), ".vskill", "keys.env");
const defaultFs = {
    readFileSync: (p) => nodeFs.readFileSync(p, { encoding: "utf8" }),
    writeFileSync: (p, c, mode) => nodeFs.writeFileSync(p, c, { encoding: "utf8", mode }),
    chmodSync: (p, mode) => nodeFs.chmodSync(p, mode),
    existsSync: (p) => nodeFs.existsSync(p),
    mkdirSync: (p) => nodeFs.mkdirSync(p, { recursive: true, mode: 0o700 }),
    unlinkSync: (p) => nodeFs.unlinkSync(p),
};
// 0836 US-006: per-process flag so the one-time migration runs exactly
// once even if multiple keychains are constructed (the test suite does
// this; production has a single default keychain).
let _migrationRun = false;
/** Test-only — reset the migration-once flag so test factories can re-run it. */
export function _resetMigrationFlagForTests() {
    _migrationRun = false;
}
function loadDefaultKeyring() {
    // The native module is loaded lazily so the CLI still boots in environments
    // where the binary isn't available (rare, since @napi-rs/keyring ships
    // prebuilds for the major platforms — but we never want a missing native to
    // crash unrelated subcommands like `vskill --version`).
    try {
        // eslint-disable-next-line @typescript-eslint/no-var-requires
        const mod = require("@napi-rs/keyring");
        return {
            setPassword(service, account, password) {
                const entry = new mod.Entry(service, account);
                entry.setPassword(password);
            },
            getPassword(service, account) {
                try {
                    const entry = new mod.Entry(service, account);
                    return entry.getPassword();
                }
                catch {
                    return null;
                }
            },
            deletePassword(service, account) {
                try {
                    const entry = new mod.Entry(service, account);
                    return entry.deletePassword();
                }
                catch {
                    return false;
                }
            },
        };
    }
    catch {
        return null;
    }
}
function parseEnvFile(content) {
    const map = new Map();
    for (const rawLine of content.split(/\r?\n/)) {
        const line = rawLine.trim();
        if (!line || line.startsWith("#"))
            continue;
        const eq = line.indexOf("=");
        if (eq <= 0)
            continue;
        const key = line.slice(0, eq).trim();
        const val = line.slice(eq + 1).trim();
        map.set(key, val);
    }
    return map;
}
function serializeEnvMap(map) {
    const lines = [
        "# vskill keys.env — fallback secret store (mode 0600).",
        "# Managed by vskill; edit at your own risk.",
    ];
    for (const [k, v] of map)
        lines.push(`${k}=${v}`);
    return lines.join("\n") + "\n";
}
export function createKeychain(opts = {}) {
    const fs = opts.fs ?? defaultFs;
    const fallbackPath = opts.fallbackPath ?? DEFAULT_FALLBACK;
    const warn = opts.warn ?? ((msg) => process.stderr.write(`${msg}\n`));
    let keyring;
    if (opts.keyring !== undefined)
        keyring = opts.keyring;
    else
        keyring = loadDefaultKeyring();
    // 0836 US-006: run the one-time legacy-slot → canonical-slot migration
    // exactly once per process. The helper is idempotent (skip-flag +
    // mutex), so calling it on every createKeychain construction is safe;
    // the per-process flag is only an optimization to avoid the cheap
    // file-stat check on every test factory.
    if (!_migrationRun) {
        _migrationRun = true;
        try {
            runKeychainMigration({ keyring });
        }
        catch {
            // Best-effort — migration failure leaves the legacy fallback path
            // intact; no user-visible regression.
        }
    }
    let warnedFallback = false;
    let fallbackInUse = false;
    function tryKeyring(fn) {
        if (!keyring)
            return { ok: false };
        try {
            return { ok: true, value: fn(keyring) };
        }
        catch {
            return { ok: false };
        }
    }
    function ensureFallbackWarned() {
        fallbackInUse = true;
        if (warnedFallback)
            return;
        warnedFallback = true;
        warn(`vskill: OS keychain unavailable; storing GitHub token at ${fallbackPath} (mode 0600).`);
    }
    function readFallback() {
        if (!fs.existsSync(fallbackPath))
            return new Map();
        try {
            return parseEnvFile(fs.readFileSync(fallbackPath));
        }
        catch {
            return new Map();
        }
    }
    function writeFallback(map) {
        const dir = nodePath.dirname(fallbackPath);
        if (!fs.existsSync(dir))
            fs.mkdirSync(dir);
        fs.writeFileSync(fallbackPath, serializeEnvMap(map), 0o600);
        fs.chmodSync(fallbackPath, 0o600);
    }
    return {
        setGitHubToken(token) {
            if (!token || typeof token !== "string") {
                throw new Error("setGitHubToken: token must be a non-empty string");
            }
            const r = tryKeyring((kr) => kr.setPassword(SERVICE_NAME, GITHUB_TOKEN_KEY, token));
            if (r.ok)
                return;
            ensureFallbackWarned();
            const map = readFallback();
            map.set(GITHUB_TOKEN_KEY, token);
            writeFallback(map);
        },
        getGitHubToken() {
            const r = tryKeyring((kr) => kr.getPassword(SERVICE_NAME, GITHUB_TOKEN_KEY));
            if (r.ok && r.value) {
                // 0836 hardening (F-008 follow-up): if BOTH the canonical and the
                // legacy slots are populated, log a WARN. This catches the
                // dual-writer drift case where a vintage `vskill@<old>` install
                // wrote to legacy after migration's done-flag was set. Canonical
                // wins (current return), but operators get a signal the user has
                // two copies and should re-authenticate.
                try {
                    const legacy = tryKeyring((kr) => kr.getPassword(LEGACY_SERVICE_NAME, LEGACY_TOKEN_KEY));
                    if (legacy.ok && legacy.value && legacy.value !== r.value) {
                        // Avoid logging the token values themselves — only the fact of drift.
                        // eslint-disable-next-line no-console
                        console.warn("[vskill keychain] both canonical and legacy slots are populated; canonical wins. Run `vskill auth login` to clear drift.");
                    }
                }
                catch {
                    // Non-fatal: drift detection is best-effort.
                }
                return r.value;
            }
            if (r.ok && r.value === null) {
                // 0836 US-006 legacy fallback: a vintage CLI install that hasn't
                // booted post-migration may still hold the token in the old slot.
                // Honor it for one release window. The migration helper will move
                // this on the next process boot; until then we read from where it
                // lives.
                // TODO(0836-followup): remove after vskill 1.1.x ships.
                const legacy = tryKeyring((kr) => kr.getPassword(LEGACY_SERVICE_NAME, LEGACY_TOKEN_KEY));
                if (legacy.ok && legacy.value)
                    return legacy.value;
                // keyring works but slot is empty — also peek at fallback in case the
                // user set a token before keyring became available, but DO NOT warn
                // because keyring is fine.
                const map = readFallback();
                if (map.has(GITHUB_TOKEN_KEY))
                    return map.get(GITHUB_TOKEN_KEY) ?? null;
                // 0836 US-006: legacy file-fallback key, kept for the same
                // one-release window as the legacy keyring service.
                return map.get(LEGACY_TOKEN_KEY) ?? null;
            }
            ensureFallbackWarned();
            const map = readFallback();
            if (map.has(GITHUB_TOKEN_KEY))
                return map.get(GITHUB_TOKEN_KEY) ?? null;
            return map.get(LEGACY_TOKEN_KEY) ?? null;
        },
        clearGitHubToken() {
            let removed = false;
            const r = tryKeyring((kr) => kr.deletePassword(SERVICE_NAME, GITHUB_TOKEN_KEY));
            if (r.ok && r.value)
                removed = true;
            // Always also clear the fallback — defense in depth in case both backends
            // hold copies (e.g., keyring re-enabled after a fallback period).
            if (fs.existsSync(fallbackPath)) {
                const map = readFallback();
                if (map.delete(GITHUB_TOKEN_KEY)) {
                    if (map.size === 0) {
                        try {
                            fs.unlinkSync(fallbackPath);
                        }
                        catch {
                            writeFallback(map);
                        }
                    }
                    else {
                        writeFallback(map);
                    }
                    removed = true;
                }
            }
            return removed;
        },
        // -----------------------------------------------------------------
        // 0839 US-005: verified-skill API token (`vsk_*`).
        //
        // Same storage contract as the GitHub token (keyring preferred,
        // 0600 file fallback) but a distinct service/account so the two
        // tokens never collide. Public surface mirrors the GitHub helpers.
        // -----------------------------------------------------------------
        setVskillToken(token) {
            if (!token || typeof token !== "string") {
                throw new Error("setVskillToken: token must be a non-empty string");
            }
            const r = tryKeyring((kr) => kr.setPassword(VSKILL_TOKEN_SERVICE, VSKILL_TOKEN_KEY, token));
            if (r.ok)
                return;
            ensureFallbackWarned();
            const map = readFallback();
            map.set(VSKILL_TOKEN_KEY, token);
            writeFallback(map);
        },
        getVskillToken() {
            const r = tryKeyring((kr) => kr.getPassword(VSKILL_TOKEN_SERVICE, VSKILL_TOKEN_KEY));
            if (r.ok && r.value)
                return r.value;
            if (r.ok && r.value === null) {
                // Keyring works but slot is empty — peek at fallback in case the
                // user wrote a token before keyring became available. Don't warn:
                // the keyring path is healthy.
                const map = readFallback();
                return map.get(VSKILL_TOKEN_KEY) ?? null;
            }
            ensureFallbackWarned();
            const map = readFallback();
            return map.get(VSKILL_TOKEN_KEY) ?? null;
        },
        clearVskillToken() {
            let removed = false;
            const r = tryKeyring((kr) => kr.deletePassword(VSKILL_TOKEN_SERVICE, VSKILL_TOKEN_KEY));
            if (r.ok && r.value)
                removed = true;
            if (fs.existsSync(fallbackPath)) {
                const map = readFallback();
                if (map.delete(VSKILL_TOKEN_KEY)) {
                    if (map.size === 0) {
                        try {
                            fs.unlinkSync(fallbackPath);
                        }
                        catch {
                            writeFallback(map);
                        }
                    }
                    else {
                        writeFallback(map);
                    }
                    removed = true;
                }
            }
            return removed;
        },
        usingFallback() {
            return fallbackInUse || keyring === null;
        },
    };
}
/**
 * Convenience accessor used by every CLI surface that needs the GitHub token.
 * Lazily constructs a default keychain on first call.
 */
let _defaultKeychain = null;
export function getDefaultKeychain() {
    if (_defaultKeychain)
        return _defaultKeychain;
    _defaultKeychain = createKeychain();
    return _defaultKeychain;
}
/** Test-only reset hook — never call from production code. */
export function _resetDefaultKeychainForTests() {
    _defaultKeychain = null;
}
export function getGitHubToken() {
    return getDefaultKeychain().getGitHubToken();
}
export function setGitHubToken(token) {
    getDefaultKeychain().setGitHubToken(token);
}
export function clearGitHubToken() {
    return getDefaultKeychain().clearGitHubToken();
}
// 0839 US-005: module-level convenience accessors for the verified-skill
// API token. Mirror the GitHub helpers above for parity.
export function getVskillToken() {
    return getDefaultKeychain().getVskillToken();
}
export function setVskillToken(token) {
    getDefaultKeychain().setVskillToken(token);
}
export function clearVskillToken() {
    return getDefaultKeychain().clearVskillToken();
}
/** Last-4 redaction for log-safe output. */
export function redactToken(token) {
    if (!token)
        return "(none)";
    if (token.length <= 8)
        return "****";
    return `****${token.slice(-4)}`;
}
//# sourceMappingURL=keychain.js.map