/**
 * config/load.mjs — project config loader + task-isolation normalizer (C29).
 */
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { cliToInstallRoot } from '../install/cli-to-install-root.mjs';
/**
 * Load `.aperant/config.json` if present. Returns null if missing/malformed.
 */
export function loadProjectConfig(targetDir) {
    const configPath = join(targetDir, '.aperant', 'config.json');
    if (!existsSync(configPath))
        return null;
    try {
        return JSON.parse(readFileSync(configPath, 'utf-8'));
    }
    catch {
        return null;
    }
}
/**
 * Load `.aperant/config.json` deep-merged with `.aperant/config.local.json`.
 * Local wins on key collision. Nested objects merge key-by-key; arrays and
 * primitives replace wholesale.
 *
 * Returns null if config.json is missing/malformed (same as loadProjectConfig).
 * Throws Error("config.local.json parse error: <msg>") if config.local.json
 * exists but is malformed — this is a user-visible failure surface; silent
 * fallback would mask the user's broken override. See FRAMEWORK-AUDIT-001.
 *
 * @param {string} targetDir
 * @returns {Record<string, any> | null}
 */
export function loadMergedProjectConfig(targetDir) {
    const shared = loadProjectConfig(targetDir);
    if (shared === null)
        return null;
    const localPath = join(targetDir, '.aperant', 'config.local.json');
    if (!existsSync(localPath))
        return shared;
    let local;
    try {
        local = JSON.parse(readFileSync(localPath, 'utf-8'));
    }
    catch (e) {
        throw new Error(`config.local.json parse error: ${e.message}`);
    }
    return deepMerge(shared, local);
}
function deepMerge(a, b) {
    if (a === null || typeof a !== 'object' || Array.isArray(a))
        return b;
    if (b === null || typeof b !== 'object' || Array.isArray(b))
        return b;
    const out = { ...a };
    for (const k of Object.keys(b)) {
        out[k] = k in a ? deepMerge(a[k], b[k]) : b[k];
    }
    return out;
}
/**
 * AUDIT-001 — read+write helpers for `.aperant/config.local.json`.
 * Used by the 5 deferred fields migrated out of `config.json`:
 *   - verification.open_report
 *   - multi_model.cross_verification.tools
 *   - preferences.date_format
 *   - preferences.time_format
 *   - preferences.quick_task_post_verify
 *
 * Each field has its own read/write site. The shared helpers below give
 * those sites a single primitive instead of hand-rolled JSON I/O.
 */
function setDottedPath(obj, dotPath, value) {
    const parts = dotPath.split('.');
    let cursor = obj;
    for (let i = 0; i < parts.length - 1; i++) {
        const key = parts[i];
        if (!cursor[key] || typeof cursor[key] !== 'object' || Array.isArray(cursor[key])) {
            cursor[key] = {};
        }
        cursor = cursor[key];
    }
    cursor[parts[parts.length - 1]] = value;
}
function getDottedPath(obj, dotPath) {
    const parts = dotPath.split('.');
    let cursor = obj;
    for (const part of parts) {
        if (cursor === null || cursor === undefined || typeof cursor !== 'object')
            return undefined;
        cursor = cursor[part];
    }
    return cursor;
}
function deleteDottedPath(obj, dotPath) {
    const parts = dotPath.split('.');
    let cursor = obj;
    for (let i = 0; i < parts.length - 1; i++) {
        const key = parts[i];
        if (!cursor[key] || typeof cursor[key] !== 'object')
            return false;
        cursor = cursor[key];
    }
    const leaf = parts[parts.length - 1];
    if (leaf in cursor) {
        delete cursor[leaf];
        return true;
    }
    return false;
}
/**
 * Read a single dotted-path field from the merged config (local overrides
 * shared). Returns `undefined` when neither file carries the path.
 *
 * @internal
 */
export function readLocalOrSharedField(targetDir, dotPath) {
    const merged = loadMergedProjectConfig(targetDir);
    if (!merged)
        return undefined;
    return getDottedPath(merged, dotPath);
}
/**
 * Write a single dotted-path field into `.aperant/config.local.json`,
 * creating the file (and intermediate path) when it doesn't exist.
 *
 * The shared `config.json` is NOT touched by this helper — that's the
 * load-bearing AUDIT-001 contract. Callers that need to remove a stale
 * entry from `config.json` (e.g. the init.mjs migrator) must do so
 * explicitly.
 *
 * @internal
 */
