import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { z } from "zod";
import { AgentProviderRuntimeSettingsMapSchema } from "./agent/provider-launch-config.js";
import { AgentProviderSchema } from "./agent/provider-manifest.js";
const LocalAgentModelSchema = z
    .object({
    id: z.string().min(1),
    label: z.string().min(1),
    description: z.string().optional(),
    isDefault: z.boolean().optional(),
})
    .strict();
export const LocalAgentModelsSchema = z.record(AgentProviderSchema, z.array(LocalAgentModelSchema));
const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
const LogFormatSchema = z.enum(["pretty", "json"]);
const LogConfigSchema = z
    .object({
    // Legacy global log settings (kept for backwards compatibility).
    level: LogLevelSchema.optional(),
    format: LogFormatSchema.optional(),
    console: z
        .object({
        level: LogLevelSchema.optional(),
        format: LogFormatSchema.optional(),
    })
        .strict()
        .optional(),
    file: z
        .object({
        level: LogLevelSchema.optional(),
        path: z.string().min(1).optional(),
        rotate: z
            .object({
            maxSize: z.string().min(1).optional(),
            maxFiles: z.number().int().positive().optional(),
        })
            .strict()
            .optional(),
    })
        .strict()
        .optional(),
})
    .strict();
const ProviderCredentialsSchema = z
    .object({
    apiKey: z.string().min(1).optional(),
    baseURL: z.string().min(1).optional(),
})
    .strict();
const LocalSpeechProviderSchema = z
    .object({
    modelsDir: z.string().min(1).optional(),
})
    .strict();
const ProvidersSchema = z
    .object({
    openai: ProviderCredentialsSchema.optional(),
    local: LocalSpeechProviderSchema.optional(),
})
    .strict();
const SpeechProviderIdSchema = z
    .string()
    .trim()
    .toLowerCase()
    .pipe(z.enum(["openai", "local"]));
const FeatureDictationSchema = z
    .object({
    enabled: z.boolean().optional(),
    stt: z
        .object({
        provider: SpeechProviderIdSchema.optional(),
        model: z.string().min(1).optional(),
        confidenceThreshold: z.number().optional(),
        // ISO-639-1 language hint forwarded to the STT provider (e.g. "zh", "en", "ja").
        // Improves transcription accuracy by skipping language auto-detection.
        language: z.string().min(1).optional(),
    })
        .strict()
        .optional(),
    // Dictation-only OpenAI override. Lets the user point dictation at an
    // OpenAI-compatible endpoint (e.g. Groq) without affecting Voice Mode.
    // Voice Mode credentials are resolved from env OPENAI_API_KEY first,
    // then from the top-level providers.openai.apiKey as a fallback for
    // self-hosted users; never from this dictation-scoped override.
    openai: z
        .object({
        apiKey: z.string().min(1).optional(),
        baseURL: z.string().min(1).optional(),
    })
        .strict()
        .optional(),
})
    .strict();
const FeatureVoiceModeSchema = z
    .object({
    enabled: z.boolean().optional(),
    llm: z
        .object({
        provider: z.string().optional(),
        model: z.string().min(1).optional(),
    })
        .strict()
        .optional(),
    stt: z
        .object({
        provider: SpeechProviderIdSchema.optional(),
        model: z.string().min(1).optional(),
    })
        .strict()
        .optional(),
    turnDetection: z
        .object({
        provider: SpeechProviderIdSchema.optional(),
    })
        .strict()
        .optional(),
    tts: z
        .object({
        provider: SpeechProviderIdSchema.optional(),
        model: z.string().min(1).optional(),
        voice: z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]).optional(),
        speakerId: z.number().int().optional(),
        speed: z.number().optional(),
    })
        .strict()
        .optional(),
})
    .strict();
