import * as Lark from '@larksuiteoapi/node-sdk';
import { readFileSync, existsSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
import { homedir } from 'node:os';
import { logger } from './utils/logger.js';
import { isLocale, setBotLookup } from './i18n/index.js';
import { sdkDomain, normalizeBrand } from './im/lark/lark-hosts.js';
function normalizeChatReplyModeConfig(raw) {
    if (typeof raw !== 'string')
        return undefined;
    const v = raw.trim().toLowerCase();
    if (v === 'chat')
        return 'chat';
    if (v === 'new-topic' || v === 'newtopic' || v === 'thread')
        return 'new-topic';
    if (v === 'topic' || v === 'shared' || v === 'share' || v === 'alias' || v === 'topic-alias' || v === 'topic_alias')
        return 'shared';
    return undefined;
}
const bots = new Map();
// Wire the i18n lookup so `localeForBot()` can resolve per-bot locale without
// a hard import cycle between `i18n` and `bot-registry`.
setBotLookup((id) => bots.get(id));
/** Path of the bot config file we loaded (so `/oncall` can persist bindings back). */
let loadedConfigPath;
export function getLoadedConfigPath() {
    return loadedConfigPath;
}
// Route Lark SDK output through our logger so it inherits the same sink
// rules (info/debug → daemon.log in daemon mode, → stderr in CLI mode,
// dropped when CLI is silent). The default SDK logger calls console.log,
// which would corrupt CLI stdout consumers.
//
// Volume control: the SDK is chatty at info/debug ("client ready", request
// traces, etc.); without DEBUG=1 those become no-ops in the CLI path and
// stay in daemon.log on the daemon path — pm2's error.log no longer sees
// "[lark:info] client ready" floods.
function safeStringify(v) {
    if (typeof v === 'string')
        return v;
    try {
        return JSON.stringify(v);
    }
    catch {
        return String(v);
    }
}
const fmtLark = (msg) => msg.map(safeStringify).join(' ');
const larkLogger = {
    error: (...msg) => logger.error(`[lark] ${fmtLark(msg)}`),
    warn: (...msg) => logger.warn(`[lark] ${fmtLark(msg)}`),
    info: (...msg) => logger.info(`[lark] ${fmtLark(msg)}`),
    debug: (...msg) => logger.debug(`[lark] ${fmtLark(msg)}`),
    trace: (..._msg) => { },
};
export function registerBot(cfg) {
    const client = new Lark.Client({
        appId: cfg.larkAppId,
        appSecret: cfg.larkAppSecret,
        // brand → SDK domain。缺省走 feishu，国际版租户走 larksuite.com。
        // 这一行同时修好了所有经由 SDK 的调用（发消息 / 文件 / contact 等）。
        domain: sdkDomain(normalizeBrand(cfg.brand)),
        logger: larkLogger,
    });
    const state = {
        config: cfg,
        client,
        resolvedAllowedUsers: [...(cfg.allowedUsers ?? [])],
        rawAllowedUserResolution: new Map(),
    };
    bots.set(cfg.larkAppId, state);
    return state;
}
export function getBot(larkAppId) {
    const state = bots.get(larkAppId);
    if (!state) {
        throw new Error(`Bot not registered: ${larkAppId}`);
    }
    return state;
}
export function getBotClient(larkAppId) {
    return getBot(larkAppId).client;
}
/** Owner = bot 首个已授权 open_id，与「缺权限警告私信对象」同口径（见 admin 解析）。 */
export function getOwnerOpenId(larkAppId) {
    return bots.get(larkAppId)?.resolvedAllowedUsers.find(u => u.startsWith('ou_'));
}
/** Bot 自身的 open_id（用于在 mention 解析时排除自己）。 */
export function getBotOpenId(larkAppId) {
    return bots.get(larkAppId)?.botOpenId;
}
/**
 * 安全地按 appId 取 brand。未注册（如跨进程 dashboard 聚合到别的 daemon 的
 * 会话）→ 归一为 'feishu'。仅用于派生 applink 等 host，缺省 feishu 安全。
 */
export function getBotBrand(larkAppId) {
    return normalizeBrand(larkAppId ? bots.get(larkAppId)?.config.brand : undefined);
}
export function getAllBots() {
    return Array.from(bots.values());
}
/** Lookup the oncall binding for a given bot+chat, if any. */
export function findOncallChat(larkAppId, chatId) {
    const bot = bots.get(larkAppId);
    return bot?.config.oncallChats?.find(c => c.chatId === chatId);
}
// Cross-bot oncall chat discovery — cached by config-file mtime.
//
// /oncall bind is per-bot for talk authorization: receiving-bot gates must use
// findOncallChat(larkAppId, chatId). This cross-bot lookup remains for paths
// that need deployment-wide discovery/inheritance, such as pinned working-dir
// resolution and default-oncall checks.
//
// Multi-daemon deployments run one bot per process, so the in-memory `bots`
// map only sees this daemon's own bot — sibling bots' bindings live only on
// disk in the shared bots.json. Re-read that file lazily, keyed by mtime,
// so the hot path is a single stat() once the cache is warm.
let oncallChatCache = null;
export function findOncallChatForAnyBot(chatId) {
    // Fast path: this daemon's own bot(s). Covers single-daemon setups and any
    // case where the receiving bot itself is bound.
    for (const bot of bots.values()) {
        const entry = bot.config.oncallChats?.find(c => c.chatId === chatId);
        if (entry)
            return entry;
    }
    // Slow path: scan the shared bots.json for sibling bots' bindings.
    const path = loadedConfigPath;
    if (!path)
        return undefined;
    try {
        const stat = statSync(path);
        if (!oncallChatCache || oncallChatCache.mtimeMs !== stat.mtimeMs) {
            const raw = JSON.parse(readFileSync(path, 'utf-8'));
            const chats = new Map();
            if (Array.isArray(raw)) {
                for (const entry of raw) {
                    if (!Array.isArray(entry?.oncallChats))
                        continue;
                    for (const c of entry.oncallChats) {
                        if (c && typeof c.chatId === 'string' && typeof c.workingDir === 'string') {
                            chats.set(c.chatId, { chatId: c.chatId, workingDir: c.workingDir });
                        }
                    }
                }
            }
            oncallChatCache = { mtimeMs: stat.mtimeMs, chats };
        }
        return oncallChatCache.chats.get(chatId);
    }
    catch {
        return undefined;
    }
}
export function isChatOncallBoundForAnyBot(chatId) {
    return !!findOncallChatForAnyBot(chatId);
}
// Per-bot brand label, mtime-cached for the disk fallback. Keyed by larkAppId →
// the configured value (undefined when the bot has no brandLabel key).
let brandLabelCache = null;
/** Resolve the bots.json path the same way loadBotConfigs does, without
 *  requiring the registry to have been loaded (works in one-shot CLI processes
 *  like `botmux send`). Returns null when no config file exists. */
function botsConfigDiskPath() {
    const env = process.env.BOTS_CONFIG;
    if (env) {
        const r = resolve(env);
        return existsSync(r) ? r : null;
    }
    const d = resolve(homedir(), '.botmux', 'bots.json');
    return existsSync(d) ? d : null;
}
/**
 * The configured brand label for a bot, or `undefined` when unset (`''` = off
 * is preserved). Prefers the in-memory registry (daemon hot path); falls back
 * to a mtime-cached read of bots.json so the CLI process — which never loads
 * the registry — still resolves the sending bot's brand. Callers feed the
 * result into {@link brandFooterSegment} for the unset→default / ''→off rule.
 */
export function resolveBrandLabel(larkAppId) {
    const inMem = bots.get(larkAppId);
    if (inMem)
        return inMem.config.brandLabel;
    const path = loadedConfigPath ?? botsConfigDiskPath();
    if (!path)
        return undefined;
    try {
        const stat = statSync(path);
        if (!brandLabelCache || brandLabelCache.mtimeMs !== stat.mtimeMs) {
            const raw = JSON.parse(readFileSync(path, 'utf-8'));
            const map = new Map();
            if (Array.isArray(raw)) {
                for (const e of raw) {
                    if (e && typeof e.larkAppId === 'string') {
                        map.set(e.larkAppId, typeof e.brandLabel === 'string' ? e.brandLabel : undefined);
                    }
                }
            }
            brandLabelCache = { mtimeMs: stat.mtimeMs, map };
        }
        return brandLabelCache.map.get(larkAppId);
    }
    catch {
        return undefined;
    }
}
/**
 * Load bot configurations from one of (in priority order):
 * 1. BOTS_CONFIG env var — path to a JSON file
 * 2. ~/.botmux/bots.json — default config path
 */
export function loadBotConfigs() {
    // 1. BOTS_CONFIG env var
    const botsConfigPath = process.env.BOTS_CONFIG;
    if (botsConfigPath) {
        const resolved = resolve(botsConfigPath);
        if (!existsSync(resolved)) {
            throw new Error(`BOTS_CONFIG file not found: ${resolved}`);
        }
        loadedConfigPath = resolved;
        return parseBotConfigFile(resolved);
    }
    // 2. ~/.botmux/bots.json
    const defaultPath = resolve(homedir(), '.botmux', 'bots.json');
    if (existsSync(defaultPath)) {
        loadedConfigPath = defaultPath;
        return parseBotConfigFile(defaultPath);
    }
    throw new Error('No bot configuration found. Set BOTS_CONFIG or create ~/.botmux/bots.json.\nSee README for config format.');
}
function parseBotConfigFile(filePath) {
    const raw = readFileSync(filePath, 'utf-8');
    try {
        return parseBotConfigsFromText(raw);
    }
    catch (err) {
        // Preserve the file path in JSON-parse / shape errors for easier debugging.
        throw new Error(`${err?.message ?? err} (file: ${filePath})`);
    }
}
/** Pure parser: bots.json text → BotConfig[]. Exported for testing & reuse. */
export function parseBotConfigsFromText(jsonText) {
    let parsed;
    try {
        parsed = JSON.parse(jsonText);
    }
    catch {
        throw new Error(`Invalid JSON in bot config file`);
    }
    if (!Array.isArray(parsed)) {
        throw new Error(`Bot config file must contain a JSON array`);
    }
    const configs = [];
    for (let i = 0; i < parsed.length; i++) {
        const entry = parsed[i];
        if (!entry.larkAppId || typeof entry.larkAppId !== 'string') {
            throw new Error(`Bot config [${i}]: larkAppId is required and must be a string`);
        }
        if (!entry.larkAppSecret || typeof entry.larkAppSecret !== 'string') {
            throw new Error(`Bot config [${i}]: larkAppSecret is required and must be a string`);
        }
        // Parse workingDirs from comma-separated workingDir if workingDirs not explicitly set
        let workingDirs = entry.workingDirs;
        if (!workingDirs && entry.workingDir) {
            workingDirs = String(entry.workingDir).split(',').map((s) => s.trim()).filter(Boolean);
        }
        let oncallChats;
        if (Array.isArray(entry.oncallChats)) {
            oncallChats = entry.oncallChats
                .filter((c) => c && typeof c.chatId === 'string' && typeof c.workingDir === 'string')
                .map((c) => ({
                chatId: c.chatId,
                workingDir: c.workingDir,
            }));
        }
        let allowedChatGroups;
        if (Array.isArray(entry.allowedChatGroups)) {
            allowedChatGroups = entry.allowedChatGroups
                .filter((x) => typeof x === 'string' && x.trim().length > 0)
                .map((x) => x.trim());
        }
        // defaultOncall: per-bot default for auto-binding new group chats.
        // Tolerate missing fields: an entry with `enabled:true` but no workingDir
        // is treated as disabled (dashboard PUT enforces workingDir on save, but
        // hand-edited bots.json could be inconsistent — never crash on parse).
        let defaultOncall;
        const rawDefault = entry.defaultOncall;
        if (rawDefault && typeof rawDefault === 'object') {
            const enabled = rawDefault.enabled === true;
            const workingDir = typeof rawDefault.workingDir === 'string' ? rawDefault.workingDir : '';
            const since = typeof rawDefault.since === 'number' && Number.isFinite(rawDefault.since)
                ? rawDefault.since
                : 0;
            defaultOncall = { enabled: enabled && !!workingDir, workingDir, since };
        }
        let defaultOncallAutoboundChats;
        if (Array.isArray(entry.defaultOncallAutoboundChats)) {
            defaultOncallAutoboundChats = entry.defaultOncallAutoboundChats
                .filter((x) => typeof x === 'string');
        }
        // chatReplyModes：只保留每群显式设置，非法值丢弃。三态 chat｜new-topic｜
        // shared 都保留解析；写入路径会删除「与 per-bot 默认相同」的条目以保持
        // bots.json 干净（见 chat-reply-mode-store.setChatReplyMode）。
        let chatReplyModes;
        if (entry.chatReplyModes && typeof entry.chatReplyModes === 'object' && !Array.isArray(entry.chatReplyModes)) {
            const out = {};
            for (const [cid, mode] of Object.entries(entry.chatReplyModes)) {
                if (typeof cid !== 'string' || !cid.trim())
                    continue;
                const normalizedMode = normalizeChatReplyModeConfig(mode);
                if (normalizedMode)
                    out[cid] = normalizedMode;
            }
            if (Object.keys(out).length > 0)
                chatReplyModes = out;
        }
        // chatGrants：只保留 { [chatId:string]: string[] }，逐项校验 typeof === 'string'，
        // 丢弃空列表。未配置或全部非法 → undefined。
        let chatGrants;
        if (entry.chatGrants && typeof entry.chatGrants === 'object' && !Array.isArray(entry.chatGrants)) {
            const out = {};
            for (const [cid, arr] of Object.entries(entry.chatGrants)) {
                if (!Array.isArray(arr))
                    continue;
                const ids = arr.filter((x) => typeof x === 'string');
                if (ids.length > 0)
                    out[cid] = ids;
            }
            if (Object.keys(out).length > 0)
                chatGrants = out;
        }
        // globalGrants：只保留非空 string[]（open_id 列表），逐项校验 typeof === 'string'。
        // 未配置或全部非法 → undefined。
        let globalGrants;
        if (Array.isArray(entry.globalGrants)) {
            const ids = entry.globalGrants.filter((x) => typeof x === 'string' && x.trim().length > 0);
            if (ids.length > 0)
                globalGrants = ids;
        }
        // messageQuota.defaultLimit：仅保留正整数；非法/缺省 → undefined（= 默认额度关闭）。
        let messageQuota;
        const rawMq = entry.messageQuota;
        if (rawMq && typeof rawMq === 'object' && !Array.isArray(rawMq)) {
            const d = rawMq.defaultLimit;
            if (typeof d === 'number' && Number.isInteger(d) && d > 0)
                messageQuota = { defaultLimit: d };
        }
        // quotaState：scope-aware 计数。逐项校验 key 形如 `chat:*:*` / `global:*`，
        // value 为 { limit, used } 正整数（used 允许 0）。非法项丢弃；全空 → undefined。
        let quotaState;
        if (entry.quotaState && typeof entry.quotaState === 'object' && !Array.isArray(entry.quotaState)) {
            const out = {};
            for (const [k, v] of Object.entries(entry.quotaState)) {
                if (!/^(chat:.+:.+|global:.+)$/.test(k))
                    continue;
                if (!v || typeof v !== 'object')
                    continue;
                const limit = v.limit, used = v.used;
                if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0)
                    continue;
                if (typeof used !== 'number' || !Number.isInteger(used) || used < 0)
                    continue;
                out[k] = { limit, used };
            }
            if (Object.keys(out).length > 0)
                quotaState = out;
        }
        // customPassthroughCommands：用户额外放行透传的 slash 命令。归一化：转小写、
        // 自动补前导 `/`、按 /^\/[a-z0-9][a-z0-9:_-]*$/ 过滤、去重。非法/缺省 → undefined。
        // 注意：与 daemon 命令的冲突过滤放在 resolvePassthroughCommands（运行时合并）做，
        // 这里只保证条目本身格式合法，避免在解析期耦合 command-handler 的命令清单。
        let customPassthroughCommands;
        if (Array.isArray(entry.customPassthroughCommands)) {
            const normalized = entry.customPassthroughCommands
                .filter((x) => typeof x === 'string')
                .map((x) => x.trim().toLowerCase())
                .map((x) => (x.startsWith('/') ? x : `/${x}`))
                .filter((x) => /^\/[a-z0-9][a-z0-9:_-]*$/.test(x));
            const uniq = [...new Set(normalized)];
            if (uniq.length > 0)
                customPassthroughCommands = uniq;
        }
        // voice：per-bot 语音引擎覆盖。结构化保留（engine ∈ sami|openai，sami/openai
        // 为对象，speaker/rate 透传）；非对象或 engine 非法 → undefined。深度校验
        // （凭证是否可用）在 resolveVoiceConfig 做，这里只挡明显垃圾。
        let voice;
        const rawVoice = entry.voice;
        if (rawVoice && typeof rawVoice === 'object' && !Array.isArray(rawVoice)) {
            const eng = rawVoice.engine;
            if (eng === undefined || eng === 'sami' || eng === 'openai') {
                const v = {};
                if (eng)
                    v.engine = eng;
                if (typeof rawVoice.speaker === 'string')
                    v.speaker = rawVoice.speaker;
                if (typeof rawVoice.rate === 'number')
                    v.rate = rawVoice.rate;
                const s = rawVoice.sami;
                if (s && typeof s === 'object')
                    v.sami = { accessKey: s.accessKey, secretKey: s.secretKey, appkey: s.appkey, tokenUrl: s.tokenUrl, wsUrl: s.wsUrl };
                const o = rawVoice.openai;
                if (o && typeof o === 'object')
                    v.openai = { baseUrl: o.baseUrl, apiKey: o.apiKey, model: o.model };
                if (v.engine || v.sami || v.openai || v.speaker)
                    voice = v;
            }
        }
        configs.push({
            larkAppId: entry.larkAppId,
            larkAppSecret: entry.larkAppSecret,
            // brand：只认精确的 'lark'，其余 → undefined（下游 normalizeBrand 当
            // feishu）。feishu 故意存成 undefined，保持旧 bots.json 干净、不写死字段。
            brand: entry.brand === 'lark' ? 'lark' : undefined,
            name: typeof entry.name === 'string' && entry.name.trim() ? entry.name.trim() : undefined,
            cliId: entry.cliId ?? 'claude-code',
            cliPathOverride: entry.cliPathOverride,
            wrapperCli: typeof entry.wrapperCli === 'string' && entry.wrapperCli.trim()
                ? entry.wrapperCli.trim()
                : undefined,
            model: typeof entry.model === 'string' && entry.model.trim()
                ? entry.model.trim()
                : undefined,
            disableCliBypass: entry.disableCliBypass === true,
            sandbox: entry.sandbox === true,
            sandboxHidePaths: Array.isArray(entry.sandboxHidePaths)
                ? entry.sandboxHidePaths.filter((p) => typeof p === 'string' && !!p.trim())
                : [],
            backendType: entry.backendType,
            workingDir: workingDirs?.[0] ?? entry.workingDir,
            workingDirs,
            allowedUsers: entry.allowedUsers,
            allowedChatGroups,
            oncallChats,
            defaultOncall,
            defaultOncallAutoboundChats,
            defaultWorkingDir: typeof entry.defaultWorkingDir === 'string' && entry.defaultWorkingDir.trim()
                ? entry.defaultWorkingDir.trim()
                : undefined,
            chatReplyModes,
            chatGrants,
            globalGrants,
            messageQuota,
            quotaState,
            restrictGrantCommands: entry.restrictGrantCommands === true || undefined,
            customPassthroughCommands,
            lang: isLocale(entry.lang) ? entry.lang : undefined,
            // Preserve '' distinctly from undefined: '' means "brand off", undefined
            // means "use default botmux brand". Don't trim-to-undefined here.
            brandLabel: typeof entry.brandLabel === 'string' ? entry.brandLabel : undefined,
            disableStreamingCard: entry.disableStreamingCard === true || undefined,
            // Only 'chat' is meaningful; 'thread' (and anything else) normalizes to
            // undefined — the legacy thread-per-message default. Keeps bots.json clean.
            p2pMode: entry.p2pMode === 'chat' ? 'chat' : undefined,
            noCardChats: Array.isArray(entry.noCardChats)
                ? entry.noCardChats.filter((x) => typeof x === 'string' && x.trim().length > 0).map((x) => x.trim())
                : undefined,
            writableTerminalLinkInCard: entry.writableTerminalLinkInCard === true || undefined,
            privateCard: entry.privateCard === true || undefined,
            autoStartOnGroupJoin: entry.autoStartOnGroupJoin === true || undefined,
            // Preserve the configured prompt verbatim; trim-to-undefined when blank
            // so an empty string doesn't linger in bots.json.
            autoStartOnGroupJoinPrompt: typeof entry.autoStartOnGroupJoinPrompt === 'string' && entry.autoStartOnGroupJoinPrompt.trim()
                ? entry.autoStartOnGroupJoinPrompt
                : undefined,
            autoStartOnNewTopic: entry.autoStartOnNewTopic === true || undefined,
            // Per-bot regular-group default mode. Only 'new-topic' | 'shared' are
            // meaningful; 'chat' (the flat default) and anything else normalize to
            // undefined so bots.json stays clean.
            regularGroupReplyMode: (() => {
                const mode = normalizeChatReplyModeConfig(entry.regularGroupReplyMode);
                return mode === 'new-topic' || mode === 'shared' ? mode : undefined;
            })(),
            // 3-tier @ policy. Only 'topic' | 'never' are meaningful; 'always' (the
            // default) and anything else normalize to undefined so bots.json stays clean.
            regularGroupMentionMode: entry.regularGroupMentionMode === 'topic' || entry.regularGroupMentionMode === 'never'
                ? entry.regularGroupMentionMode
                : undefined,
            // 文档订阅默认触发范围。只 'all' 有意义；'mention-only'（默认）归一化为
            // undefined 让 bots.json 保持干净。
            docSubscribeDefaultMode: entry.docSubscribeDefaultMode === 'all' ? 'all' : undefined,
            voice,
        });
    }
    return configs;
}
//# sourceMappingURL=bot-registry.js.map