/**
 * Map an authoring project (the default export of `lobu.config.ts`, built by
 * `defineConfig` from `@lobu/cli/config`) to the apply `DesiredState`.
 *
 * `DesiredState` is an apply-internal IR and stays CLI-private; this is the one
 * place that translates the public authoring objects into it. The mapping is
 * pure (modulo `installedAt` timestamps, matching the TOML loader) so it can be
 * unit-tested without importing `lobu.config.ts`.
 */
import { validateEntityMetrics } from "@lobu/connector-sdk";
import { isHostedChatEntry } from "@lobu/core";
import { CronExpressionParser } from "cron-parser";
import { isSecretRef } from "../../../config/index.js";
import { ValidationError } from "../../memory/_lib/errors.js";
/** Source label recorded on connector docs (mirrors the YAML manifest path). */
const CONFIG_SOURCE = "lobu.config.ts";
// Mirror the TOML loader's structural validators so a malformed TS config fails
// loud in the CLI before any remote mutation, not with a confusing server 4xx.
const CONNECTION_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
const AUTH_PROFILE_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,79}$/;
const MIN_CRON_INTERVAL_MS = 60_000;
/** Throw on the first duplicate identifier in a collection (config parity). */
function assertUniqueBy(items, key, label) {
    const seen = new Set();
    for (const item of items) {
        const k = key(item);
        if (seen.has(k)) {
            throw new ValidationError(`duplicate ${label} "${k}" in lobu.config.ts — each must be unique`);
        }
        seen.add(k);
    }
}
/** Error message if the cron is invalid or fires more than once a minute, else null. */
function cronError(schedule) {
    try {
        const it = CronExpressionParser.parse(schedule);
        const first = it.next().toDate();
        const second = it.next().toDate();
        if (second.getTime() - first.getTime() < MIN_CRON_INTERVAL_MS) {
            return `schedule "${schedule}" is too frequent (minimum interval is 1 minute)`;
        }
        return null;
    }
    catch (err) {
        return `invalid cron expression "${schedule}" — ${err instanceof Error ? err.message : String(err)}`;
    }
}
/** `"$NAME"` → `"NAME"`, else null. Mirrors the TOML loader's env-ref detection. */
function envRefName(value) {
    const match = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(value.trim());
    return match ? (match[1] ?? null) : null;
}
/** Provider id used as the storage key; falls back to the model when omitted. */
function providerId(provider) {
    return provider.id ?? provider.model;
}
/** Resolve a connector reference (key string, or the class from `defineConnector`) to its key. */
function connectorKey(ref) {
    if (typeof ref === "string")
        return ref;
    return new ref().definition.key;
}
function entitySlug(ref) {
    return typeof ref === "string" ? ref : ref.key;
}
function agentId(ref) {
    return typeof ref === "string" ? ref : ref.id;
}
function authProfileSlug(ref) {
    if (ref === undefined)
        return undefined;
    return typeof ref === "string" ? ref : ref.slug;
}
/**
 * Deterministic, human-readable stable id for a platform binding, derived from
 * `(agentId, type, name?)`. Must stay stable across applies so the same
 * platform matches (noop) instead of being recreated — `apply` PUTs it to
 * `/platforms/by-stable-id/:stableId`.
 */
function platformStableId(agentId, type, name) {
    const slug = (s) => s
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, "-")
        .replace(/^-+|-+$/g, "");
    return [agentId, type, name]
        .filter((p) => !!p)
        .map(slug)
        .filter(Boolean)
        .join("-");
}
/**
 * Resolve a credential to its actual secret value, mirroring the TOML loader's
 * connector-credential handling (`loadConnectors` in desired-state.ts): a
 * `secret()` / `$VAR` ref resolves to its env value — apply pushes the REAL
 * value to the DB, never the `$VAR` placeholder — and a literal passes through.
 * The ref is collected so the apply secrets gate fails loud when it is unset
 * (the placeholder is only returned as a safe fallback the gate then rejects).
 */
function resolveCredentialValue(value, required, env) {
    if (isSecretRef(value)) {
        required.add(value.$secret);
        return env[value.$secret] ?? `$${value.$secret}`;
    }
    const ref = envRefName(value);
    if (ref) {
        required.add(ref);
        return env[ref] ?? value;
    }
    return value;
}
/**
 * Merge an agent's file-resolved artifacts (prompt markdown from its dir +
 * skills declared via `defineAgent({ skills })`) into the already-mapped agent
 * settings. Pure (no file IO — the loader reads the files and passes the
 * results in) so it can be unit-tested directly.
 *
 * Mirrors `buildAgentSettings`'s skill-merge semantics exactly: agent-level
 * nix is laid down first (already in `settings`), then skill nix packages are
 * unioned on top (deduped). Skills are prompt/behavior only — they no longer
 * contribute network or MCP config.
 */
