/**
 * install/pipeline.mjs — orchestrates a single per-runtime install.
 *
 *   discover canonical files  →  call runtime transform  →  write outputs  →  write manifest
 *
 * The pipeline is the only file in this directory that talks to the
 * filesystem on the write path; transforms are pure. This separation keeps
 * runtime modules unit-testable (no temp dirs, no I/O) and concentrates the
 * write surface so atomicity + manifest accounting live in one place.
 */
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { atomicWriteJson } from '../util/atomic-write.mjs';
import { atomicWriteText } from './atomic-text.mjs';
import { addManifestEntry, newManifest, sha256, writeManifest } from './manifest.mjs';
import { getRuntime } from './runtime-detect.mjs';
import { classifyFileType } from './transforms/_shared.mjs';
import { parseVersionHeader } from './version-header.mjs';
const TRANSFORM_VERSION = '1';
/**
 * Defense-in-depth: assert a transform's output path is safely contained
 * within `installRoot`. Transforms are first-party code, but a buggy or
 * malicious contribution could return `../escape` or an absolute path; the
 * pipeline is the only fs-touching surface so the guard belongs here.
 *
 * Throws on:
 *   - absolute outputPath (transforms must always return install-root-relative)
 *   - resolved path that escapes installRoot (any `..` traversal that lands outside)
 *
 * @param {string} installRoot
 * @param {string} outputPath
 * @returns {string} the resolved absolute path (safe to write)
 */
function safeResolveWithin(installRoot, outputPath) {
    if (isAbsolute(outputPath)) {
        throw new Error(`transform returned absolute outputPath: ${outputPath}`);
    }
    const abs = resolve(installRoot, outputPath);
    const rel = relative(installRoot, abs);
    if (rel.startsWith('..') || isAbsolute(rel)) {
        throw new Error(`transform outputPath escapes install root: ${outputPath}`);
    }
    return abs;
}
/**
 * @typedef {Object} CanonicalFile
 * @property {string} canonicalPath  absolute path to the canonical source
 * @property {string} relPath         path relative to packages/framework (used in manifest sourcePath)
 * @property {'skill'|'agent'|'hook-js'|'hook-sh'|'unknown'} fileType
 * @property {string} content
 */
/**
 * @typedef {Object} TransformOutput
 * @property {string} outputPath  path relative to the runtime's install root
 * @property {string} content
 */
/**
 * @typedef {Object} TransformInput
 * @property {string} canonicalContent
 * @property {string} canonicalPath
 * @property {string} canonicalRelPath
 * @property {string} version
 * @property {'skill'|'agent'|'hook-js'|'hook-sh'|'unknown'} fileType
 * @property {Record<string, string>} [agentContentByName]  canonical agent body
 *   keyed by agent name (basename sans `.md`, e.g. `apt-planner`). Lets a
 *   transform inline a referenced agent's prompt when the target runtime has
 *   no separate agent-spawn surface (Codex). The pipeline always supplies it
 *   (empty object when no agents discovered); optional here so unit tests can
 *   construct a TransformInput without it — transforms default it to `{}`.
 */
/**
 * @typedef {(input: TransformInput) => TransformOutput[]} TransformFn
 */
/**
 * Discover canonical input files under `packages/framework/`. Returns
 * skills (`skills/<name>/SKILL.md`, `skills/apt-*.md`) and agents
 * (`agents/apt-*.md`). Hooks (`hooks/*.js`) are included when the dir
 * exists.
 *
 * @param {string} canonicalRoot  absolute path to packages/framework
 * @returns {CanonicalFile[]}
 */
