/**
 * Per-project chat routing (v0.7.1 Phase C).
 *
 * Note on src.legacy/ placement (TG.1 (f) / WAB.2): the "legacy" label is a
 * pre-0.5.x architecture marker, NOT a "dead code" marker. The chat-daemon
 * compiled from `src.legacy/chat/` is the production runtime for inbound
 * Telegram + outbound chat_send. This file is actively maintained — see
 * `inbound_dm_user_ids` (v0.5.94 / WAB.2 Part A) below.
 *
 * Schema on disk: `~/.opensquid/projects/<uuid>/chat-routing.json`
 *
 * ```jsonc
 * {
 *   "telegram": {
 *     "report_channel": "telegram:-1001234567890",  // outbound default
 *     "report_topic_id": 15,                          // forum-topic id for outbound
 *     "inbound_chat_ids": ["-1001234567890"],        // accepts inbound from these chats
 *     "inbound_topic_ids": [15],                      // strict whitelist when set
 *     "inbound_dm_user_ids": ["8075471258"]          // v0.5.94 — DM allowlist
 *   },
 *   "discord": {
 *     "report_channel": "discord:1234567890",
 *     "inbound_channel_ids": ["1234567890"]
 *   },
 *   "slack": {
 *     "report_channel": "slack:C012345",
 *     "inbound_channel_ids": ["C012345"]
 *   }
 * }
 * ```
 *
 * Routing rules:
 *   - **Outbound** (agent → chat): the MCP tool picks `report_channel`
 *     from the active project's routing config. (Phase E wires the MCP
 *     tools; this module just exposes the lookup.)
 *   - **Inbound** (chat → agent): the daemon's gateway.onMessage handler
 *     constructs a routing key (DM key, topic key, or chat-only key per
 *     TG.1 policy decision (a)+(d)) and looks it up in the index this
 *     module builds. Match → JSONL append to that project's inbox.
 *     No match → JSONL append to the orphan inbox.
 *
 * UUID is the stable primary key because the project's human-friendly
 * `id` can be renamed via `opensquid project init` without rewriting
 * routing files.
 */
import { promises as fs } from "node:fs";
import * as path from "node:path";
import { resolveDataRoot } from "../../codex/store.js";
// ---------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------
export function projectsRootPath(dataRoot) {
    return path.join(resolveDataRoot(dataRoot), "projects");
}
export function projectChatRoutingPath(projectUuid, dataRoot) {
    return path.join(projectsRootPath(dataRoot), projectUuid, "chat-routing.json");
}
export function projectInboxDir(projectUuid, dataRoot) {
    return path.join(projectsRootPath(dataRoot), projectUuid, "inbox");
}
export function orphanInboxDir(dataRoot) {
    return path.join(resolveDataRoot(dataRoot), "inbox", "orphan");
}
// ---------------------------------------------------------------------
// Load
// ---------------------------------------------------------------------
/**
 * Read a single project's routing config. Returns null if the file
 * doesn't exist or fails to parse — both are non-fatal (the project
 * just doesn't have routing configured, so inbound for it goes to
 * the orphan inbox).
 */
export async function loadProjectChatRouting(projectUuid, dataRoot) {
    const p = projectChatRoutingPath(projectUuid, dataRoot);
    try {
        const raw = await fs.readFile(p, "utf8");
        return JSON.parse(raw);
    }
    catch (err) {
        if (err.code === "ENOENT")
            return null;
        // Malformed JSON: log on stderr (daemon log) and fall back to
        // no-routing. Better than crashing the daemon over a bad file.
        process.stderr.write(`[chat-routing] failed to parse ${p}: ${err instanceof Error ? err.message : err}\n`);
        return null;
    }
}
/**
 * Scan `~/.opensquid/projects/*` and load every routing file that
 * exists. Returns a Map keyed by project_uuid → routing config. Used
 * by `buildRoutingIndex` to construct the lookup map.
 */
