"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.FileDesignContextStore = exports.canReuseCacheEntry = exports.promptRequestsFreshExtraction = exports.confidenceBucketFor = exports.ageBucketFor = exports.cacheKeyForUrl = exports.normalizeCacheUrl = exports.DEFAULT_CONFIDENCE_THRESHOLD = exports.DEFAULT_FRESHNESS_MS = exports.DESIGN_CONTEXT_SCHEMA_VERSION = void 0;
const crypto_1 = __importDefault(require("crypto"));
const fs_1 = __importDefault(require("fs"));
const os_1 = __importDefault(require("os"));
const path_1 = __importDefault(require("path"));
const logger_1 = require("../../utils/logger");
const log = (0, logger_1.createLogger)('DesignContextStore');
/**
 * Runtime user cache for inspiration-link design context. Persists generated
 * designmd markdown plus safe metadata so future MCP
 * `create_zero_to_one_project` requests with the same web link can reuse
 * prior extraction by default. Treated as runtime user data — never mutates
 * the bundled `src/services/templates/designmd` catalog.
 *
 * Path layout (local filesystem implementation):
 *   <directory>/<sha256(normalizedUrl)>.json
 *
 * The default <directory> is `~/.rivet/design-context-cache/`. The store
 * abstraction is interface-first so future hosted/server deployments can
 * provide a durable backend (HTTPS, KV, SQL) without changing the
 * extraction or MCP orchestration logic.
 */
/**
 * Output/extractor schema version. Bump when the cache entry shape or the
 * generated markdown structure changes incompatibly. Cached entries with a
 * different schema version are not silently reused — they regenerate.
 */
exports.DESIGN_CONTEXT_SCHEMA_VERSION = '2';
/**
 * Default freshness window for a cached entry. Entries older than this are
 * marked `stale` and trigger regeneration by default. Tunable per call so
 * tests can exercise the stale branch without sleeping.
 */
exports.DEFAULT_FRESHNESS_MS = 14 * 24 * 60 * 60 * 1000;
/**
 * Default confidence threshold below which cached entries are considered
 * low-confidence and trigger regeneration. Mirrors the in-extractor
 * `LOW_SIGNAL_THRESHOLD` so cached and live signals stay aligned.
 */
exports.DEFAULT_CONFIDENCE_THRESHOLD = 18;
/**
 * Match prompts that explicitly request fresh/latest/current/regenerated
 * design context for an inspiration link. Matches are evaluated against the
 * raw user prompt — callers MUST keep the prompt out of telemetry payloads.
 *
 * The bare adjectives `fresh`/`latest`/`current` are intentionally NOT
 * matched on their own because they collide with common project-description
 * phrasing (e.g. "build a fresh landing page", "use the latest design
 * patterns", "based on the current site") that does not actually request a
 * cache bypass. They only match when paired with a cache- or
 * extraction-bearing noun. Verbs like `re-extract`, `refresh`, and
 * `bypass cache` are unambiguous on their own.
 */
const CACHE_BYPASS_NOUNS = 'extract(?:ion)?|fetch|context|data|copy|signals?|markdown|version|run|pull|scrape|grab|snapshot|content|html|cache|design\\s+context';
const FRESH_INTENT_PATTERNS = [
    new RegExp(`\\bfresh(?:est)?\\s+(?:${CACHE_BYPASS_NOUNS})\\b`, 'i'),
    new RegExp(`\\blatest\\s+(?:${CACHE_BYPASS_NOUNS})\\b`, 'i'),
    new RegExp(`\\bcurrent\\s+(?:${CACHE_BYPASS_NOUNS})\\b`, 'i'),
    /\bup[-\s]?to[-\s]?date\s+(?:extract(?:ion)?|fetch|context|data|copy|signals?|markdown|version|cache|design\s+context)\b/i,
    /\bre[-\s]?extract\b/i,
    /\bre[-\s]?fetch\b/i,
    /\bre[-\s]?generate\b/i,
    /\brefresh\b/i,
    /\bbypass\s+cache\b/i,
    /\bno[-\s]?cache\b/i,
];
/**
 * Normalize an HTTP/HTTPS URL for cache keying. Lowercases scheme and host,
 * strips default ports and fragments, and preserves meaningful path/query
 * so two pages with the same host but different paths or query strings
 * keep separate cache entries. Returns `undefined` for inputs that are not
 * parseable HTTP/HTTPS URLs.
 */
