/**
 * Config-sync engine: makes `.lisa.config.json` (plus the per-developer
 * `.lisa.config.local.json` overlay) the source of truth for every registry
 * setting.
 *
 * Populate direction (only when a config value is completely missing):
 * absorb the value from an existing artifact file if one holds it, otherwise
 * fall back to the built-in default. Pure-default populations are recorded in
 * the config's `_lisaSync.populated` block so a later Lisa version that ships
 * a changed default can distinguish "still the default" (safe to update) from
 * "the project chose this value" (never touched).
 *
 * Sync direction (always): the effective config value is written into every
 * artifact file that exists on disk — config wins.
 * @module sync/config-sync
 */
import * as fse from "fs-extra";
import * as path from "node:path";
import { deepMerge, readJsonOrNull, writeJson } from "../utils/index.js";
import { fillMissing, getAtPath, isJsonObject, jsonEquals, setAtPath, } from "./json-path.js";
import { REQUIRED_KEYS, SYNC_REGISTRY, } from "./registry.js";
import { syncArtifacts, validateEntryValue, } from "./artifact-sync.js";
import { aliasCompatibleDefault, hasLegacyAlias } from "./legacy-aliases.js";
import { applyLegacyMonitorThresholdMigration } from "./legacy-monitor-thresholds.js";
import { recordedPopulation, recordPopulation } from "./sync-population.js";
/**
 * Check whether a registry relevance condition matches the merged config.
 * @param merged - Merged (committed + local) config view
 * @param condition - `"section"` (path exists) or `"path=value"` (equality)
 * @returns True when the condition matches
 */
function conditionMatches(merged, condition) {
    const eqIndex = condition.indexOf("=");
    if (eqIndex === -1) {
        return getAtPath(merged, condition) !== undefined;
    }
    const value = getAtPath(merged, condition.slice(0, eqIndex));
    return value === condition.slice(eqIndex + 1);
}
/**
 * Whether a registry entry applies to this project's current config.
 * @param merged - Merged (committed + local) config view
 * @param relevantWhen - Entry conditions (absent = always relevant)
 * @returns True when the entry should be synced
 */
function isRelevant(merged, relevantWhen) {
    if (relevantWhen === undefined)
        return true;
    return relevantWhen.some(condition => conditionMatches(merged, condition));
}
/**
 * Read the first existing artifact's value for a setting.
 * @param entry - Registry entry
 * @param reads - Project input readers
 * @returns The artifact value, or undefined when no artifact holds one
 */
async function readArtifactValue(entry, reads) {
    const bindings = entry.artifacts ?? [];
    const values = await Promise.all(bindings.map(async (binding) => {
        const parsed = await reads.readJson(binding.file);
        return parsed === null ? undefined : getAtPath(parsed, binding.pointer);
    }));
    return values.find(value => value !== undefined);
}
/**
 * Build the next state with an updated committed config and appended action.
 * @param state - Current sync state
 * @param committed - Updated committed config
 * @param action - Action to append to the report
 * @returns Updated state
 */
function withAction(state, committed, action) {
    return { ...state, committed, actions: [...state.actions, action] };
}
/**
 * Populate a config key that is completely missing: absorb the artifact
 * value when one exists, otherwise write the built-in default and record it.
 * @param state - Current sync state
 * @param entry - Registry entry
 * @param artifactValue - Value found in an existing artifact, if any
 * @returns Updated state
 */
function populateMissing(state, entry, artifactValue) {
    if (artifactValue !== undefined) {
        const absorbed = validateEntryValue(entry, fillMissing(artifactValue, entry.defaultValue));
        return withAction(state, setAtPath(state.committed, entry.key, absorbed), {
            key: entry.key,
            kind: "absorbed-artifact",
            detail: `value absorbed from ${entry.artifacts?.[0]?.file ?? "artifact"}`,
        });
    }
    const defaultValue = validateEntryValue(entry, entry.defaultValue);
    const committed = recordPopulation(setAtPath(state.committed, entry.key, defaultValue), entry.key, defaultValue);
    return withAction(state, committed, {
        key: entry.key,
        kind: "populated-default",
        detail: "built-in default written to .lisa.config.json",
    });
}
/**
 * Resolve the next committed config for one entry (populate direction).
 * @param state - Current sync state
 * @param entry - Registry entry
 * @param merged - Merged config view (local overlay applied)
 * @param local - Local config overlay
 * @param artifactValue - Value found in an existing artifact, if any
 * @returns Updated state
 */
