/**
 * install/update-chips.mjs — read the SessionStart worker's cache file
 * and produce the colored statusline chip(s) for it.
 *
 * Color scheme (matches GSD precedent):
 *   - yellow ⬆ /apt:update          when update_available
 *   - red    ⚠ stale skills — run /apt:update    when stale_files_total > 0
 *   - yellow ⚠ dev install — re-run installer    when dev_install_ahead
 *   - dim    ? /apt:update          when not_published
 *
 * NO_COLOR is honored upstream (statusline computes the `color` flag).
 */
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
const ANSI = {
    yellow: '\x1b[33m',
    red: '\x1b[31m',
    dim: '\x1b[2m',
    reset: '\x1b[0m',
};
/**
 * @typedef {Object} UpdateCache
 * @property {boolean} not_published
 * @property {boolean} update_available
 * @property {boolean} dev_install_ahead
 * @property {number} stale_files_total
 * @property {string} checked_at
 * @property {string|null} [lookup_error]  set when npm view threw a non-404 error
 */
/**
 * Resolve the cache file location. Honors $HOME (tests inject a tmp HOME)
 * via os.homedir() which respects the env.
 *
 * @returns {string}
 */
export function updateCachePath() {
    return join(homedir(), '.cache', 'aperant', 'apt-update-check.json');
}
/**
 * Read the worker's cache file. Returns null when missing or malformed —
 * statusline shows no chips in that case.
 *
 * @returns {UpdateCache|null}
 */
export function readUpdateCache() {
    const path = updateCachePath();
    if (!existsSync(path))
        return null;
    try {
        const data = JSON.parse(readFileSync(path, 'utf-8'));
        if (!data || typeof data !== 'object')
            return null;
        return data;
    }
    catch {
        return null;
    }
}
/** Semver-shape pattern: MAJOR.MINOR.PATCH with optional pre-release and build metadata. */
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
/**
 * Read the worker's cache file with TTL and schema validation. Returns
 * `{ fresh: true, cache }` when the cache exists, parses, declares
 * `schema_version === 1`, and was written within `ttlMs` milliseconds.
 * Returns `{ fresh: false }` when missing, stale, or schema mismatch.
 *
 * Security: `latest_version` and `installed_version` inside
 * `cache.framework` are validated as semver strings. Invalid values are
 * dropped (set to null) so they never propagate into shell-interpolated
 * contexts.
 *
 * ENOENT and JSON parse errors stay silent (expected when the cache is
 * missing or being rewritten). Unexpected errors are logged to stderr.
 *
 * @param {number} ttlMs  Maximum age in milliseconds before cache is treated as stale.
 * @returns {{ fresh: true, cache: Record<string, unknown> } | { fresh: false }}
 */
export function readFreshUpdateCache(ttlMs) {
    const path = updateCachePath();
    if (!existsSync(path))
        return { fresh: false };
    let data;
    try {
        data = JSON.parse(readFileSync(path, 'utf-8'));
    }
    catch (err) {
        // SyntaxError = malformed JSON (expected); anything else is unexpected.
        if (!(err instanceof SyntaxError)) {
            console.error('[update-cache] read failed:', err.message);
        }
        return { fresh: false };
    }
    try {
        if (!data || typeof data !== 'object')
            return { fresh: false };
        if (data.schema_version !== 1)
            return { fresh: false };
        if (typeof data.checked_at !== 'string')
            return { fresh: false };
        const age = Date.now() - new Date(data.checked_at).getTime();
        if (!Number.isFinite(age) || age < 0 || age >= ttlMs)
            return { fresh: false };
        // Validate semver fields in framework block — reject non-semver strings.
        const fw = data.framework && typeof data.framework === 'object' ? data.framework : {};
        const sanitized = { ...data, framework: { ...fw } };
        for (const field of ['latest_version', 'installed_version']) {
            if (typeof sanitized.framework[field] === 'string' &&
                !SEMVER_RE.test(sanitized.framework[field])) {
                sanitized.framework[field] = null;
            }
        }
        return { fresh: true, cache: sanitized };
    }
    catch (err) {
        console.error('[update-cache] read failed:', err.message);
        return { fresh: false };
    }
}
/**
 * Build the chip string array for a given cache. Empty array when there
 * are no chips to show. Order: stale_files first (most urgent), then
 * update_available, then dev-ahead, then not-published, then lookup_error.
 *
 * @param {UpdateCache|null} cache
 * @param {{ color: boolean }} opts
 * @returns {string[]}
 */
export function formatUpdateChips(cache, { color }) {
    if (!cache)
        return [];
    const chips = [];
    const wrap = (ansi, text) => (color ? `${ansi}${text}${ANSI.reset}` : text);
    if (cache.stale_files_total && cache.stale_files_total > 0) {
        chips.push(wrap(ANSI.red, '⚠ stale skills — run /apt:update'));
    }
    if (cache.update_available) {
        chips.push(wrap(ANSI.yellow, '⬆ /apt:update'));
    }
    if (cache.dev_install_ahead) {
        chips.push(wrap(ANSI.yellow, '⚠ dev install — re-run installer'));
    }
    if (cache.not_published) {
        chips.push(wrap(ANSI.dim, '? /apt:update'));
    }
    // Show a dim warning chip when the npm lookup failed with a non-404 error
    // (network timeout, ENOTFOUND, etc.). This distinguishes "genuinely up to
    // date" from "we don't know because the check failed".
    if (cache.lookup_error) {
        chips.push(wrap(ANSI.dim, '⚠ /apt:update — check failed'));
    }
    return chips;
}
//# sourceMappingURL=update-chips.mjs.map