export async function loadAllProjectChatRouting(dataRoot) {
    const root = projectsRootPath(dataRoot);
    const out = new Map();
    let entries;
    try {
        entries = await fs.readdir(root);
    }
    catch (err) {
        if (err.code === "ENOENT")
            return out;
        throw err;
    }
    for (const uuid of entries) {
        // Skip hidden files; only consider directory-like entries.
        if (uuid.startsWith("."))
            continue;
        const routing = await loadProjectChatRouting(uuid, dataRoot);
        if (routing)
            out.set(uuid, routing);
    }
    return out;
}
/**
 * Build the inbound chat_id → project_uuid lookup map from a collection
 * of per-project routing configs. Used at daemon startup AND whenever
 * the file watcher fires on a routing change.
 *
 * Collision handling: if two projects claim the same inbound chat_id,
 * the LATER one wins (Map.set overwrite). Surface a structured warning
 * via the optional `onCollision` callback so the operator can fix it.
 */
export function buildRoutingIndex(configs, onCollision) {
    const idx = new Map();
    for (const [projectUuid, cfg] of configs) {
        for (const channelKey of collectInboundChannels(cfg)) {
            const existing = idx.get(channelKey);
            if (existing && existing !== projectUuid && onCollision) {
                onCollision({
                    channel_key: channelKey,
                    existing_uuid: existing,
                    newcomer_uuid: projectUuid,
                });
            }
            idx.set(channelKey, projectUuid);
        }
    }
    return idx;
}
/**
 * Yield each inbound channel id (in `<platform>:<native_id>` shape) a
 * routing config declares.
 */
export function collectInboundChannels(cfg) {
    const out = [];
    if (cfg.telegram?.inbound_chat_ids) {
        // v0.7.2: if inbound_topic_ids is set, emit a more-specific key per
        // (chat_id, topic_id) tuple so the routing index can distinguish
        // multiple projects sharing one supergroup. Otherwise emit the
        // chat-only key (v0.7.1 behavior).
        const topicIds = cfg.telegram.inbound_topic_ids;
        for (const chatId of cfg.telegram.inbound_chat_ids) {
            if (topicIds && topicIds.length > 0) {
                for (const tid of topicIds)
                    out.push(`telegram:${chatId}:${tid}`);
            }
            else {
                out.push(`telegram:${chatId}`);
            }
        }
    }
    // v0.5.94 (WAB.2 Part A / TG.1 (a)): emit DM allowlist keys as
    // `telegram:dm:<user_id>`. Worker's onMessage detects DMs (chat.id ===
    // from.id) and looks up against this key. Separate key namespace from
    // `telegram:<chat_id>` so a user whose user_id collides with a
    // supergroup chat_id (unlikely but possible — both are integers) does
    // not silently cross-route.
    if (cfg.telegram?.inbound_dm_user_ids) {
        for (const uid of cfg.telegram.inbound_dm_user_ids)
            out.push(`telegram:dm:${uid}`);
    }
    if (cfg.discord?.inbound_channel_ids) {
        for (const id of cfg.discord.inbound_channel_ids)
            out.push(`discord:${id}`);
    }
    if (cfg.slack?.inbound_channel_ids) {
        for (const id of cfg.slack.inbound_channel_ids)
            out.push(`slack:${id}`);
    }
    return out;
}
// ---------------------------------------------------------------------
// Write
// ---------------------------------------------------------------------
/**
 * Persist a project's routing config. Creates the project directory if
 * missing. Atomic-ish via write-then-rename so partial writes never
 * leave a corrupt file.
 */
export async function saveProjectChatRouting(projectUuid, routing, dataRoot) {
    const filePath = projectChatRoutingPath(projectUuid, dataRoot);
    await fs.mkdir(path.dirname(filePath), { recursive: true });
    const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
    await fs.writeFile(tmp, `${JSON.stringify(routing, null, 2)}\n`, "utf8");
    await fs.rename(tmp, filePath);
    return { path: filePath };
}