export const PersistedConfigSchema = z
    .object({
    // v1 schema marker
    version: z.literal(1).optional(),
    // v1 config layout
    daemon: z
        .object({
        listen: z.string().optional(),
        allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(),
        mcp: z
            .object({
            enabled: z.boolean().optional(),
            injectIntoAgents: z.boolean().optional(),
        })
            .passthrough()
            .optional(),
        cors: z
            .object({
            allowedOrigins: z.array(z.string()).optional(),
        })
            .strict()
            .optional(),
        relay: z
            .object({
            enabled: z.boolean().optional(),
            endpoint: z.string().optional(),
            publicEndpoint: z.string().optional(),
        })
            .strict()
            .optional(),
    })
        .strict()
        .optional(),
    app: z
        .object({
        baseUrl: z.string().optional(),
    })
        .strict()
        .optional(),
    providers: ProvidersSchema.optional(),
    agents: z
        .object({
        providers: AgentProviderRuntimeSettingsMapSchema.optional(),
        // `models` validated lazily in loadPersistedConfig so a malformed local
        // override silently falls back to the gateway list instead of crashing
        // daemon startup. See seawork-models.ts:loadLocalModelOverrides.
        models: z.unknown().optional(),
    })
        .strict()
        .optional(),
    features: z
        .object({
        dictation: FeatureDictationSchema.optional(),
        voiceMode: FeatureVoiceModeSchema.optional(),
    })
        .strict()
        .optional(),
    log: LogConfigSchema.optional(),
})
    .strict();
const CONFIG_FILENAME = "config.json";
const KNOWN_TOP_LEVEL_KEYS = new Set([
    "version",
    "daemon",
    "app",
    "providers",
    "agents",
    "features",
    "log",
]);
// Defaults never include `agents.models`, so the `unknown` → typed mismatch
// between `PersistedConfigRaw` and `PersistedConfig` is safe to cast away.
const DEFAULT_PERSISTED_CONFIG = PersistedConfigSchema.parse({
    version: 1,
    daemon: {
        listen: "127.0.0.1:6767",
        cors: {
            allowedOrigins: ["https://app.seawork.ai"],
        },
        relay: {
            enabled: true,
        },
    },
    app: {
        baseUrl: "https://app.seawork.ai",
    },
});
function getConfigPath(seaworkHome) {
    return path.join(seaworkHome, CONFIG_FILENAME);
}
function getLogger(logger) {
    return logger?.child({ module: "config" });
}
// Forward-compat: the daemon may be reading a config.json written by a newer
// daemon (rollback / cross-device sync / future field). Strip anything the
// strict schema doesn't recognize *before* parsing so unknown keys can't kill
// startup. Known sub-trees (`daemon`, `providers`, `agents`, `features`) get
// the same treatment one level deep — the namespaces most likely to grow new
// fields. Stripped paths are returned for caller to surface as a warning.
//
// Leaves (`log.console.*`, `daemon.cors.*`, etc.) remain strict-validated so
// typos are still caught, just not at the top-level entry points.
const KNOWN_SUB_KEYS = {
    daemon: new Set(["listen", "allowedHosts", "mcp", "cors", "relay"]),
    providers: new Set(["openai", "local"]),
    agents: new Set(["providers", "models"]),
    features: new Set(["dictation", "voiceMode"]),
};
function isPlainRecord(value) {
    return !!value && typeof value === "object" && !Array.isArray(value);
}
function stripUnknownKeysAndCollect(parsed) {
    if (!isPlainRecord(parsed)) {
        return { cleaned: parsed, stripped: [], unknownTree: null };
    }
    const stripped = [];
    const root = {};
    const unknownTree = {};
    for (const [key, value] of Object.entries(parsed)) {
        if (!KNOWN_TOP_LEVEL_KEYS.has(key)) {
            stripped.push(key);
            unknownTree[key] = value;
            continue;
        }
        const subKeys = KNOWN_SUB_KEYS[key];
        if (subKeys && isPlainRecord(value)) {
            const subRoot = {};
            const subUnknown = {};
            for (const [subKey, subValue] of Object.entries(value)) {
                if (subKeys.has(subKey)) {
                    subRoot[subKey] = subValue;
                }
                else {
                    stripped.push(`${key}.${subKey}`);
                    subUnknown[subKey] = subValue;
                }
            }
            root[key] = subRoot;
            if (Object.keys(subUnknown).length > 0)
                unknownTree[key] = subUnknown;
        }
        else {
            root[key] = value;
        }
    }
    return {
        cleaned: root,
        stripped,
        unknownTree: Object.keys(unknownTree).length > 0 ? unknownTree : null,
    };
}
// Deep-merge raw unknown branches back into the to-be-written object so newer-
// daemon-only fields survive a load/save round-trip in an older daemon. The
// known half of the tree always wins — this only fills in keys the writer
// didn't touch.
function mergeUnknownBack(target, unknown) {
    for (const [key, value] of Object.entries(unknown)) {
        const existing = target[key];
        if (isPlainRecord(existing) && isPlainRecord(value)) {
            mergeUnknownBack(existing, value);
        }
        else if (existing === undefined) {
            target[key] = value;
        }
        // else: writer set this key explicitly; respect their value.
    }
}
// Best-effort recovery of unknown future fields from disk so they survive a
// save() invoked with an in-memory config that doesn't know about them. Reads
// the current file, returns the unknown subtree, and swallows all errors —
// save's job is to write, not to fail because the prior file is corrupt.
function readUnknownTreeFromDisk(configPath) {
    try {
        if (!existsSync(configPath))
            return null;
        const raw = readFileSync(configPath, "utf-8");
        const parsed = JSON.parse(raw);
        const migrated = stripDeprecatedLocalSpeechConfigFields(parsed);
        return stripUnknownKeysAndCollect(migrated).unknownTree;
    }
    catch {
        return null;
    }
}
function stripDeprecatedLocalSpeechConfigFields(parsed) {
    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
        return parsed;
    }
    const root = { ...parsed };
    // Strip top-level deprecated fields from older config versions
    delete root.anthropicAuthToken;
    const providers = root.providers;
    if (!providers || typeof providers !== "object" || Array.isArray(providers)) {
        return root;
    }
    const providersRecord = { ...providers };
    const local = providersRecord.local;
    if (!local || typeof local !== "object" || Array.isArray(local)) {
        root.providers = providersRecord;
        return root;
    }
    const localRecord = { ...local };
    if ("autoDownload" in localRecord) {
        delete localRecord.autoDownload;
    }
    providersRecord.local = localRecord;
    root.providers = providersRecord;
    return root;
}
/**
 * Pure read + validate of `~/.seawork/config.json`. Returns null if the file
 * does not exist; throws on read errors, malformed JSON, or strict-schema
 * violations. No disk side effects — callers that want to materialize a
 * default file when missing should use `loadPersistedConfig`.
 */
