/**
 * Canonical agent-package resolver (PHNX-3838).
 *
 * `resolveAgentPackage` is the ONE place that decides what a package's logical
 * resources are: it validates the manifest, resolves every declared resource
 * path against the package directory (failing closed on anything missing,
 * malformed, or escaping the package root), hashes each resource
 * deterministically, and applies the package's conflict-resolution rule —
 * within one scope (portable, or one harness's overlay) a duplicate
 * `(kind, name)` is a hard validation error; across scopes, an overlay
 * resource deterministically replaces the portable resource of the same
 * `(kind, name)` when a harness materializes it (`effectiveResources`).
 *
 * This module never writes to disk and never knows about any specific
 * harness's native format — `materialize.ts` is the only consumer of
 * `effectiveResources`, and it owns projection, not resolution.
 */
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'yaml';
import { assertWithin, isSafeSegmentName } from '../paths.js';
import { loadAgentPackageManifest } from './package-schema.js';
import { AgentPackageError } from './package-types.js';
/**
 * Reject a package source that is a symlink, or that resolves (through a
 * symlinked ancestor) outside the package's real root. `assertWithin` above is a
 * TEXTUAL check — `path.resolve` normalizes `..` but never follows links — so a
 * `skills/evil -> /home/user/.ssh` symlink, or an `instructions.md ->
 * /etc/passwd` symlink, passes it while the actual bytes read/copied come from
 * OUTSIDE the package. This is the containment check that makes the copy safe.
 * A missing source is left for the `requireFile`/`requireDir` caller to report.
 */