const normalizeCacheUrl = (rawUrl) => {
    const trimmed = rawUrl.trim();
    if (!trimmed)
        return undefined;
    let parsed;
    try {
        parsed = new URL(trimmed);
    }
    catch {
        return undefined;
    }
    if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
        return undefined;
    }
    parsed.hostname = parsed.hostname.toLowerCase();
    if ((parsed.protocol === 'http:' && parsed.port === '80') ||
        (parsed.protocol === 'https:' && parsed.port === '443')) {
        parsed.port = '';
    }
    parsed.hash = '';
    return parsed.toString();
};
exports.normalizeCacheUrl = normalizeCacheUrl;
/**
 * Compute a stable cache key from a normalized URL. SHA-256 hashed to hex
 * so the resulting key is filesystem-safe and does not embed proprietary
 * URL content in the filename layer.
 */
const cacheKeyForUrl = (rawUrl) => {
    const normalized = (0, exports.normalizeCacheUrl)(rawUrl);
    if (!normalized)
        return undefined;
    return crypto_1.default.createHash('sha256').update(normalized).digest('hex');
};
exports.cacheKeyForUrl = cacheKeyForUrl;
/**
 * Coarse age bucket for safe telemetry. Maps a wall-clock age in
 * milliseconds onto a small fixed bucket name so analytics never receive
 * exact timestamps.
 */
const ageBucketFor = (ageMs) => {
    if (ageMs < 60 * 60 * 1000)
        return 'lt_1h';
    if (ageMs < 24 * 60 * 60 * 1000)
        return '1h_24h';
    if (ageMs < 7 * 24 * 60 * 60 * 1000)
        return '1d_7d';
    if (ageMs < 30 * 24 * 60 * 60 * 1000)
        return '7d_30d';
    return 'gt_30d';
};
exports.ageBucketFor = ageBucketFor;
/**
 * Coarse confidence bucket for safe telemetry. Three buckets keyed off the
 * extractor signal density score so reuse outcomes can be aggregated
 * without leaking specific confidence values.
 */
const confidenceBucketFor = (confidence) => {
    if (confidence < exports.DEFAULT_CONFIDENCE_THRESHOLD)
        return 'low';
    if (confidence < 60)
        return 'medium';
    return 'high';
};
exports.confidenceBucketFor = confidenceBucketFor;
/**
 * Return true when the user prompt explicitly asks for fresh/latest/
 * current design context. Used by the MCP flow to bypass cache reuse even
 * when a compatible entry exists.
 */
const promptRequestsFreshExtraction = (prompt) => FRESH_INTENT_PATTERNS.some((pattern) => pattern.test(prompt));
exports.promptRequestsFreshExtraction = promptRequestsFreshExtraction;
/**
 * Decide whether a cached entry is reusable for a request, factoring in
 * extractor/schema version, freshness window, confidence threshold, and
 * any explicit user request for fresh extraction. Callers MUST honour the
 * decision — the store itself never silently reuses incompatible entries.
 */
const canReuseCacheEntry = (entry, options) => {
    const now = options.now ?? Date.now();
    const freshnessMs = options.freshnessMs ?? exports.DEFAULT_FRESHNESS_MS;
    const schemaVersion = options.schemaVersion ?? exports.DESIGN_CONTEXT_SCHEMA_VERSION;
    const confidenceThreshold = options.confidenceThreshold ?? exports.DEFAULT_CONFIDENCE_THRESHOLD;
    const ageMs = Math.max(0, now - entry.metadata.createdAtMs);
    const freshness = ageMs <= freshnessMs ? 'fresh' : 'stale';
    if (options.forceRefresh) {
        return { reuse: false, reason: 'forced_refresh', freshness, ageMs };
    }
    if (entry.metadata.schemaVersion !== schemaVersion) {
        return { reuse: false, reason: 'version_mismatch', freshness, ageMs };
    }
    if (freshness === 'stale') {
        return { reuse: false, reason: 'stale', freshness, ageMs };
    }
    if (entry.metadata.confidence < confidenceThreshold) {
        return { reuse: false, reason: 'low_confidence', freshness, ageMs };
    }
    return { reuse: true, freshness, ageMs };
};
exports.canReuseCacheEntry = canReuseCacheEntry;
const ALLOWED_KEY_PATTERN = /^[a-f0-9]{64}$/;
/**
 * Filesystem-backed `DesignContextStore`. One JSON file per cache key under
 * a per-user cache directory. Safe to delete the entire directory without
 * affecting bundled catalog data or compiled application code.
 *
 * Read/write failures degrade gracefully — get returns undefined on any
 * parse/IO error so the MCP flow falls through to a fresh extraction
 * instead of crashing the user request.
 */