function readPersistedConfig(seaworkHome, logger) {
    const log = getLogger(logger);
    const configPath = getConfigPath(seaworkHome);
    if (!existsSync(configPath))
        return null;
    let raw;
    try {
        raw = readFileSync(configPath, "utf-8");
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        throw new Error(`[Config] Failed to read ${configPath}: ${message}`);
    }
    let parsed;
    try {
        parsed = JSON.parse(raw);
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`);
    }
    const migrated = stripDeprecatedLocalSpeechConfigFields(parsed);
    const { cleaned, stripped } = stripUnknownKeysAndCollect(migrated);
    if (stripped.length > 0) {
        log?.warn(`Ignoring unknown keys in ${configPath}: ${stripped.join(", ")}. ` +
            `These are likely from a newer daemon; startup will continue.`);
    }
    const result = PersistedConfigSchema.safeParse(cleaned);
    if (!result.success) {
        const issues = result.error.issues
            .map((i) => `  - ${i.path.join(".")}: ${i.message}`)
            .join("\n");
        throw new Error(`[Config] Invalid config in ${configPath}:\n${issues}`);
    }
    const config = result.data;
    // Lazily validate agents.models so a malformed user-supplied override gets
    // dropped (with a warning) instead of crashing daemon startup. The strict
    // top-level schema gives clear diagnostics for daemon-controlled fields,
    // but local model lists are user-edited and must degrade gracefully.
    const rawModels = config.agents?.models;
    if (rawModels !== undefined) {
        const modelsResult = LocalAgentModelsSchema.safeParse(rawModels);
        if (modelsResult.success) {
            config.agents = { ...config.agents, models: modelsResult.data };
        }
        else {
            const issues = modelsResult.error.issues
                .map((i) => `  - agents.models.${i.path.join(".")}: ${i.message}`)
                .join("\n");
            log?.warn(`Ignoring malformed agents.models in ${configPath}; falling back to gateway list:\n${issues}`);
            if (config.agents) {
                const { providers } = config.agents;
                config.agents = providers !== undefined ? { providers } : undefined;
            }
        }
    }
    log?.info(`Loaded from ${configPath}`);
    return config;
}
export function loadPersistedConfig(seaworkHome, logger) {
    const log = getLogger(logger);
    const configPath = getConfigPath(seaworkHome);
    if (!existsSync(configPath)) {
        try {
            mkdirSync(path.dirname(configPath), { recursive: true });
            writeFileSync(configPath, JSON.stringify(DEFAULT_PERSISTED_CONFIG, null, 2) + "\n");
            log?.info(`Initialized config file at ${configPath}`);
        }
        catch (err) {
            const message = err instanceof Error ? err.message : String(err);
            throw new Error(`[Config] Failed to initialize ${configPath}: ${message}`);
        }
    }
    // Initialize-if-missing above guarantees the file now exists, so the read
    // path will return a non-null config.
    const config = readPersistedConfig(seaworkHome, logger);
    if (!config) {
        throw new Error(`[Config] Expected ${configPath} to exist after initialization`);
    }
    return config;
}
/**
 * Best-effort, side-effect-free read of the persisted config for consumers
 * that should never crash on bad disk state and never write to disk
 * (provider model overrides, dictation language hint, …). Returns null if
 * the file is missing or fails to load. Going through this single helper
 * keeps the “strict schema + silent fallback” contract in one place — a
 * hand-rolled `JSON.parse(config.json)` would otherwise drift (e.g. accept
 * entries the strict schema would reject).
 */
export function tryLoadPersistedConfig(seaworkHome) {
    try {
        return readPersistedConfig(seaworkHome);
    }
    catch {
        return null;
    }
}
export function savePersistedConfig(seaworkHome, config, logger) {
    const log = getLogger(logger);
    const configPath = getConfigPath(seaworkHome);
    const result = PersistedConfigSchema.safeParse(config);
    if (!result.success) {
        const issues = result.error.issues
            .map((i) => `  - ${i.path.join(".")}: ${i.message}`)
            .join("\n");
        throw new Error(`[Config] Invalid config to save:\n${issues}`);
    }
    if (result.data.agents?.models !== undefined) {
        const modelsResult = LocalAgentModelsSchema.safeParse(result.data.agents.models);
        if (!modelsResult.success) {
            const issues = modelsResult.error.issues
                .map((i) => `  - agents.models.${i.path.join(".")}: ${i.message}`)
                .join("\n");
            throw new Error(`[Config] Invalid agents.models to save:\n${issues}`);
        }
        result.data.agents = { ...result.data.agents, models: modelsResult.data };
    }
    // Preserve any forward-compat fields already on disk (written by a newer
    // daemon) so that this older daemon's save doesn't silently delete them.
    // The in-memory config has no knowledge of these keys, so we re-read them
    // from disk and merge them under untouched paths only.
    //
    // Read-then-write is not atomic; a concurrent writer between these calls
    // could still lose data. Acceptable here because the daemon holds a pid
    // lock (pid-lock.ts) so only one process owns ~/.seawork/config.json at a
    // time. CLI mutations route through the same daemon.
    const toWrite = result.data;
    const existingUnknown = readUnknownTreeFromDisk(configPath);
    if (existingUnknown) {
        mergeUnknownBack(toWrite, existingUnknown);
    }
    try {
        writeFileSync(configPath, JSON.stringify(toWrite, null, 2) + "\n");
        log?.info(`Saved to ${configPath}`);
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        throw new Error(`[Config] Failed to write ${configPath}: ${message}`);
    }
}
//# sourceMappingURL=persisted-config.js.map