import { existsSync, readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import Ajv from "ajv";
import addFormats from "ajv-formats";
import { ValidationError } from "../../memory/_lib/errors.js";
import { mapProjectToDesiredState, mergeAgentDirArtifacts, } from "./map-config.js";
import { isRecord, } from "./shared.js";
async function parseSkillFrontmatter(raw) {
    const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
    if (!match?.[1])
        return { frontmatter: null, body: raw.trim() };
    const { parse: parseYaml } = await import("yaml");
    const parsed = parseYaml(match[1]);
    return {
        frontmatter: parsed && typeof parsed === "object" ? parsed : null,
        body: (match[2] || "").trim(),
    };
}
/**
 * Map a resolved skill (inline `defineSkill` or file-loaded `skillFromFile`)
 * into a `SkillConfig` entry — the shape stored on agent settings and synced to
 * the worker's `.skills/`. The nix packages here merge into the agent's worker
 * sandbox at apply time, which is why skills resolve eagerly. Skills are
 * prompt/behavior only — they declare no network or MCP config.
 */
function skillToConfig(args) {
    const skill = {
        repo: `${args.source}/${args.name}`,
        name: args.name,
        content: args.content,
        enabled: true,
    };
    if (args.description)
        skill.description = args.description;
    if (args.nixPackages?.length)
        skill.nixPackages = args.nixPackages;
    return skill;
}
/** Read a `SKILL.md` (a dir holding one, or a `.md` path) for `skillFromFile`. */
async function readSkillFile(cwd, relPath, nameOverride) {
    const abs = resolve(cwd, relPath);
    const filePath = abs.endsWith(".md") ? abs : join(abs, "SKILL.md");
    let raw;
    try {
        raw = (await readFile(filePath, "utf-8")).trim();
    }
    catch {
        throw new ValidationError(`skillFromFile("${relPath}"): no SKILL.md found at ${filePath}`);
    }
    if (!raw) {
        throw new ValidationError(`skillFromFile("${relPath}"): ${filePath} is empty`);
    }
    const { frontmatter, body } = await parseSkillFrontmatter(raw);
    const name = nameOverride ?? frontmatter?.name ?? basename(abs.replace(/\.md$/, ""));
    return { name, content: body, ...(frontmatter ? { fm: frontmatter } : {}) };
}
/** Resolve one declared skill (inline `defineSkill` or `skillFromFile`). */
async function resolveSkill(skill, cwd) {
    if (skill.path !== undefined) {
        const { name, content, fm } = await readSkillFile(cwd, skill.path, skill.name);
        return skillToConfig({
            name,
            content,
            source: "file",
            description: fm?.description,
            nixPackages: fm?.nixPackages,
        });
    }
    if (!skill.name) {
        throw new ValidationError("defineSkill requires a `name`.");
    }
    return skillToConfig({
        name: skill.name,
        content: skill.content ?? "",
        source: "inline",
        description: skill.description,
        nixPackages: skill.nixPackages,
    });
}
/**
 * Resolve an agent's declared `skills` into `SkillConfig` entries, deduped by
 * name (a duplicate is an authoring error — explicit lists shouldn't collide).
 */
async function resolveAgentSkills(skills, cwd) {
    const resolved = await Promise.all(skills.map((s) => resolveSkill(s, cwd)));
    const byName = new Map();
    for (const skill of resolved) {
        if (byName.has(skill.name)) {
            throw new ValidationError(`duplicate skill "${skill.name}" — skill names must be unique within an agent.`);
        }
        byName.set(skill.name, skill);
    }
    return [...byName.values()];
}
async function readMarkdown(agentDir) {
    const result = {};
    const files = [
        ["identityMd", "IDENTITY.md"],
        ["soulMd", "SOUL.md"],
        ["userMd", "USER.md"],
    ];
    for (const [key, filename] of files) {
        try {
            const content = await readFile(join(agentDir, filename), "utf-8");
            if (content.trim())
                result[key] = content.trim();
        }
        catch {
            // missing file is fine
        }
    }
    return result;
}
function asString(value) {
    return typeof value === "string" ? value : undefined;
}
function schemaFromAuthMethods(authSchema) {
    const kinds = new Set();
    if (!authSchema || typeof authSchema !== "object")
        return kinds;
    const methods = authSchema.methods;
    if (!Array.isArray(methods))
        return kinds;
    for (const method of methods) {
        if (!isRecord(method))
            continue;
        const t = asString(method.type);
        // ConnectorAuthMethod `type` ∈ env_keys | oauth | browser | interactive | none
        if (t === "env_keys")
            kinds.add("env");
        else if (t === "oauth") {
            kinds.add("oauth_app");
            kinds.add("oauth_account");
        }
        else if (t === "browser" || t === "interactive") {
            kinds.add("browser_session");
        }
    }
    return kinds;
}
/**
 * Build per-connector validation schemas from a connector definition. Accepts
 * either a typed `ConnectorDefinition` (from `@lobu/connector-sdk`) or the
 * snake_cased shape from `manage_catalog` connector entries
 * (`options_schema`, `feeds_schema`, `auth_schema`).
 */
export function resolveConnectorSchemas(def) {
    const optionsSchema = ("optionsSchema" in def ? def.optionsSchema : undefined) ??
        ("options_schema" in def ? (def.options_schema ?? undefined) : undefined) ??
        undefined;
    const feedsRaw = ("feeds" in def ? def.feeds : undefined) ??
        ("feeds_schema" in def ? (def.feeds_schema ?? undefined) : undefined) ??
        undefined;
    const authSchema = ("authSchema" in def ? def.authSchema : undefined) ??
        ("auth_schema" in def ? (def.auth_schema ?? undefined) : undefined) ??
        undefined;
    const feedKeys = new Set();
    const feedConfigSchemas = new Map();
    if (feedsRaw && typeof feedsRaw === "object") {
        for (const [feedKey, feedDef] of Object.entries(feedsRaw)) {
            if (!feedDef || typeof feedDef !== "object")
                continue;
            feedKeys.add(feedKey);
            const cfg = feedDef.configSchema;
            if (cfg && typeof cfg === "object") {
                feedConfigSchemas.set(feedKey, cfg);
            }
        }
    }
    return {
        ...(optionsSchema ? { optionsSchema } : {}),
        feedKeys,
        feedConfigSchemas,
        authKinds: schemaFromAuthMethods(authSchema),
    };
}
let sharedAjv = null;
function getAjv() {
    if (!sharedAjv) {
        sharedAjv = new Ajv({ allErrors: true, strict: false });
        addFormats(sharedAjv);
    }
    return sharedAjv;
}
function validateAgainstSchema(schema, value, context) {
    const ajv = getAjv();
    let validate;
    try {
        validate = ajv.compile(schema);
    }
    catch (err) {
        // A malformed connector schema is the connector author's problem, not the
        // operator's — surface it but don't block the whole apply on it.
        throw new ValidationError(`${context}: connector declares an invalid JSON schema — ${err instanceof Error ? err.message : String(err)}`);
    }
    if (!validate(value ?? {})) {
        const detail = (validate.errors ?? [])
            .map((e) => `${e.instancePath || "(root)"} ${e.message ?? ""}`.trim())
            .join("; ");
        throw new ValidationError(`${context}: ${detail || "does not match the connector schema"}`);
    }
}
/**
 * Validate a single connection (+ its feeds) and its referenced auth-profile
 * kinds against a resolved connector schema. Pass `null` to skip schema
 * checks (e.g. a connector that only exists as a local `.ts` not yet
 * compiled by the server) — structural checks have already run at load time.
 */
export function validateConnectionAgainstConnector(connection, authProfiles, schemas) {
    // Validate against `{}` when config is omitted too — that surfaces missing
    // required keys instead of letting an empty config slip through.
    if (schemas?.optionsSchema) {
        // `managedBy` is Lobu metadata (cloud-grant delegation), not a connector
        // option — strip it before validating against the connector's option schema
        // so a strict (additionalProperties:false) schema doesn't reject it.
        const optionConfig = { ...(connection.config ?? {}) };
        delete optionConfig.managedBy;
        validateAgainstSchema(schemas.optionsSchema, optionConfig, `${connection.sourceFile}: connection "${connection.slug}" config`);
    }
    for (const feed of connection.feeds) {
        if (!schemas)
            continue;
        if (schemas.feedKeys.size > 0 && !schemas.feedKeys.has(feed.feedKey)) {
            throw new ValidationError(`${connection.sourceFile}: connection "${connection.slug}" references unknown feed "${feed.feedKey}" for connector "${connection.connector}" (known feeds: ${[...schemas.feedKeys].sort().join(", ") || "(none)"})`);
        }
        const feedSchema = schemas.feedConfigSchemas.get(feed.feedKey);
        if (feedSchema) {
            validateAgainstSchema(feedSchema, feed.config ?? {}, `${connection.sourceFile}: connection "${connection.slug}" feed "${feed.feedKey}" config`);
        }
    }
    // `auth:` must reference a runtime/account profile (never `oauth_app`);
    // `app_auth:` must reference an `oauth_app` profile.
    if (connection.authProfileSlug) {
        const profile = requireAuthProfile(connection, authProfiles, connection.authProfileSlug);
        if (profile.kind === "oauth_app") {
            throw new ValidationError(`${connection.sourceFile}: connection "${connection.slug}" \`auth\` references auth profile "${connection.authProfileSlug}" of kind \`oauth_app\` — use \`app_auth\` for OAuth-app credentials and \`auth\` for the account/runtime profile`);
        }
    }
    if (connection.appAuthProfileSlug) {
        const profile = requireAuthProfile(connection, authProfiles, connection.appAuthProfileSlug);
        if (profile.kind !== "oauth_app") {
            throw new ValidationError(`${connection.sourceFile}: connection "${connection.slug}" \`app_auth\` must reference an \`oauth_app\` auth profile (got \`${profile.kind}\`)`);
        }
    }
}
/** Keys the connector treats as feed-scoped (declared in any feed's `configSchema`). */
export function feedScopedKeys(schemas) {
    const keys = new Set();
    for (const feedSchema of schemas.feedConfigSchemas.values()) {
        const props = feedSchema
            .properties;
        if (props)
            for (const k of Object.keys(props))
                keys.add(k);
    }
    return keys;
}
/**
 * The server stores feed-scoped settings on feeds, not the connection, and
 * REJECTS a connection whose config carries any feed-scoped key (see
 * `splitConfigByFeedScope` in packages/server). `lobu apply` mirrors that split
 * here: any feed-scoped key found in a connection's `config` is demoted to a
 * per-feed default (an explicit feed value wins) and removed from the
 * connection config. `managedBy` is Lobu metadata, never a connector option, so
 * it stays on the connection. Returns the demoted key names so the caller can
 * warn. Mutates `connection` in place so the normalized shape flows into the
 * diff + create/update payloads.
 */
export function normalizeConnectionConfigScope(connection, schemas) {
    if (!schemas || !connection.config)
        return [];
    const scoped = feedScopedKeys(schemas);
    if (scoped.size === 0)
        return [];
    const demoted = {};
    const kept = {};
    for (const [k, v] of Object.entries(connection.config)) {
        if (k !== "managedBy" && scoped.has(k))
            demoted[k] = v;
        else
            kept[k] = v;
    }
    const demotedKeys = Object.keys(demoted);
    if (demotedKeys.length === 0)
        return [];
    connection.feeds = connection.feeds.map((feed) => ({
        ...feed,
        config: { ...demoted, ...(feed.config ?? {}) },
    }));
    connection.config = Object.keys(kept).length > 0 ? kept : undefined;
    return demotedKeys;
}
function requireAuthProfile(connection, authProfiles, slugRef) {
    const profile = authProfiles.get(slugRef);
    if (!profile) {
        throw new ValidationError(`${connection.sourceFile}: connection "${connection.slug}" references auth profile "${slugRef}" which is not declared in any \`type: auth_profile\` doc`);
    }
    if (profile.connector !== connection.connector) {
        throw new ValidationError(`${connection.sourceFile}: connection "${connection.slug}" references auth profile "${slugRef}" for connector "${profile.connector}", but the connection uses connector "${connection.connector}"`);
    }
    return profile;
}
export function validateAuthProfileAgainstConnector(profile, schemas) {
    if (!schemas)
        return;
    if (schemas.authKinds.size > 0 && !schemas.authKinds.has(profile.kind)) {
        throw new ValidationError(`${profile.sourceFile}: auth_profile "${profile.slug}" uses \`kind: ${profile.kind}\`, but connector "${profile.connector}" supports: ${[...schemas.authKinds].sort().join(", ") || "(none)"}`);
    }
}
/**
 * Resolve the project's explicit `connectors: [connectorFromFile(...)]` list
 * into connector definitions to compile + ship. Replaces directory
 * auto-discovery: only listed connectors are uploaded. Paths are relative to
 * the config dir and guarded (no absolute, `..`, or backslash escapes),
 * mirroring `resolveReactionScript`.
 *
 * Each source ships with `key: null`; `apply-cmd` compiles each `sourcePath` on
 * the CLI (where the project's node_modules is available) and the server
 * resolves the real key. We intentionally do NOT compile/instantiate here to
 * resolve the key eagerly — that would force a full esbuild + module load on
 * every load (including `--dry-run`) for no benefit, since the server is the
 * source of truth for the compiled key. A connection that references a
 * connector by a bare *string* key relies on that string matching the file's
 * compiled `definition.key`; reference it by its `defineConnector` class
 * (`connector: myConnector`) to make that match exact.
 */
function resolveConnectorSources(sources, cwd) {
    const baseDir = resolve(cwd);
    const defs = [];
    for (const src of sources) {
        const rel = src.path.trim();
        if (!rel) {
            throw new ValidationError("connectorFromFile() requires a path to a `*.connector.ts` file");
        }
        if (rel.startsWith("/") || rel.includes("\\")) {
            throw new ValidationError(`connectorFromFile(${JSON.stringify(rel)}) must be a relative POSIX path (./foo.connector.ts) — absolute paths and backslashes are not allowed`);
        }
        if (rel.split("/").some((seg) => seg === "..")) {
            throw new ValidationError(`connectorFromFile(${JSON.stringify(rel)}) must not contain \`..\` segments — keep the connector under the config directory`);
        }
        if (!rel.endsWith(".ts")) {
            throw new ValidationError(`connectorFromFile(${JSON.stringify(rel)}) must point at a \`.ts\` file`);
        }
        const abs = resolve(baseDir, rel);
        const relPath = relative(baseDir, abs);
        if (relPath === ".." ||
            relPath.startsWith(`..${sep}`) ||
            isAbsolute(relPath)) {
            throw new ValidationError(`connectorFromFile(${JSON.stringify(rel)}) resolves outside the config directory (${abs})`);
        }
        let sourceCode;
        try {
            sourceCode = readFileSync(abs, "utf-8");
        }
        catch {
            throw new ValidationError(`connectorFromFile(${JSON.stringify(rel)}) does not exist (resolved to ${abs})`);
        }
        defs.push({
            key: null,
            sourcePath: abs,
            sourceCode,
            sourceFile: rel.replace(/^\.\//, ""),
        });
    }
    return defs.sort((a, b) => a.sourceFile.localeCompare(b.sourceFile));
}
const REACTION_SCRIPT_MAX_BYTES = 256 * 1024;
/**
 * Resolve + read a watcher reaction script (`reactionFromFile(path)`): relative
 * POSIX path under the config directory, ends in `.ts`, no `..` / absolute /
 * backslash segments, ≤256KB. Ships RAW source — the server compiles it on
 * receipt via `set_reaction_script`.
 */
function resolveReactionScript(cwd, watcherSlug, rel) {
    const trimmed = rel.trim();
    if (!trimmed) {
        throw new ValidationError(`watcher "${watcherSlug}" \`reaction\` must be a path to a sibling .ts file (e.g. \`reaction: reactionFromFile("./reactions/foo.reaction.ts")\`)`);
    }
    if (trimmed.startsWith("/") || trimmed.includes("\\")) {
        throw new ValidationError(`watcher "${watcherSlug}" \`reaction\` must be a relative POSIX path (./foo.reaction.ts) — absolute paths and backslashes are not allowed`);
    }
    if (trimmed.split("/").some((seg) => seg === "..")) {
        throw new ValidationError(`watcher "${watcherSlug}" \`reaction\` must not contain \`..\` segments — keep the script under the config directory`);
    }
    if (!trimmed.endsWith(".ts")) {
        throw new ValidationError(`watcher "${watcherSlug}" \`reaction\` must end in \`.ts\` (got ${JSON.stringify(trimmed)})`);
    }
    const baseDir = resolve(cwd);
    const abs = resolve(baseDir, trimmed);
    // Containment check via `relative` (cross-platform): a hard-coded `${baseDir}/`
    // prefix uses POSIX `/` and wrongly rejects every path on Windows (backslash
    // separators). `rel` is "" when abs === baseDir, starts with ".." when abs
    // escapes baseDir, and is absolute when on a different drive.
    const relPath = relative(baseDir, abs);
    if (relPath === ".." ||
        relPath.startsWith(`..${sep}`) ||
        isAbsolute(relPath)) {
        throw new ValidationError(`watcher "${watcherSlug}" \`reaction\` resolves outside the config directory (${abs})`);
    }
    let sourceCode;
    try {
        sourceCode = readFileSync(abs, "utf-8");
    }
    catch {
        throw new ValidationError(`watcher "${watcherSlug}" \`reaction\` ${trimmed} does not exist (resolved to ${abs})`);
    }
    if (Buffer.byteLength(sourceCode, "utf8") > REACTION_SCRIPT_MAX_BYTES) {
        throw new ValidationError(`watcher "${watcherSlug}" \`reaction\` exceeds the ${REACTION_SCRIPT_MAX_BYTES}-byte cap — reaction scripts should be a few hundred lines, not a vendored library`);
    }
    return { sourcePath: abs, sourceCode };
}
/**
 * Import a `lobu.config.ts` and return its `defineConfig` default export (the
 * SDK {@link Project}). Shared by {@link loadDesiredStateFromConfig} (apply) and
 * the commands that read the authored config directly (`lobu run` preview
 * registration, `lobu doctor`, `lobu chat`, `lobu validate`, `lobu memory seed`).
 *
 * Uses jiti — the same runtime TypeScript loader Next.js/Nuxt use for their
 * `*.config.ts` — which transpiles on import and resolves the config's imports
 * (`@lobu/cli/config`, `@lobu/connector-sdk`, relative reaction/connector files)
 * from the project. No bundling, no temp file. The dynamic `import("jiti")` is
 * lazy + allow-listed (AGENTS.md).
 */
export async function loadProjectConfig(cwd) {
    const configPath = resolve(cwd, "lobu.config.ts");
    if (!existsSync(configPath)) {
        throw new ValidationError(`No lobu.config.ts found in ${cwd}`);
    }
    const { createJiti } = await import("jiti");
    // Resolve the SDK imports the config will reference (`@lobu/cli/config`,
    // `@lobu/connector-sdk`) against the running CLI's own copies — not the
    // project's `node_modules`. This lets a freshly-scaffolded project
    // `validate`/`run` with zero install: the user has the CLI, that's enough.
    // Falls through silently if a symbol can't be resolved from here (the
    // catch-all error below still surfaces real problems).
    const alias = {};
    for (const spec of ["@lobu/cli/config", "@lobu/connector-sdk"]) {
        try {
            alias[spec] = fileURLToPath(import.meta.resolve(spec));
        }
        catch {
            // CLI dist may not expose this symbol in some packaging layouts; skip.
        }
    }
    const jiti = createJiti(pathToFileURL(configPath).href, Object.keys(alias).length > 0 ? { alias } : undefined);
    let project;
    try {
        project = await jiti.import(configPath, { default: true });
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        throw new ValidationError(`Failed to load lobu.config.ts — ${message}`);
    }
    if (!project ||
        typeof project !== "object" ||
        project.kind !== "project") {
        throw new ValidationError("lobu.config.ts must `export default defineConfig({ ... })`");
    }
    return { project: project, configPath };
}
/**
 * Load desired state from a TypeScript entrypoint (`lobu.config.ts`): import the
 * `defineConfig()` project, map it to `DesiredState`, then attach the
 * file-based artifacts (agent-dir markdown + skills, watcher reaction scripts,
 * local connector source).
 */
export async function loadDesiredStateFromConfig(opts) {
    const env = opts.env ?? process.env;
    const { project: typedProject, configPath } = await loadProjectConfig(opts.cwd);
    const state = mapProjectToDesiredState(typedProject, env, opts.only);
    // Agent artifacts: SOUL/IDENTITY/USER.md (convention, from the agent dir) +
    // skills (explicit `defineAgent({ skills })`, inline or `skillFromFile`). The
    // mapper stays pure (no file IO); we read the files here and merge them in.
    await Promise.all(typedProject.agents.map(async (agent, i) => {
        const settings = state.agents[i]?.settings;
        if (!settings)
            return;
        const agentDir = resolve(opts.cwd, agent.dir ?? join("agents", agent.id));
        const markdown = await readMarkdown(agentDir);
        const localSkills = await resolveAgentSkills(agent.skills ?? [], opts.cwd);
        mergeAgentDirArtifacts(settings, markdown, localSkills);
    }));
    // Watcher reaction scripts: a sibling `.ts` file referenced by path. The
    // mapper stays pure; resolve + read the source here (raw, server compiles
    // it) and attach it. state.watchers[i] aligns with typedProject.watchers[i]
    // (the mapper maps them in order).
    (typedProject.watchers ?? []).forEach((watcher, i) => {
        // Gate on absence, not truthiness — a present-but-empty
        // `reactionFromFile("")` must reach the validator (which rejects it),
        // matching parseWatcher.
        if (watcher.reaction === undefined)
            return;
        const dw = state.watchers[i];
        if (!dw)
            return;
        // `reaction` is typed ReactionSource, but jiti evaluates the config without
        // typechecking, so a stale `reaction: "./x.reaction.ts"` string slips
        // through and would read `.path` as undefined. Reject it with a clear
        // message instead of a downstream TypeError. (An empty `reactionFromFile("")`
        // keeps a string path and still reaches the validator, which rejects it.)
        const reactionPath = watcher.reaction.path;
        if (typeof reactionPath !== "string") {
            throw new Error(`Watcher "${watcher.slug}": set reaction with reactionFromFile("./x.reaction.ts"), not a bare string path.`);
        }
        dw.reactionScript = resolveReactionScript(opts.cwd, watcher.slug, reactionPath);
    });
    // `--only agents|memory` skips connectors (matching the mapper), so don't
    // ship local connector source for those runs either.
    if (!opts.only) {
        state.connectors.definitions = resolveConnectorSources(typedProject.connectors ?? [], opts.cwd);
    }
    // Surface load-time warnings to `lobu apply` (which prints them). #1010's
    // "ignored connectors because [memory] is disabled" case is obsolete here —
    // lobu.config.ts has no `[memory].enabled` gate, so connectors always load —
    // but the channel is kept so future TS-loader warnings flow to the operator.
    const warnings = [];
    return { state, configPath, warnings };
}
//# sourceMappingURL=desired-state.js.map