export function writeLocalConfigField(targetDir, dotPath, value) {
    const aperantDir = join(targetDir, '.aperant');
    mkdirSync(aperantDir, { recursive: true });
    const localPath = join(aperantDir, 'config.local.json');
    let local = {};
    if (existsSync(localPath)) {
        try {
            local = JSON.parse(readFileSync(localPath, 'utf-8'));
            if (local === null || typeof local !== 'object' || Array.isArray(local))
                local = {};
        }
        catch (e) {
            throw new Error(`config.local.json parse error: ${e.message}`);
        }
    }
    setDottedPath(local, dotPath, value);
    writeFileSync(localPath, `${JSON.stringify(local, null, 2)}\n`);
}
/**
 * AUDIT-001 — first-run migrator. For each dotted-path entry present in
 * `.aperant/config.json`, copy the value into `.aperant/config.local.json`
 * (idempotent — won't overwrite an existing local value) and delete the
 * entry from `config.json`. Each field's migration is independent —
 * one failure does NOT block the others (a per-field try/catch wraps
 * each move).
 *
 * Returns an array `[{ field, migrated_value, status }]` for caller
 * telemetry; `status` is `'migrated' | 'already-local' | 'absent' | 'error'`.
 *
 * @internal
 */
export function migrateDeferredFieldsToLocalConfig(targetDir, dotPaths) {
    const results = [];
    const sharedPath = join(targetDir, '.aperant', 'config.json');
    if (!existsSync(sharedPath)) {
        for (const dotPath of dotPaths)
            results.push({ field: dotPath, status: 'absent' });
        return results;
    }
    let shared;
    try {
        shared = JSON.parse(readFileSync(sharedPath, 'utf-8'));
    }
    catch {
        for (const dotPath of dotPaths)
            results.push({ field: dotPath, status: 'error' });
        return results;
    }
    // Pre-load local config ONCE so the already-local check reads from
    // local-only state (NOT from the merged loader — that would conflate
    // the shared entry we're about to delete with a real local override).
    const localPath = join(targetDir, '.aperant', 'config.local.json');
    let localAtStart = {};
    if (existsSync(localPath)) {
        try {
            localAtStart = JSON.parse(readFileSync(localPath, 'utf-8')) ?? {};
        }
        catch {
            localAtStart = {};
        }
    }
    let sharedMutated = false;
    for (const dotPath of dotPaths) {
        try {
            const sharedValue = getDottedPath(shared, dotPath);
            if (sharedValue === undefined) {
                results.push({ field: dotPath, status: 'absent' });
                continue;
            }
            const existingLocal = getDottedPath(localAtStart, dotPath);
            // Already-local: the local override already exists. Strip the
            // stale shared entry but keep the local value as-is (don't
            // overwrite a user-tuned local with the stale shared default).
            if (existingLocal !== undefined) {
                deleteDottedPath(shared, dotPath);
                sharedMutated = true;
                results.push({ field: dotPath, status: 'already-local', migrated_value: sharedValue });
                continue;
            }
            writeLocalConfigField(targetDir, dotPath, sharedValue);
            deleteDottedPath(shared, dotPath);
            sharedMutated = true;
            results.push({ field: dotPath, status: 'migrated', migrated_value: sharedValue });
        }
        catch (e) {
            results.push({ field: dotPath, status: 'error', error: e?.message ?? String(e) });
        }
    }
    if (sharedMutated) {
        try {
            writeFileSync(sharedPath, `${JSON.stringify(shared, null, 2)}\n`);
        }
        catch (e) {
            // Disk write failed after per-field local writes succeeded.
            // Log a structured warning so callers can surface the partial state
            // to the user (config.local.json updated, config.json NOT cleaned up).
            results.push({
                field: '__write_shared__',
                status: 'error',
                error: `migration_partial_rollback: config.local.json was updated but config.json could not be written (${e?.message ?? String(e)}). Re-run init to retry.`,
            });
        }
    }
    return results;
}
/**
 * AUDIT-001 — the 5 deferred fields that move to config.local.json.
 * Single source of truth used by both the init migrator and the
 * per-field regression tests.
 */
export const AUDIT_001_LOCAL_FIELDS = Object.freeze([
    'verification.open_report',
    'multi_model.cross_verification.tools',
    'preferences.date_format',
    'preferences.time_format',
    'preferences.quick_task_post_verify',
]);
/**
 * Return a normalized task_isolation config object with safe fallbacks.
 * Always returns a concrete object even when the project has no config at all,
 * so callers can treat this as the single source of truth for isolation flags.
 */