export function discoverCanonicalFiles(canonicalRoot) {
    const out = [];
    const skillsRoot = join(canonicalRoot, 'skills');
    const agentsRoot = join(canonicalRoot, 'agents');
    const hooksRoot = join(canonicalRoot, 'hooks');
    if (existsDir(skillsRoot)) {
        for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
            if (entry.isDirectory()) {
                const skillFile = join(skillsRoot, entry.name, 'SKILL.md');
                if (existsFile(skillFile)) {
                    const content = readFileSync(skillFile, 'utf-8');
                    out.push({
                        canonicalPath: skillFile,
                        relPath: relative(canonicalRoot, skillFile),
                        fileType: 'skill',
                        content,
                    });
                }
            }
            else if (entry.isFile() && entry.name.startsWith('apt-') && entry.name.endsWith('.md')) {
                // Top-level agent-skill files (apt-planner.md etc.) — treated as
                // agent-style for routing but live under skills/. Classify by
                // path so transforms can route them correctly.
                const filePath = join(skillsRoot, entry.name);
                out.push({
                    canonicalPath: filePath,
                    relPath: relative(canonicalRoot, filePath),
                    fileType: 'agent',
                    content: readFileSync(filePath, 'utf-8'),
                });
            }
        }
    }
    if (existsDir(agentsRoot)) {
        for (const entry of readdirSync(agentsRoot, { withFileTypes: true })) {
            if (entry.isFile() && entry.name.startsWith('apt-') && entry.name.endsWith('.md')) {
                const filePath = join(agentsRoot, entry.name);
                out.push({
                    canonicalPath: filePath,
                    relPath: relative(canonicalRoot, filePath),
                    fileType: 'agent',
                    content: readFileSync(filePath, 'utf-8'),
                });
            }
        }
    }
    if (existsDir(hooksRoot)) {
        for (const entry of readdirSync(hooksRoot, { withFileTypes: true })) {
            if (!entry.isFile())
                continue;
            const filePath = join(hooksRoot, entry.name);
            const ft = classifyFileType(filePath);
            if (ft === 'hook-js' || ft === 'hook-sh') {
                out.push({
                    canonicalPath: filePath,
                    relPath: relative(canonicalRoot, filePath),
                    fileType: ft,
                    content: readFileSync(filePath, 'utf-8'),
                });
            }
        }
    }
    return out;
}
/**
 * Run the install pipeline for a single runtime. Discovers canonical files,
 * runs the runtime's transform on each, writes outputs, writes manifest.
 *
 * @param {Object} args
 * @param {string} args.runtime         runtime id (e.g. 'claude')
 * @param {TransformFn} args.transform  transform function from the runtime module
 * @param {string} args.canonicalRoot   absolute path to packages/framework
 * @param {string} args.targetDir       absolute path to the project root the user is installing into
 * @param {string} args.version         framework version string (replaces {{APT_VERSION}})
 * @param {CanonicalFile[]} [args.canonicalFiles]  pre-discovered files (pass to avoid redundant disk reads when installing multiple runtimes)
 * @returns {{ runtime: string, installRoot: string, files: string[], skipped: string[], manifest: import('./manifest.mjs').Manifest }}
 */