class FileDesignContextStore {
    directory;
    dirReady = false;
    constructor(options = {}) {
        this.directory =
            options.directory ?? path_1.default.join(os_1.default.homedir(), '.rivet', 'design-context-cache');
    }
    /**
     * Resolve a cache key to its on-disk file path. Rejects keys that do not
     * match the expected sha256 hex shape so a malformed key cannot escape
     * the cache directory.
     */
    resolvePath(cacheKey) {
        if (!ALLOWED_KEY_PATTERN.test(cacheKey))
            return undefined;
        return path_1.default.join(this.directory, `${cacheKey}.json`);
    }
    async get(cacheKey) {
        const target = this.resolvePath(cacheKey);
        if (!target)
            return undefined;
        try {
            const raw = await fs_1.default.promises.readFile(target, 'utf8');
            const parsed = JSON.parse(raw);
            if (!isCacheEntry(parsed)) {
                log.debug(`Discarding malformed cache entry at ${cacheKey}.`);
                return undefined;
            }
            return parsed;
        }
        catch (error) {
            if (error &&
                typeof error === 'object' &&
                'code' in error &&
                error.code === 'ENOENT') {
                return undefined;
            }
            log.debug(`Failed to read cache entry ${cacheKey}: ${String(error)}`);
            return undefined;
        }
    }
    async put(entry) {
        const target = this.resolvePath(entry.metadata.cacheKey);
        if (!target) {
            log.warn('Refusing to write cache entry with invalid cache key shape.');
            return;
        }
        const tmp = `${target}.${crypto_1.default.randomBytes(4).toString('hex')}.tmp`;
        try {
            if (!this.dirReady) {
                await fs_1.default.promises.mkdir(this.directory, {
                    recursive: true,
                    mode: 0o700,
                });
                this.dirReady = true;
            }
            await fs_1.default.promises.writeFile(tmp, JSON.stringify(entry), {
                encoding: 'utf8',
                mode: 0o600,
            });
            await fs_1.default.promises.rename(tmp, target);
        }
        catch (error) {
            log.debug(`Failed to write cache entry: ${String(error)}`);
            try {
                await fs_1.default.promises.unlink(tmp);
            }
            catch {
                // best-effort cleanup
            }
        }
    }
}
exports.FileDesignContextStore = FileDesignContextStore;
/**
 * Runtime guard so corrupted or third-party-tampered cache files cannot
 * inject arbitrary fields into the MCP flow.
 */
const isCacheEntry = (value) => {
    if (!value || typeof value !== 'object')
        return false;
    const record = value;
    const metadata = record.metadata;
    if (!metadata || typeof metadata !== 'object')
        return false;
    if (typeof metadata.cacheKey !== 'string')
        return false;
    if (typeof metadata.schemaVersion !== 'string')
        return false;
    if (typeof metadata.createdAtMs !== 'number')
        return false;
    if (typeof metadata.lastUsedAtMs !== 'number')
        return false;
    if (typeof metadata.confidence !== 'number')
        return false;
    if (!Array.isArray(metadata.warningCodes))
        return false;
    if (typeof record.markdown !== 'string')
        return false;
    if (typeof record.label !== 'string')
        return false;
    const signals = record.signals;
    if (!signals || typeof signals !== 'object')
        return false;
    return true;
};
//# sourceMappingURL=designContextStore.js.map