export function getTaskIsolationConfig(projectDir) {
    const defaults = {
        worktree_per_task: false,
        branch_prefix: 'apt',
        from_branch: 'main',
        push_on_create: true,
        cleanup_on_close: 'ask',
        // C54 — language-agnostic worktree bootstrap. All off by default so
        // existing users (any project without `task_isolation.bootstrap_worktree`
        // in config) see zero behavior change.
        bootstrap_worktree: false,
        max_parallel_installs: 2,
        skip_bootstrap_for: [],
        install_registry_allowlist: null, // null ⇒ use hardcoded manifest-scanner defaults
        bootstrap_cmd: null, // custom install command (string[] argv); null ⇒ auto-detect
    };
    const config = loadProjectConfig(resolve(projectDir));
    const user = config?.task_isolation || {};
    // Allow pipeline.branch_prefix to act as the default when task_isolation
    // doesn't specify its own — mirrors how /apt:ship already reads pipeline.
    if (!user.branch_prefix && config?.pipeline?.branch_prefix) {
        user.branch_prefix = config.pipeline.branch_prefix;
    }
    return { ...defaults, ...user };
}
const __filename = fileURLToPath(import.meta.url);
// packages/framework/src/cli/config/load.mjs → packages/framework
const FRAMEWORK_ROOT = resolve(dirname(__filename), '..', '..', '..');
/**
 * Resolve the allowlist of trusted skill sources — spec §2.5 + subtask 7.
 *
 * When `.aperant/config.json` carries `trusted_skill_sources: [...]` the
 * user value wins (typically used to allowlist a private skill marketplace
 * clone). When absent we compute safe defaults:
 *
 *   1. <framework-repo>/skills                    (dev checkout)
 *   2. <targetDir>/packages/framework/skills      (monorepo dev)
 *   3. <targetDir>/.aperant/deps/node_modules/@aperant/framework/skills
 *                                                 (consumer install)
 *   4. <targetDir>/<cli-install-root>/skills      (currently-detected runtime)
 *
 * Any non-existent entry is pruned — the caller never has to existsSync().
 *
 * @param {string} projectDir
 * @param {string} [cli]  result of detectHost().cli ('claude-code', etc.)
 * @returns {string[]}    absolute paths, de-duplicated, order-preserving
 */
export function getTrustedSkillSources(projectDir, cli) {
    const targetDir = resolve(projectDir);
    const config = loadProjectConfig(targetDir);
    const configured = Array.isArray(config?.trusted_skill_sources)
        ? config.trusted_skill_sources
        : null;
    if (configured && configured.length > 0) {
        // SEC-001: Relative paths are resolved against targetDir and then checked
        // for containment — a relative path with "../" components could otherwise
        // escape the project root and point at arbitrary filesystem locations. Only
        // paths that resolve INSIDE targetDir are accepted as relative entries;
        // paths that escape are rejected with a clear error so the failure is not
        // silent.
        //
        // Absolute paths are accepted as-is (they represent legitimate use cases
        // such as a shared skill marketplace clone or a framework install outside
        // the project root). The attack surface for absolute paths is the same as
        // for any other `config.json` mutation — if an attacker can write
        // `config.json`, they can already inject skills directly. The real attack
        // vector SEC-001 is guarding against is the subtle relative-path escape
        // (e.g. `"../../../etc/evil"`).
        const resolved = [];
        for (const p of configured) {
            if (isAbsolute(p)) {
                resolved.push(resolve(p));
            }
            else {
                const abs = resolve(targetDir, p);
                const rel = relative(targetDir, abs);
                if (rel.startsWith('..') || isAbsolute(rel)) {
                    throw new Error(`trusted_skill_sources relative entry "${p}" escapes the project directory ` +
                        `"${targetDir}" (resolves to "${abs}"). ` +
                        'Relative paths must not use "../" components that walk above the project root. ' +
                        'Use an absolute path to reference a skill source outside the project.');
                }
                resolved.push(abs);
            }
        }
        return resolved.filter((p, i, arr) => arr.indexOf(p) === i);
    }
    const defaults = [];
    // 1. Framework repo's own skills dir (what this process runs from)
    const frameworkSkills = join(FRAMEWORK_ROOT, 'skills');
    if (existsSync(frameworkSkills))
        defaults.push(frameworkSkills);
    // 2. Monorepo dev: packages/framework/skills under targetDir
    const monorepoSkills = join(targetDir, 'packages', 'framework', 'skills');
    if (monorepoSkills !== frameworkSkills && existsSync(monorepoSkills)) {
        defaults.push(monorepoSkills);
    }
    // 3. Consumer install: pulled in via .aperant/deps
    const consumerSkills = join(targetDir, '.aperant', 'deps', 'node_modules', '@aperant', 'framework', 'skills');
    if (existsSync(consumerSkills))
        defaults.push(consumerSkills);
    // 4. Host CLI's installed runtime skills — only when the router knows
    // which runtime it's in. Some hosts (claude-code, gemini, …) install into
    // .claude/skills / .gemini/skills etc; the router picks skills from
    // the host-local directory so per-runtime transforms are honored.
    //
    // Codex is excluded: its transform strips frontmatter to {name, description}
    // for codex validator compliance, and that stripped form fails the
    // router's SkillFrontmatterSchema (contract.mjs). The canonical + kernel
    // roots above still serve routing on Codex hosts (they carry the fat
    // frontmatter the schema requires).
    const installRoot = cli ? cliToInstallRoot(cli) : null;
    if (installRoot && cli !== 'codex') {
        const runtimeSkills = join(targetDir, installRoot, 'skills');
        if (existsSync(runtimeSkills))
            defaults.push(runtimeSkills);
    }
    return defaults.filter((p, i, arr) => arr.indexOf(p) === i);
}
//# sourceMappingURL=load.mjs.map