export function mergeAgentDirArtifacts(settings, markdown, localSkills) {
    if (markdown.soulMd)
        settings.soulMd = markdown.soulMd;
    if (markdown.identityMd)
        settings.identityMd = markdown.identityMd;
    if (markdown.userMd)
        settings.userMd = markdown.userMd;
    if (localSkills.length > 0) {
        settings.skillsConfig = { skills: localSkills };
    }
    // Nix merge — agent packages first, then skill packages, deduped.
    const nixPackages = [
        ...(settings.nixConfig?.packages ?? []),
        ...localSkills.flatMap((s) => s.nixPackages ?? []),
    ];
    if (nixPackages.length > 0) {
        settings.nixConfig = {
            ...settings.nixConfig,
            packages: [...new Set(nixPackages)],
        };
    }
}
function mapAgent(agent, env, required) {
    const settings = {};
    if (agent.providers?.length) {
        settings.installedProviders = agent.providers.map((p) => ({
            providerId: providerId(p),
            installedAt: Date.now(),
        }));
        settings.modelSelection = { mode: "auto" };
        const preferences = Object.fromEntries(agent.providers
            .filter((p) => !!p.model?.trim())
            .map((p) => [providerId(p), p.model.trim()]));
        if (Object.keys(preferences).length > 0) {
            settings.providerModelPreferences = preferences;
        }
    }
    const allowed = agent.network?.allowed ?? [];
    const denied = agent.network?.denied ?? [];
    if (allowed.length > 0 || denied.length > 0) {
        settings.networkConfig = {
            ...(allowed.length > 0 ? { allowedDomains: [...new Set(allowed)] } : {}),
            ...(denied.length > 0 ? { deniedDomains: [...new Set(denied)] } : {}),
        };
    }
    if (agent.tools) {
        if (agent.tools.preApproved?.length) {
            settings.preApprovedTools = [...new Set(agent.tools.preApproved)];
        }
        const toolsConfig = {};
        if (agent.tools.allowed?.length) {
            toolsConfig.allowedTools = [...new Set(agent.tools.allowed)];
        }
        if (agent.tools.denied?.length) {
            toolsConfig.deniedTools = [...new Set(agent.tools.denied)];
        }
        if (agent.tools.strict !== undefined)
            toolsConfig.strictMode = agent.tools.strict;
        if (Object.keys(toolsConfig).length > 0)
            settings.toolsConfig = toolsConfig;
    }
    if (agent.guardrails?.length) {
        settings.guardrails = [...new Set(agent.guardrails)];
    }
    if (agent.nixPackages?.length) {
        settings.nixConfig = { packages: [...new Set(agent.nixPackages)] };
    }
    const providerKeys = [];
    for (const provider of agent.providers ?? []) {
        if (provider.key === undefined)
            continue;
        if (isSecretRef(provider.key)) {
            required.add(provider.key.$secret);
            const value = env[provider.key.$secret];
            if (value)
                providerKeys.push({ providerId: providerId(provider), value });
            continue;
        }
        const ref = envRefName(provider.key);
        if (ref) {
            required.add(ref);
            const value = env[ref];
            if (value)
                providerKeys.push({ providerId: providerId(provider), value });
            continue;
        }
        providerKeys.push({
            providerId: providerId(provider),
            value: provider.key,
        });
    }
    const platforms = (agent.platforms ?? [])
        // A hosted-bot entry (slack/telegram with no `config`) is reached via the
        // hosted Lobu bot + a `/lobu link` claim, NOT a self-hosted connection. It
        // must never become a credential-less `agent_connections` row — `lobu run`
        // reads it straight from the authored config to mint the link code.
        .filter((p) => !isHostedChatEntry(p))
        .map((p) => ({
        stableId: platformStableId(agent.id, p.type, p.name),
        type: p.type,
        ...(p.name ? { name: p.name } : {}),
        // Resolve `secret()`/`$VAR` to the REAL value — the platform-write path
        // stores the incoming plaintext as the secret (server-side
        // `normalizeConfigForStorage` swaps it for a `secret://` ref + encrypts it),
        // so sending the `$VAR` placeholder would persist a broken token. Mirrors
        // provider keys + auth-profile credentials. The config row never holds
        // cleartext at rest; the secret name is collected for the secrets gate.
        config: Object.fromEntries(Object.entries(p.config ?? {}).map(([k, v]) => [
            k,
            resolveCredentialValue(v, required, env),
        ])),
        ...(p.channels?.length ? { channels: p.channels } : {}),
    }));
    // Distinct platforms must not collapse to the same stable id (e.g. names that
    // slugify equal), or apply would clobber one with the other.
    const seenStableIds = new Set();
    for (const p of platforms) {
        if (seenStableIds.has(p.stableId)) {
            throw new ValidationError(`agent "${agent.id}" has two platforms that resolve to the same id "${p.stableId}" — give them distinct names`);
        }
        seenStableIds.add(p.stableId);
        // Channel bindings are Slack-only and must be "<teamId>/<channelId>".
        // Validate up front (the TOML loader did) so a bad binding fails the plan
        // instead of erroring at syncPlatformChannels after platforms are mutated.
        if (p.channels?.length) {
            if (p.type !== "slack") {
                throw new ValidationError(`agent "${agent.id}" platform "${p.type}" declares channels, but channel bindings are only supported on slack`);
            }
            for (const channel of p.channels) {
                if (!/^[^/\s]+\/[^/\s]+$/.test(channel)) {
                    throw new ValidationError(`agent "${agent.id}" slack platform has an invalid channel "${channel}" — expected "<teamId>/<channelId>"`);
                }
            }
        }
    }
    const metadata = {
        agentId: agent.id,
        name: agent.name ?? agent.id,
    };
    if (agent.description)
        metadata.description = agent.description;
    return { metadata, settings, platforms, providerKeys };
}
function mapEntityType(entity) {
    // Fail loud in the CLI before any remote mutation: the server's
    // assertValidBacking would otherwise reject an empty backing mid-apply with a
    // confusing 4xx, possibly after other mutations have already landed.
    if (entity.backing && entity.backing.sql.trim() === "") {
        throw new ValidationError(`entity type "${entity.key}" has an empty backing.sql`);
    }
    // Declared metrics, included only when present so a non-metric type never
    // churns the diff (mirrors `backing`). The four fields round-trip verbatim.
    const metrics = entity.eventSets || entity.measures || entity.dimensions || entity.segments
        ? {
            ...(entity.eventSets ? { eventSets: entity.eventSets } : {}),
            ...(entity.measures ? { measures: entity.measures } : {}),
            ...(entity.dimensions ? { dimensions: entity.dimensions } : {}),
            ...(entity.segments ? { segments: entity.segments } : {}),
        }
        : undefined;
    // Fail loud in the CLI (before any remote mutation) on referential/shape
    // errors the types can't catch — a measure naming a missing eventSet/segment,
    // or a non-`count` measure without `expr`. The server re-validates.
    if (metrics) {
        const errors = validateEntityMetrics(metrics);
        if (errors.length > 0) {
            throw new ValidationError(`entity type "${entity.key}" has invalid metrics: ${errors.join("; ")}`);
        }
    }
    return {
        slug: entity.key,
        ...(entity.name ? { name: entity.name } : {}),
        ...(entity.description ? { description: entity.description } : {}),
        ...(entity.required ? { required: entity.required } : {}),
        ...(entity.properties ? { properties: entity.properties } : {}),
        // Event kinds included only when declared so a type with none compares equal
        // on both sides and never churns the diff (mirrors `backing`/`metrics`).
        ...(entity.eventKinds && Object.keys(entity.eventKinds).length > 0
            ? { eventKinds: entity.eventKinds }
            : {}),
        // View template included only when declared so absence never churns the diff
        // (a no-prune apply leaves any UI-authored template untouched).
        ...(entity.viewTemplate ? { viewTemplate: entity.viewTemplate } : {}),
        // metadata is carried for config-API compat (defineEntityType consumers may
        // attach it) but is neither diffed nor sent to the server.
        ...(entity.metadata ? { metadata: entity.metadata } : {}),
        // `backing` is present only for derived types; a stored entity (the default)
        // carries no backing so it never churns the diff. `connection` (a slug) is
        // included only when set, so an internal-backed view never churns either.
        ...(entity.backing
            ? {
                backing: {
                    sql: entity.backing.sql,
                    ...(entity.backing.connection
                        ? { connection: entity.backing.connection }
                        : {}),
                },
            }
            : {}),
        ...(metrics ? { metrics } : {}),
    };
}
function mapRelationshipType(rel) {
    return {
        slug: rel.key,
        ...(rel.name ? { name: rel.name } : {}),
        ...(rel.description ? { description: rel.description } : {}),
        ...(rel.rules
            ? {
                rules: rel.rules.map((rule) => ({
                    source: entitySlug(rule.source),
                    target: entitySlug(rule.target),
                })),
            }
            : {}),
        // metadata is carried for config-API compat (defineRelationshipType
        // consumers may attach it) but is neither diffed nor sent to the server.
        ...(rel.metadata ? { metadata: rel.metadata } : {}),
    };
}
/**
 * The config API authors `keyingConfig` in camelCase (`entityType`, `entityPath`,
 * `keyFields`, `keyOutputField`), but the server stores it verbatim into
 * `keying_config` and reads snake_case keys (`watcher-extraction-schema.ts`,
 * `promote-keyed-entities.ts`). Without this translation an entity-typed watcher
 * authored via config silently lands as untyped (schema derivation + promotion
 * both miss `entity_type`). Translate the known keys; pass any extra keys through.
 */
