/**
 * route/skill-discover.mjs — filesystem scan of installed skills.
 *
 * The router-0.3.0 envelope is built from whatever apt-<slug>/SKILL.md files
 * live under a trusted root. No hardcoded KNOWN_SKILLS array, no manifest
 * lookup — the filesystem is the source of truth (D-08).
 *
 * Trust boundary (spec §2.5 + subtask 7): only paths contained within at
 * least one entry of `trustedSkillSources` are accepted. Defaults are:
 *   - packages/framework/skills (dev checkouts)
 *   - .aperant/deps/node_modules/@aperant/framework/skills (consumer installs)
 *   - <host-cli install root>/skills (the runtime the host CLI is in)
 * `getTrustedSkillSources()` (see config/load.mjs) resolves those for a
 * given target dir + cli.
 *
 * Each discovered record is frontmatter-validated. Failures are dropped
 * and logged to `.aperant/logs/route-dropped.jsonl` so debugging is not a
 * needle-in-haystack.
 */
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, } from 'node:fs';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { parse as parseYaml } from 'yaml';
import { sanitizeAptVersionForYamlParse } from '../install/version-header.mjs';
import { SkillFrontmatterSchema } from '../skill-author/contract.mjs';
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n/;
/**
 * @typedef {Object} DiscoveredSkill
 * @property {string} name
 * @property {string} description
 * @property {string} stage
 * @property {string} intent
 * @property {string} when_to_use
 * @property {boolean} user_invocable
 * @property {boolean} internal
 * @property {boolean} spawns_agent
 * @property {string|null} agent_name
 * @property {string[]} gates
 * @property {string} default_execution_mode
 * @property {string[]} execution_modes
 * @property {'create-new'|'require-existing'|'self-managed'|'none'} task_context
 * @property {('QUICK'|'STANDARD'|'DEEP'|'DEBUG')|undefined} default_track
 * @property {string} file_path           absolute path to the SKILL.md
 */
/**
 * @typedef {Object} DiscoveredDrop
 * @property {string} file_path
 * @property {'outside_allowlist'|'missing_frontmatter'|'malformed_frontmatter'|'read_error'} reason
 * @property {string[]} [errors]
 */
/**
 * @typedef {Object} DiscoverResult
 * @property {DiscoveredSkill[]} skills
 * @property {DiscoveredDrop[]} dropped
 * @property {string[]} scanned_roots
 */
/**
 * Return true iff `file` is contained within at least one root. Both the
 * file path and each root are resolved with `realpathSync` before comparison
 * so that symlinks pointing outside an allowlisted root are caught.
 *
 * SEC-002: using `resolve()` alone (without `realpathSync`) means a symlink
 * inside a trusted root that points outside it would still pass the
 * containment check. `realpathSync` resolves every symlink component to its
 * canonical on-disk path, making the check sound. When a path does not exist
 * (e.g. a dangling symlink) we fall through to return false.
 *
 * @param {string} file
 * @param {string[]} roots  absolute paths
 * @returns {boolean}
 */
function pathInsideAnyRoot(file, roots) {
    let realFile;
    try {
        realFile = realpathSync(resolve(file));
    }
    catch {
        // Dangling symlink or path that doesn't exist — cannot be inside any root.
        return false;
    }
    for (const root of roots) {
        let realRoot;
        try {
            realRoot = realpathSync(resolve(root));
        }
        catch {
            continue;
        }
        const rel = relative(realRoot, realFile);
        if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)))
            return true;
    }
    return false;
}
/**
 * Read a SKILL.md, extract + parse frontmatter, and validate. Throws on
 * hard errors (caller catches + logs to dropped).
 *
 * @param {string} file  absolute path
 * @returns {{ ok: true, record: DiscoveredSkill } | { ok: false, reason: DiscoveredDrop['reason'], errors: string[] }}
 */