function populateEntry(state, entry, merged, local, artifactValue) {
    const stateAfterMigration = applyLegacyMonitorThresholdMigration(state, entry.key);
    const mergedAfterMigration = stateAfterMigration === state
        ? merged
        : deepMerge(stateAfterMigration.committed, local);
    const rawConfigValue = getAtPath(mergedAfterMigration, entry.key);
    if (rawConfigValue === undefined) {
        return populateMissing(stateAfterMigration, entry, artifactValue);
    }
    const configValue = validateEntryValue(entry, rawConfigValue);
    const committedValue = getAtPath(stateAfterMigration.committed, entry.key);
    const localValue = getAtPath(local, entry.key);
    const recorded = recordedPopulation(stateAfterMigration.committed, entry.key);
    const evolutionValue = entry.legacyAliases === undefined ? configValue : committedValue;
    if (recorded !== undefined &&
        jsonEquals(evolutionValue, recorded) &&
        !jsonEquals(evolutionValue, entry.defaultValue) &&
        !hasLegacyAlias(localValue, entry.legacyAliases)) {
        const committed = recordPopulation(setAtPath(stateAfterMigration.committed, entry.key, entry.defaultValue), entry.key, entry.defaultValue);
        return withAction(stateAfterMigration, committed, {
            key: entry.key,
            kind: "default-evolved",
            detail: "value still matched the old default; updated to the new one",
        });
    }
    const hasEffectiveAlias = hasLegacyAlias(configValue, entry.legacyAliases);
    if (hasEffectiveAlias && committedValue === undefined) {
        return stateAfterMigration;
    }
    // Compatibility fills are based only on committed state. Each present alias
    // prunes its own mapped current default while unrelated defaults still fill.
    const fillSource = hasEffectiveAlias ? committedValue : configValue;
    const fallback = hasEffectiveAlias
        ? aliasCompatibleDefault(configValue, entry.defaultValue, entry.legacyAliases)
        : entry.defaultValue;
    const filled = fillMissing(fillSource, fallback);
    if (!jsonEquals(filled, fillSource)) {
        return withAction(stateAfterMigration, setAtPath(stateAfterMigration.committed, entry.key, filled), {
            key: entry.key,
            kind: "filled-missing",
            detail: "missing sub-keys filled with defaults",
        });
    }
    return stateAfterMigration;
}
/**
 * Report required keys that are missing from the merged config.
 * @param merged - Merged config view
 * @returns Missing required keys with their setup hints
 */
function findMissingRequired(merged) {
    return REQUIRED_KEYS.filter(required => isRelevant(merged, required.relevantWhen) &&
        getAtPath(merged, required.key) === undefined).map(required => ({ key: required.key, setupHint: required.setupHint }));
}
/**
 * Run one populate-and-sync pass over a project.
 *
 * Precedence per setting: existing config value → existing artifact value →
 * built-in default. After population, every existing artifact file is
 * rewritten from the (local-overlaid) config value.
 * @param destDir - Absolute path to the project root
 * @param options - Sync options
 * @returns Report of every action taken (or planned, when dryRun)
 */
export async function runConfigSync(destDir, options = {}) {
    const reads = options.reads ?? defaultSyncReads(destDir);
    const committedRaw = await reads.readJson(".lisa.config.json");
    const localRaw = await reads.readJson(".lisa.config.local.json");
    const committed = isJsonObject(committedRaw) ? committedRaw : {};
    const local = isJsonObject(localRaw) ? localRaw : {};
    const initial = {
        committed,
        actions: [],
        artifactWrites: new Map(),
        divergence: undefined,
    };
    const finalState = await SYNC_REGISTRY.reduce(async (statePromise, entry) => {
        const state = await statePromise;
        const merged = deepMerge(state.committed, local);
        if (!isRelevant(merged, entry.relevantWhen)) {
            return state;
        }
        const artifactValue = await readArtifactValue(entry, reads);
        const populated = populateEntry(state, entry, merged, local, artifactValue);
        return syncArtifacts(populated, entry, local, reads);
    }, Promise.resolve(initial));
    // Fail the whole pass, dry run included: a dry run that reported success
    // while the two mutation floors disagree would be the same silence this
    // guard exists to remove.
    // Fail the whole pass, dry run included: a dry run that reported success
    // while the two mutation floors disagree would be the same silence this
    // guard exists to remove. A divergence the file itself records is handled
    // in artifact-sync.ts and never reaches here.
    if (finalState.divergence !== undefined) {
        throw finalState.divergence;
    }
    const mergedFinal = deepMerge(finalState.committed, local);
    if (options.dryRun !== true) {
        await persistState(destDir, committed, finalState);
    }
    return {
        actions: finalState.actions,
        missingRequired: findMissingRequired(mergedFinal),
        dryRun: options.dryRun === true,
    };
}
/**
 * Build the normal filesystem-backed readers used by mutating and CLI sync.
 * @param destDir - Project root
 * @returns Default sync input readers
 */
const defaultSyncReads = (destDir) => ({
    readJson: relativePath => readJsonOrNull(path.join(destDir, relativePath)),
    pathExists: relativePath => fse.pathExists(path.join(destDir, relativePath)),
});
/**
 * Write the updated committed config and every queued artifact file.
 * @param destDir - Project root
 * @param originalCommitted - Committed config as read at the start of the run
 * @param state - Final sync state
 */
async function persistState(destDir, originalCommitted, state) {
    if (!jsonEquals(originalCommitted, state.committed)) {
        await writeJson(path.join(destDir, ".lisa.config.json"), state.committed);
    }
    await Promise.all(Array.from(state.artifactWrites.entries()).map(([file, content]) => writeJson(path.join(destDir, file), content)));
}
//# sourceMappingURL=config-sync.js.map