const KEYING_KEY_MAP = {
    entityType: "entity_type",
    entityPath: "entity_path",
    keyFields: "key_fields",
    keyOutputField: "key_output_field",
};
function normalizeKeyingConfig(kc) {
    const out = {};
    for (const [k, v] of Object.entries(kc))
        out[KEYING_KEY_MAP[k] ?? k] = v;
    return out;
}
function mapWatcher(watcher) {
    if (watcher.schedule) {
        const err = cronError(watcher.schedule);
        if (err) {
            throw new ValidationError(`watcher "${watcher.slug}" has an invalid schedule "${watcher.schedule}": ${err}`);
        }
    }
    const sources = watcher.sources
        ? Object.entries(watcher.sources).map(([name, query]) => ({ name, query }))
        : undefined;
    return {
        slug: watcher.slug,
        agent: agentId(watcher.agent),
        prompt: watcher.prompt,
        ...(watcher.keyingConfig
            ? { keyingConfig: normalizeKeyingConfig(watcher.keyingConfig) }
            : {}),
        ...(watcher.name ? { name: watcher.name } : {}),
        ...(watcher.description ? { description: watcher.description } : {}),
        ...(watcher.schedule ? { schedule: watcher.schedule } : {}),
        ...(sources ? { sources } : {}),
        ...(watcher.notification?.channel
            ? { notificationChannel: watcher.notification.channel }
            : {}),
        ...(watcher.notification?.priority
            ? { notificationPriority: watcher.notification.priority }
            : {}),
        ...(watcher.minCooldownSeconds !== undefined
            ? { minCooldownSeconds: watcher.minCooldownSeconds }
            : {}),
        ...(watcher.tags ? { tags: watcher.tags } : {}),
        ...(watcher.reactionsGuidance
            ? { reactionsGuidance: watcher.reactionsGuidance }
            : {}),
        ...(watcher.agentKind ? { agentKind: watcher.agentKind } : {}),
    };
}
function mapAuthProfile(profile, required, env) {
    if (!AUTH_PROFILE_SLUG_PATTERN.test(profile.slug)) {
        throw new ValidationError(`auth profile slug "${profile.slug}" must match /^[a-z0-9][a-z0-9-]{0,79}$/ (lowercase letters/digits/hyphens, no leading hyphen, ≤80 chars)`);
    }
    const interactive = profile.authKind === "oauth_account" ||
        profile.authKind === "browser_session";
    if (interactive &&
        profile.credentials &&
        Object.keys(profile.credentials).length > 0) {
        throw new ValidationError(`auth profile "${profile.slug}" has kind "${profile.authKind}" — credentials must not be set; lobu apply never writes interactive-auth tokens (complete auth via the connect URL)`);
    }
    const credentials = profile.credentials && !interactive
        ? Object.fromEntries(Object.entries(profile.credentials).map(([key, value]) => [
            key,
            resolveCredentialValue(value, required, env),
        ]))
        : undefined;
    return {
        slug: profile.slug,
        connector: connectorKey(profile.connector),
        kind: profile.authKind,
        sourceFile: CONFIG_SOURCE,
        ...(profile.name ? { name: profile.name } : {}),
        ...(credentials ? { credentials } : {}),
    };
}
function mapConnection(connection) {
    if (!CONNECTION_SLUG_PATTERN.test(connection.slug)) {
        throw new ValidationError(`connection slug "${connection.slug}" must match /^[a-z0-9][a-z0-9-]{0,62}$/ (lowercase letters/digits/hyphens, no leading hyphen, ≤63 chars)`);
    }
    const seenFeeds = new Set();
    const feeds = (connection.feeds ?? []).map((feed) => {
        if (seenFeeds.has(feed.feed)) {
            throw new ValidationError(`connection "${connection.slug}" declares feed "${feed.feed}" more than once`);
        }
        seenFeeds.add(feed.feed);
        if (feed.schedule) {
            const err = cronError(feed.schedule);
            if (err) {
                throw new ValidationError(`connection "${connection.slug}" feed "${feed.feed}" has an invalid schedule "${feed.schedule}": ${err}`);
            }
        }
        return {
            feedKey: feed.feed,
            ...(feed.name ? { name: feed.name } : {}),
            ...(feed.schedule ? { schedule: feed.schedule } : {}),
            ...(feed.config ? { config: feed.config } : {}),
        };
    });
    const authSlug = authProfileSlug(connection.authProfile);
    const appAuthSlug = authProfileSlug(connection.appAuthProfile);
    // A managed connection's grant lives in a cloud (public) org. Fold the
    // `managedBy` descriptor into the persisted connection `config` so the server
    // resolver (execution-context.ts) can detect it and fetch the user's token
    // from the cloud at runtime — no new column or CRUD field needed. It lives in
    // the trusted connection `config` (never in `auth_data`).
    const config = connection.managedBy
        ? { ...(connection.config ?? {}), managedBy: { ...connection.managedBy } }
        : connection.config;
    return {
        slug: connection.slug,
        connector: connectorKey(connection.connector),
        feeds,
        sourceFile: CONFIG_SOURCE,
        ...(connection.name ? { name: connection.name } : {}),
        ...(authSlug ? { authProfileSlug: authSlug } : {}),
        ...(appAuthSlug ? { appAuthProfileSlug: appAuthSlug } : {}),
        ...(config ? { config } : {}),
        ...(connection.deviceWorkerId
            ? { deviceWorkerId: connection.deviceWorkerId }
            : {}),
    };
}
/**
 * Translate an authoring project into the apply `DesiredState`. When `only` is
 * set, connector definitions/connections/auth-profiles are skipped (and their
 * secrets not collected), matching the TOML loader's `--only` behavior so
 * `lobu apply --only agents` doesn't demand connector secrets.
 */