function readSkill(file) {
    let text;
    try {
        text = readFileSync(file, 'utf-8');
    }
    catch (err) {
        return {
            ok: false,
            reason: 'read_error',
            errors: [err && /** @type {Error} */ (err).message ? err.message : String(err)],
        };
    }
    const m = text.match(FRONTMATTER_RE);
    if (!m) {
        return { ok: false, reason: 'missing_frontmatter', errors: ['no --- frontmatter block'] };
    }
    let parsed;
    try {
        parsed = parseYaml(sanitizeAptVersionForYamlParse(m[1]));
    }
    catch (err) {
        return {
            ok: false,
            reason: 'malformed_frontmatter',
            errors: [err && /** @type {Error} */ (err).message ? err.message : String(err)],
        };
    }
    if (parsed === null || typeof parsed !== 'object') {
        return {
            ok: false,
            reason: 'malformed_frontmatter',
            errors: ['frontmatter did not parse to an object'],
        };
    }
    const res = SkillFrontmatterSchema.safeParse(parsed);
    if (!res.success) {
        return {
            ok: false,
            reason: 'malformed_frontmatter',
            errors: res.error.issues.map((i) => `${i.path.length ? i.path.join('.') : '(root)'}: ${i.message}`),
        };
    }
    const d = res.data;
    return {
        ok: true,
        record: {
            name: d.name,
            description: d.description,
            stage: d.stage,
            intent: d.intent,
            when_to_use: d.when_to_use,
            user_invocable: d.user_invocable,
            internal: d.internal,
            spawns_agent: d.spawns_agent,
            agent_name: d.agent_name ?? null,
            gates: Array.isArray(d.gates) ? [...d.gates] : [],
            default_execution_mode: d.default_execution_mode,
            execution_modes: Array.isArray(d.execution_modes) ? [...d.execution_modes] : [],
            task_context: d.task_context,
            default_track: d.default_track,
            file_path: file,
        },
    };
}
/**
 * Walk a single root and return every apt-<slug>/SKILL.md it contains.
 *
 * @param {string} root  absolute path; return [] silently if root doesn't exist.
 */
function walkSkillRoot(root) {
    const out = [];
    if (!existsSync(root))
        return out;
    let stat;
    try {
        stat = statSync(root);
    }
    catch {
        return out;
    }
    if (!stat.isDirectory())
        return out;
    let entries;
    try {
        entries = readdirSync(root, { withFileTypes: true });
    }
    catch {
        return out;
    }
    for (const entry of entries) {
        if (!entry.name.startsWith('apt'))
            continue;
        const sub = join(root, entry.name);
        if (entry.isDirectory()) {
            const skillFile = join(sub, 'SKILL.md');
            if (existsSync(skillFile))
                out.push(skillFile);
        }
        // Top-level apt-*.md files (e.g. skills/apt-planner.md) are
        // intentionally ignored by the router — they're agent definitions
        // consumed by the spawn path, not skills the user picks from a
        // classification menu.
    }
    return out;
}
/**
 * Redact raw attacker-controlled values from Zod validation error strings
 * before they are written to the log (SEC-002). Zod enum rejection messages
 * embed the received value verbatim: "Invalid enum value. Expected ...,
 * received 'evil-payload'". We strip the received portion and replace it
 * with a byte-length indicator so downstream log consumers never see the
 * raw attacker string.
 *
 * @param {string[]} errors
 * @returns {string[]}
 */
function redactErrors(errors) {
    if (!Array.isArray(errors))
        return errors;
    return errors.map((msg) => {
        if (typeof msg !== 'string')
            return msg;
        // Replace ", received '<anything>'" with a length annotation.
        // The regex is anchored to the literal Zod enum error suffix so
        // only the received-value fragment is removed, not diagnostic context.
        return msg.replace(/, received '([^']*)'/g, (_, v) => `, received [${v.length} chars redacted]`);
    });
}
/**
 * Append one dropped record to .aperant/logs/route-dropped.jsonl. Best-
 * effort — if the log dir can't be created we swallow the error (the
 * router still returns the envelope without the skill).
 *
 * SEC-002: the `errors` array may contain raw attacker-controlled field
 * values from Zod enum rejections. redactErrors() strips the received-value
 * fragment before the record is serialized so log consumers (dashboards,
 * alert rules) never see the raw attacker string.
 *
 * @param {string} targetDir
 * @param {DiscoveredDrop} drop
 */