export function runInstall({ runtime, transform, canonicalRoot, targetDir, version, canonicalFiles, }) {
    const descriptor = getRuntime(runtime);
    if (!descriptor) {
        throw new Error(`Unknown runtime: ${runtime}`);
    }
    const installRoot = resolve(targetDir, descriptor.installRoot);
    const manifest = newManifest({ runtime, frameworkVersion: version });
    const files = canonicalFiles ?? discoverCanonicalFiles(canonicalRoot);
    const written = [];
    const skipped = [];
    // Map of agent name → canonical agent body, so a transform whose runtime
    // has no separate agent-spawn surface (Codex) can inline a referenced
    // agent's prompt. Keyed by basename sans `.md` (e.g. `apt-planner`). When
    // the same name exists under both skills/ and agents/, last-wins — they are
    // the same agent (see the de-dupe note below).
    /** @type {Record<string, string>} */
    const agentContentByName = {};
    for (const f of files) {
        if (f.fileType === 'agent') {
            const name = f.relPath.replace(/^.*\//, '').replace(/\.md$/, '');
            agentContentByName[name] = f.content;
        }
    }
    // De-dupe by output path — last-write-wins. Two canonical files can
    // legitimately target the same output (e.g. skills/apt-planner.md and
    // agents/apt-planner.md both producing agents/apt-planner.md). The
    // manifest must reflect what's actually on disk after all writes, so
    // we collect entries in a map keyed by outputPath and serialize at the
    // end. This guarantees `installedHash` always matches the final bytes
    // at `path`.
    /** @type {Map<string, import('./manifest.mjs').ManifestFileEntry>} */
    const entryByPath = new Map();
    for (const f of files) {
        const sourceHash = sha256(f.content);
        let outputs;
        try {
            outputs = transform({
                canonicalContent: f.content,
                canonicalPath: f.canonicalPath,
                canonicalRelPath: f.relPath,
                version,
                fileType: f.fileType,
                agentContentByName,
            });
        }
        catch (err) {
            skipped.push(`${f.relPath}: transform threw ${err instanceof Error ? err.message : String(err)}`);
            continue;
        }
        if (!Array.isArray(outputs)) {
            skipped.push(`${f.relPath}: transform did not return an array`);
            continue;
        }
        for (const out of outputs) {
            if (!out || typeof out.outputPath !== 'string' || typeof out.content !== 'string') {
                skipped.push(`${f.relPath}: invalid transform output`);
                continue;
            }
            let absPath;
            try {
                absPath = safeResolveWithin(installRoot, out.outputPath);
            }
            catch (err) {
                skipped.push(`${f.relPath}: ${err instanceof Error ? err.message : String(err)}`);
                continue;
            }
            atomicWriteText(absPath, out.content);
            const installedHash = sha256(out.content);
            // Record whether the installed file carries a version header. When a
            // transform legitimately strips it (Codex frontmatter whitelist),
            // drift detection must not flag it missing_header — installedHash is
            // the drift signal. Only relevant for header-bearing file types.
            const headerless = /\.(md|js|mjs|sh)$/.test(out.outputPath) && parseVersionHeader(out.content) === null;
            entryByPath.set(out.outputPath, {
                path: out.outputPath,
                sourceHash,
                installedHash,
                sourcePath: `packages/framework/${f.relPath}`,
                transformVersion: TRANSFORM_VERSION,
                ...(headerless ? { headerless: true } : {}),
            });
            if (!written.includes(out.outputPath))
                written.push(out.outputPath);
        }
    }
    for (const entry of entryByPath.values()) {
        addManifestEntry(manifest, entry);
    }
    writeManifest(installRoot, manifest);
    return { runtime, installRoot, files: written, skipped, manifest };
}
/**
 * Write a top-level summary of the install (which runtimes were installed,
 * counts) to `.aperant/install/last-install.json` for cross-runtime
 * visibility. Used by `init` to record the multi-runtime decision once.
 *
 * @param {string} targetDir
 * @param {Array<{ runtime: string, installRoot: string, files: string[], skipped: string[] }>} results
 * @param {string} version
 */
export function writeLastInstallSummary(targetDir, results, version) {
    const summary = {
        schema_version: 1,
        recorded_at: new Date().toISOString(),
        framework_version: version,
        runtimes: results.map((r) => ({
            runtime: r.runtime,
            install_root: relative(targetDir, r.installRoot),
            file_count: r.files.length,
            skipped_count: r.skipped.length,
        })),
    };
    atomicWriteJson(join(targetDir, '.aperant', 'install', 'last-install.json'), summary);
}
function existsDir(p) {
    try {
        return statSync(p).isDirectory();
    }
    catch {
        return false;
    }
}
function existsFile(p) {
    try {
        return statSync(p).isFile();
    }
    catch {
        return false;
    }
}
// Re-export __dirname helper for callers that need to locate canonicalRoot
export const __pipelineDirname = dirname(fileURLToPath(import.meta.url));
//# sourceMappingURL=pipeline.mjs.map