export function mapProjectToDesiredState(project, env = process.env, only) {
    const required = new Set();
    const agents = project.agents.map((agent) => mapAgent(agent, env, required));
    const entityTypes = (project.entities ?? []).map(mapEntityType);
    const relationshipTypes = (project.relationships ?? []).map(mapRelationshipType);
    const watchers = (project.watchers ?? []).map(mapWatcher);
    const authProfiles = only
        ? []
        : (project.authProfiles ?? []).map((profile) => mapAuthProfile(profile, required, env));
    const connections = only
        ? []
        : (project.connections ?? []).map(mapConnection);
    // Reject duplicate identifiers per collection (the TOML/YAML loader did this;
    // the TS path must keep parity). Duplicates otherwise generate duplicate plan
    // rows that fail mid-apply or make the desired state ambiguous.
    assertUniqueBy(agents, (a) => a.metadata.agentId, "agent id");
    assertUniqueBy(entityTypes, (e) => e.slug, "entity type key");
    assertUniqueBy(relationshipTypes, (r) => r.slug, "relationship type key");
    assertUniqueBy(watchers, (w) => w.slug, "watcher slug");
    assertUniqueBy(authProfiles, (p) => p.slug, "auth profile slug");
    assertUniqueBy(connections, (c) => c.slug, "connection slug");
    const agentIds = new Set(project.agents.map((agent) => agent.id));
    for (const watcher of watchers) {
        if (!agentIds.has(watcher.agent)) {
            throw new ValidationError(`watcher "${watcher.slug}" names agent "${watcher.agent}", but no agent with that id is declared in lobu.config.ts`);
        }
    }
    const memory = {};
    if (project.org)
        memory.org = project.org;
    if (project.orgName)
        memory.name = project.orgName;
    if (project.orgDescription)
        memory.description = project.orgDescription;
    if (project.organizationId)
        memory.organizationId = project.organizationId;
    return {
        agents,
        prune: project.prune ?? false,
        ...(Object.keys(memory).length > 0 ? { memory } : {}),
        memorySchema: { entityTypes, relationshipTypes },
        watchers,
        connectors: { definitions: [], authProfiles, connections },
        requiredSecrets: [...required].sort(),
    };
}
//# sourceMappingURL=map-config.js.map