function logDropped(targetDir, drop) {
    try {
        const logDir = join(targetDir, '.aperant', 'logs');
        mkdirSync(logDir, { recursive: true });
        const safeErrors = drop.errors ? redactErrors(drop.errors) : undefined;
        const record = safeErrors !== undefined ? { ...drop, errors: safeErrors } : drop;
        appendFileSync(join(logDir, 'route-dropped.jsonl'), `${JSON.stringify({ ts: new Date().toISOString(), ...record })}\n`, 'utf-8');
    }
    catch {
        /* best-effort: never block the hot path on log I/O */
    }
}
/**
 * Process-level cache for discoverSkills results keyed by the sorted,
 * joined set of resolved trustedSkillSources roots. This memoization
 * is intentionally cheap and narrow:
 *
 *   - Key: sorted joined resolved roots (captures which dirs to scan)
 *   - TTL: 1 second (covers test-harness multi-call patterns without
 *     returning stale data in long-running watch processes)
 *   - Invalidation: the TTL is the only eviction — the cache does NOT
 *     attempt to detect filesystem changes (inotify/kqueue is out of
 *     scope for a single-invocation CLI).
 *
 * Single CLI invocations (the primary use case) see no observable change;
 * test harnesses that call discoverSkills 14×+ within one second benefit.
 *
 * @type {Map<string, { result: DiscoverResult, expiresAt: number }>}
 */
const _discoverCache = new Map();
const _DISCOVER_CACHE_TTL_MS = 1_000;
/**
 * Test-only helper — drop the per-roots discover cache so a fresh
 * `discoverSkills` call rescans the trusted source roots. Used by the
 * in-process cmd harness (run-cmd.mjs) to guarantee cross-call isolation
 * within a single vitest worker.
 *
 * @internal
 */
export function __resetCaches() {
    _discoverCache.clear();
}
/**
 * Scan the trusted skill sources and return discovered skill records.
 * Skills whose file path is not inside one of the trusted roots are
 * dropped and logged (trust boundary).
 *
 * Results are memoized for up to 1 second per unique set of resolved
 * trustedSkillSources roots (PRF-001). The memoization wraps the
 * containment-checked scan so the security boundary is never bypassed.
 *
 * @param {Object} opts
 * @param {string} opts.targetDir              project root (used for log dest)
 * @param {string[]} opts.trustedSkillSources  absolute paths
 * @returns {DiscoverResult}
 */
export function discoverSkills({ targetDir, trustedSkillSources }) {
    // Build a stable cache key from the sorted resolved roots.
    const resolvedRoots = (trustedSkillSources || []).map((r) => resolve(r)).sort();
    const cacheKey = resolvedRoots.join('\0');
    const now = Date.now();
    const cached = _discoverCache.get(cacheKey);
    if (cached && cached.expiresAt > now) {
        return cached.result;
    }
    const result = _discoverSkillsUncached({ targetDir, trustedSkillSources });
    _discoverCache.set(cacheKey, { result, expiresAt: now + _DISCOVER_CACHE_TTL_MS });
    return result;
}
/**
 * Internal uncached implementation — called by discoverSkills after
 * cache miss. Do not call directly; always go through discoverSkills so
 * the memoization layer is in effect.
 *
 * @param {Object} opts
 * @param {string} opts.targetDir
 * @param {string[]} opts.trustedSkillSources
 * @returns {DiscoverResult}
 */
function _discoverSkillsUncached({ targetDir, trustedSkillSources }) {
    const roots = (trustedSkillSources || []).map((r) => resolve(r)).filter((r) => existsSync(r));
    const skillsByName = new Map();
    const dropped = [];
    for (const root of roots) {
        for (const file of walkSkillRoot(root)) {
            if (!pathInsideAnyRoot(file, roots)) {
                const drop = { file_path: file, reason: 'outside_allowlist' };
                dropped.push(drop);
                logDropped(targetDir, drop);
                continue;
            }
            const res = readSkill(file);
            if (!res.ok) {
                const drop = { file_path: file, reason: res.reason, errors: res.errors };
                dropped.push(drop);
                logDropped(targetDir, drop);
                continue;
            }
            // First-writer-wins on duplicate names. Since roots are ordered
            // (dev → consumer → host-cli install root) by getTrustedSkillSources,
            // the first-resolved copy (typically the dev checkout) ends up in
            // the envelope. That ordering is load-bearing — it means work on a
            // local dev branch is always what gets classified, even when a
            // consumer-install copy also exists in .aperant/deps.
            if (!skillsByName.has(res.record.name)) {
                skillsByName.set(res.record.name, res.record);
            }
        }
    }
    const skills = Array.from(skillsByName.values()).sort((a, b) => a.name.localeCompare(b.name));
    return {
        skills,
        dropped,
        scanned_roots: roots,
    };
}
//# sourceMappingURL=skill-discover.mjs.map