function assertRealSourceWithin(abs, packageReal, label) {
    let lst;
    try {
        lst = fs.lstatSync(abs);
    }
    catch {
        return; // does not exist — requireFile/requireDir reports the specific error
    }
    if (lst.isSymbolicLink()) {
        throw new AgentPackageError(`${label}: '${abs}' is a symlink; symlinked package sources are not allowed`, 'path-escape');
    }
    let real;
    try {
        real = fs.realpathSync(abs);
    }
    catch {
        return;
    }
    if (real !== packageReal && !real.startsWith(packageReal + path.sep)) {
        throw new AgentPackageError(`${label}: '${abs}' resolves outside the package directory`, 'path-escape');
    }
}
function resolveWithin(containRoot, relPath, label, packageReal) {
    const abs = path.resolve(containRoot, relPath);
    try {
        assertWithin(containRoot, abs);
    }
    catch {
        throw new AgentPackageError(`${label}: '${relPath}' escapes the package directory`, 'path-escape');
    }
    assertRealSourceWithin(abs, packageReal, label);
    return abs;
}
function sha256OfBytes(data) {
    return crypto.createHash('sha256').update(data).digest('hex');
}
/** Deterministic combined hash of every file under `dir`, sorted by relative path. */
function sha256OfDir(dir) {
    const files = [];
    const walk = (base) => {
        for (const entry of fs.readdirSync(base, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
            if (entry.isSymbolicLink())
                continue;
            const full = path.join(base, entry.name);
            if (entry.isDirectory())
                walk(full);
            else if (entry.isFile())
                files.push(full);
        }
    };
    walk(dir);
    const hash = crypto.createHash('sha256');
    for (const file of files) {
        const rel = path.relative(dir, file).split(path.sep).join('/');
        hash.update(rel).update('\0').update(fs.readFileSync(file)).update('\n');
    }
    return hash.digest('hex');
}
function requireFile(absPath, label) {
    let stat;
    try {
        stat = fs.statSync(absPath);
    }
    catch {
        throw new AgentPackageError(`${label}: no such file '${absPath}'`, 'invalid-resource');
    }
    if (!stat.isFile()) {
        throw new AgentPackageError(`${label}: '${absPath}' is not a file`, 'invalid-resource');
    }
}
function requireDir(absPath, label) {
    let stat;
    try {
        stat = fs.statSync(absPath);
    }
    catch {
        throw new AgentPackageError(`${label}: no such directory '${absPath}'`, 'invalid-resource');
    }
    if (!stat.isDirectory()) {
        throw new AgentPackageError(`${label}: '${absPath}' is not a directory`, 'invalid-resource');
    }
}
function parseYamlFile(absPath, label) {
    let raw;
    try {
        raw = fs.readFileSync(absPath, 'utf-8');
    }
    catch (err) {
        throw new AgentPackageError(`${label}: cannot read '${absPath}' — ${err.message}`, 'invalid-resource');
    }
    try {
        return yaml.parse(raw);
    }
    catch (err) {
        throw new AgentPackageError(`${label}: malformed YAML in '${absPath}' — ${err.message}`, 'invalid-resource');
    }
}
function resolveInstructions(packageDir, relPath, provenance, label, packageReal) {
    const abs = resolveWithin(packageDir, relPath, label, packageReal);
    requireFile(abs, label);
    return { kind: 'instructions', name: 'instructions', sourcePath: abs, sha256: sha256OfBytes(fs.readFileSync(abs)), provenance };
}
function resolveDirResource(packageDir, relPath, kind, markerFile, provenance, label, packageReal) {
    const abs = resolveWithin(packageDir, relPath, label, packageReal);
    requireDir(abs, label);
    requireFile(path.join(abs, markerFile), `${label} (missing ${markerFile})`);
    return { kind, name: path.basename(abs), sourcePath: abs, sha256: sha256OfDir(abs), provenance };
}
function resolveMcpResource(packageDir, relPath, provenance, label, packageReal) {
    const abs = resolveWithin(packageDir, relPath, label, packageReal);
    requireFile(abs, label);
    const server = parseYamlFile(abs, label);
    if (!server || typeof server.name !== 'string' || server.name.length === 0) {
        throw new AgentPackageError(`${label}: '${relPath}' must declare a 'name'`, 'invalid-resource');
    }
    if (server.transport !== 'stdio' && server.transport !== 'http' && server.transport !== 'sse') {
        throw new AgentPackageError(`${label}: '${relPath}' has an invalid transport (${JSON.stringify(server.transport)})`, 'invalid-resource');
    }
    if (server.transport === 'stdio' && !server.command) {
        throw new AgentPackageError(`${label}: '${relPath}' declares transport 'stdio' but no 'command'`, 'invalid-resource');
    }
    if ((server.transport === 'http' || server.transport === 'sse') && !server.url) {
        throw new AgentPackageError(`${label}: '${relPath}' declares transport '${server.transport}' but no 'url'`, 'invalid-resource');
    }
    return {
        kind: 'mcp',
        name: server.name,
        sourcePath: abs,
        sha256: sha256OfBytes(fs.readFileSync(abs)),
        provenance,
        mcp: server,
    };
}
function resolveHookResource(packageDir, relPath, provenance, label, packageReal) {
    const abs = resolveWithin(packageDir, relPath, label, packageReal);
    requireFile(abs, label);
    const hook = parseYamlFile(abs, label);
    if (!hook || typeof hook.name !== 'string' || hook.name.length === 0) {
        throw new AgentPackageError(`${label}: '${relPath}' must declare a 'name'`, 'invalid-resource');
    }
    // The hook name becomes a filename in the materialized home, so a traversing
    // name ('../../foo') would write a hook script outside the output home. Reject
    // anything but a single safe path segment at the source.
    if (!isSafeSegmentName(hook.name)) {
        throw new AgentPackageError(`${label}: hook name '${hook.name}' is not a safe single path segment`, 'invalid-resource');
    }
    if (typeof hook.script !== 'string' || hook.script.length === 0) {
        throw new AgentPackageError(`${label}: '${relPath}' must declare a 'script'`, 'invalid-resource');
    }
    if (!Array.isArray(hook.events) || hook.events.length === 0 || hook.events.some((e) => typeof e !== 'string')) {
        throw new AgentPackageError(`${label}: '${relPath}' must declare a non-empty 'events' list of strings`, 'invalid-resource');
    }
    const scriptPath = resolveWithin(path.dirname(abs), hook.script, label, packageReal);
    requireFile(scriptPath, `${label} (hook script)`);
    return {
        kind: 'hooks',
        name: hook.name,
        sourcePath: abs,
        sha256: sha256OfBytes(Buffer.concat([fs.readFileSync(abs), Buffer.from('\0'), fs.readFileSync(scriptPath)])),
        provenance,
        hook: { def: hook, scriptPath },
    };
}
function assertNoDuplicates(resources, scopeLabel) {
    const seen = new Map();
    for (const r of resources) {
        const key = `${r.kind}:${r.name}`;
        const prior = seen.get(key);
        if (prior) {
            throw new AgentPackageError(`${scopeLabel}: duplicate ${r.kind} '${r.name}' declared by both '${prior.sourcePath}' and '${r.sourcePath}'`, 'duplicate-resource');
        }
        seen.set(key, r);
    }
}
function resolveScope(packageDir, paths, provenance, scopeLabel, packageReal) {
    const resources = [];
    if (paths.instructions)
        resources.push(resolveInstructions(packageDir, paths.instructions, provenance, scopeLabel, packageReal));
    for (const p of paths.skills)
        resources.push(resolveDirResource(packageDir, p, 'skills', 'SKILL.md', provenance, scopeLabel, packageReal));
    for (const p of paths.subagents)
        resources.push(resolveDirResource(packageDir, p, 'subagents', 'AGENT.md', provenance, scopeLabel, packageReal));
    for (const p of paths.mcp)
        resources.push(resolveMcpResource(packageDir, p, provenance, scopeLabel, packageReal));
    for (const p of paths.hooks)
        resources.push(resolveHookResource(packageDir, p, provenance, scopeLabel, packageReal));
    // Sort deterministically — resolution order in agent.yaml must not affect output.
    resources.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind.localeCompare(b.kind)));
    assertNoDuplicates(resources, scopeLabel);
    return resources;
}
/** Deterministic package identity digest — independent of which harness later materializes it. */
function computeDigest(portable, overlays) {
    const hash = crypto.createHash('sha256');
    const record = (label, resources) => {
        for (const r of resources)
            hash.update(`${label}\0${r.kind}\0${r.name}\0${r.sha256}\n`);
    };
    record('portable', portable);
    for (const agent of Object.keys(overlays).sort())
        record(`overlay:${agent}`, overlays[agent] ?? []);
    return hash.digest('hex');
}
/** Parse, validate, and resolve every declared resource of a package directory into one canonical result. */
export function resolveAgentPackage(packageDir) {
    const manifest = loadAgentPackageManifest(packageDir);
    const ex = manifest.execution;
    // The package's REAL root — every resource source must realpath-resolve under
    // this, so a symlinked ancestor can't smuggle in bytes from outside. The
    // manifest already loaded from packageDir, so it exists and realpath succeeds.
    const packageReal = fs.realpathSync(path.resolve(packageDir));
    const portable = resolveScope(packageDir, { instructions: ex.instructions, skills: ex.skills, subagents: ex.subagents, mcp: ex.mcp, hooks: ex.hooks }, 'portable', 'execution', packageReal);
    const overlays = {};
    for (const [agent, overlay] of Object.entries(ex.harnessOverlays)) {
        overlays[agent] = resolveScope(packageDir, { instructions: overlay.instructions, skills: overlay.skills ?? [], subagents: overlay.subagents ?? [], mcp: overlay.mcp ?? [], hooks: overlay.hooks ?? [] }, 'overlay', `execution.harness_overlays.${agent}`, packageReal);
    }
    return {
        manifest,
        packageDir: path.resolve(packageDir),
        digest: computeDigest(portable, overlays),
        portable,
        overlays,
    };
}
/**
 * The resource set a specific harness actually materializes: portable
 * resources, with any harness-overlay resource of the same `(kind, name)`
 * deterministically replacing its portable counterpart, plus overlay-only
 * additions. This is the ONE merge rule every harness adapter shares —
 * `materialize.ts` calls this instead of re-deriving precedence per harness.
 */
export function effectiveResources(resolved, harness) {
    const overlay = resolved.overlays[harness] ?? [];
    const overlayKeys = new Set(overlay.map((r) => `${r.kind}:${r.name}`));
    const merged = [
        ...resolved.portable.filter((r) => !overlayKeys.has(`${r.kind}:${r.name}`)),
        ...overlay,
    ];
    merged.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind.localeCompare(b.kind)));
    return merged;
}
