/**
 * chat-daemon RPC server (v0.7.1 Phase B).
 *
 * Runs INSIDE the daemon worker process. Listens on the platform-
 * correct socket address (Unix socket / named pipe) and dispatches
 * JSON-RPC method calls to the in-process ChatGateway.
 *
 * Connection model: stateless. Each line is one request; each response
 * goes back on the same connection. Connections may pipeline multiple
 * requests, and the server reads line-by-line (we keep a per-connection
 * buffer until \n).
 */
import { createServer } from "node:net";
import { existsSync, unlinkSync } from "node:fs";
import { TopicGoneError } from "../adapters/telegram.js";
import { recordTopicGoneEvent } from "./collisions.js";
import { JSON_RPC_INTERNAL_ERROR, JSON_RPC_INVALID_PARAMS, JSON_RPC_INVALID_REQUEST, JSON_RPC_METHOD_NOT_FOUND, JSON_RPC_PARSE_ERROR, daemonSockAddress, } from "./protocol.js";
import { loadProjectChatRouting } from "./routing.js";
import { SubscriberRegistry } from "./subscribers.js";
import { clearBinding, findOwnerOfBinding, resolveOrCreateTopic } from "./workspace-topic.js";
export class RpcServer {
    opts;
    server;
    listening = false;
    address;
    startedAt;
    /**
     * TPS.6 patch 1 (v0.5.125) — long-lived subscriber registry. The
     * gateway.onMessage wire-up that broadcasts to this registry lands
     * in patch 2 (v0.5.126). For patch 1, the daemon accepts
     * subscribe/unsubscribe RPCs but does not yet push notifications.
     */
    subscribers = new SubscriberRegistry();
    constructor(opts) {
        this.opts = opts;
        this.address = daemonSockAddress(opts.dataRoot);
        this.startedAt = opts.startedAt ?? Date.now();
        this.server = createServer((socket) => this.handleConnection(socket));
        // Treat client errors as informational; a misbehaving client must
        // not crash the daemon.
        this.server.on("error", () => {
            /* swallowed at server level; per-conn errors handled below */
        });
    }
    async listen() {
        if (this.listening)
            return;
        // Unix-socket clean-up: a previously-aborted daemon may have left a
        // stale socket file. Removing it is safe because the previous
        // daemon is gone (verified by the lifecycle layer before we got
        // here). Windows named pipes are kernel objects with no file
        // residue, so this is a no-op there.
        if (process.platform !== "win32" && existsSync(this.address)) {
            try {
                unlinkSync(this.address);
            }
            catch {
                /* race-tolerant */
            }
        }
        await new Promise((resolve, reject) => {
            this.server.once("error", reject);
            this.server.listen(this.address, () => {
                this.server.off("error", reject);
                this.listening = true;
                resolve();
            });
        });
    }
    async close() {
        if (!this.listening)
            return;
        await new Promise((resolve) => {
            this.server.close(() => resolve());
        });
        this.listening = false;
        if (process.platform !== "win32" && existsSync(this.address)) {
            try {
                unlinkSync(this.address);
            }
            catch {
                /* race-tolerant */
            }
        }
    }
    handleConnection(socket) {
        let buf = "";
        socket.setEncoding("utf8");
        socket.on("data", (chunk) => {
            buf += chunk;
            let nl;
            while ((nl = buf.indexOf("\n")) >= 0) {
                const line = buf.slice(0, nl);
                buf = buf.slice(nl + 1);
                if (line.trim().length === 0)
                    continue;
                this.dispatchLine(line, socket);
            }
        });
        socket.on("error", () => {
            // Half-open connections are common; closing here keeps the daemon
            // tidy without surfacing an error to other clients. Subscriber
            // eviction is handled by the registry's own socket 'error'/'close'
            // listeners installed at register() time, so we don't need to
            // touch the registry here.
            try {
                socket.destroy();
            }
            catch {
                /* already gone */
            }
        });
    }
    dispatchLine(line, socket) {
        let req;
        try {
            req = JSON.parse(line);
        }
        catch {
            this.respond(socket, parseError());
            return;
        }
        if (!req || req.jsonrpc !== "2.0" || typeof req.id === "undefined" || !req.method) {
            this.respond(socket, invalidRequest(req?.id ?? null));
            return;
        }
        if (this.opts.onRequest)
            this.opts.onRequest(req.method);
        void this.handle(req, socket).then((res) => this.respond(socket, res));
    }
    respond(socket, response) {
        try {
            socket.write(`${JSON.stringify(response)}\n`);
        }
        catch {
            /* dropped — connection gone */
        }
    }
    /**
     * TPS.7 (v0.5.130) — async stale-topic recovery. Locates the
     * workspace that owned the now-stale binding, clears it via
     * `clearBinding`, and records a `TopicGoneEvent` in collisions.jsonl
     * (which fires a debounced Telegram notification through the same
     * surface TPS.5 collisions use).
     *
     * Fire-and-forget from the send-RPC catch block. NEVER throws — all
     * failures log to stderr. The original send already failed; the
     * caller doesn't care about recovery errors.
     *
     * Re-binding does NOT happen here — by design it defers to the next
     * MCP-bridge subscribe (TPS.6 auto-boot path), which is lockfile-
     * protected so two concurrent recoveries can't double-create topics.
     */
    async handleTopicGone(err) {
        try {
            const workspaceUuid = await findOwnerOfBinding({
                chatId: err.chatId,
                topicId: err.threadId,
                ...(this.opts.dataRoot !== undefined ? { dataRoot: this.opts.dataRoot } : {}),
            });
            if (workspaceUuid === null) {
                // No project claims this binding — either the user manually
                // cleared it, a concurrent recovery beat us to it, or the
                // binding was never tracked. Nothing to clean; nothing to
                // notify.
                return;
            }
            await clearBinding({
                workspaceUuid,
                ...(this.opts.dataRoot !== undefined ? { dataRoot: this.opts.dataRoot } : {}),
            });
            await recordTopicGoneEvent({
                workspaceUuid,
                chatId: err.chatId,
                topicId: err.threadId,
                underlyingDescription: err.message,
                gateway: this.opts.gateway,
                ...(this.opts.dataRoot !== undefined ? { dataRoot: this.opts.dataRoot } : {}),
            });
        }
        catch (recoveryErr) {
            process.stderr.write(`[rpc-server] topic-gone recovery failed for chatId=${err.chatId} topicId=${err.threadId}: ${recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr)}\n`);
        }
    }
    async handle(req, socket) {
        try {
            switch (req.method) {
                case "subscribe": {
                    const p = req.params;
                    if (!p ||
                        typeof p.session_id !== "string" ||
                        p.session_id.length === 0 ||
                        typeof p.workspace_uuid !== "string" ||
                        typeof p.workspace_path !== "string" ||
                        !Array.isArray(p.chat_ids)) {
                        return failure(req.id, JSON_RPC_INVALID_PARAMS, "subscribe: session_id, workspace_uuid, workspace_path, chat_ids[] required");
                    }
                    for (const id of p.chat_ids) {
                        if (typeof id !== "string") {
                            return failure(req.id, JSON_RPC_INVALID_PARAMS, "subscribe: chat_ids[] must be strings");
                        }
                    }
                    this.subscribers.register({
                        session_id: p.session_id,
                        workspace_uuid: p.workspace_uuid,
                        workspace_path: p.workspace_path,
                        chat_ids: p.chat_ids,
                        socket,
                    });
                    // TPS.6 patch 4 (v0.5.128) — daemon-side auto-boot. When the
                    // workspace has a configured supergroup but no auto_bound
                    // binding yet, resolve-or-create the forum topic now so the
                    // MCP bridge starts seeing per-workspace-routed inbound
                    // messages without requiring a separate `opensquid setup
                    // chat` wizard run. resolveOrCreateTopic is idempotent —
                    // existing bindings come back as {created:false} with the
                    // existing topic_id/topic_name.
                    //
                    // Skipped conditions (all degrade silently to a wildcard-
                    // routed subscription; subscribers see general topic only):
                    //   - workspace_uuid is the "no-project" sentinel
                    //   - chat-routing.json missing or has no
                    //     telegram.report_channel (no destination)
                    //   - resolveOrCreateTopic throws (logged to stderr, never
                    //     blocks subscribe ack)
                    const autoBoot = await tryAutoBootTopic(p.workspace_uuid, p.workspace_path, this.opts.dataRoot);
                    if (autoBoot) {
                        return success(req.id, {
                            ok: true,
                            bound_topic_id: autoBoot.topicId,
                            bound_topic_name: autoBoot.topicName,
                        });
                    }
                    return success(req.id, { ok: true });
                }
                case "unsubscribe": {
                    const p = req.params;
                    if (!p || typeof p.session_id !== "string" || p.session_id.length === 0) {
                        return failure(req.id, JSON_RPC_INVALID_PARAMS, "unsubscribe: session_id required");
                    }
                    this.subscribers.unregister(p.session_id);
                    return success(req.id, { ok: true });
                }
                case "ping":
                    return success(req.id, {
                        pong: true,
                        pid: this.opts.pid ?? process.pid,
                        version: this.opts.version ?? "unknown",
                    });
                case "list_channels": {
                    // 0.7.4 (#147): walk active adapters and ask which are
                    // outbound-only (degraded due to 409 collision with an
                    // external poller). Only the telegram adapter currently
                    // exposes isOutboundOnly(); duck-type test so other
                    // platforms can opt in later without changing this code.
                    const platforms = this.opts.gateway.activePlatforms();
                    const outboundOnly = [];
                    for (const p of platforms) {
                        const adapter = this.opts.gateway.getAdapter(p);
                        const maybe = adapter;
                        if (typeof maybe?.isOutboundOnly === "function" && maybe.isOutboundOnly()) {
                            outboundOnly.push(p);
                        }
                    }
                    return success(req.id, {
                        active_platforms: platforms,
                        uptime_ms: Date.now() - this.startedAt,
                        outbound_only_platforms: outboundOnly,
                    });
                }
                case "send": {
                    const p = req.params;
                    if (!p || typeof p.channel !== "string" || typeof p.text !== "string") {
                        return failure(req.id, JSON_RPC_INVALID_PARAMS, "send: channel + text required");
                    }
                    try {
                        const result = await this.opts.gateway.send({
                            channel: p.channel,
                            text: p.text,
                            replyTo: p.replyTo,
                            threadId: p.threadId,
                        });
                        return success(req.id, {
                            ok: true,
                            platform: result.platform,
                            message_id: result.messageId,
                            delivered_at: result.deliveredAt.toISOString(),
                        });
                    }
                    catch (err) {
                        // TPS.7 (v0.5.130) — typed stale-topic detection. Fire the
                        // recovery asynchronously so the caller gets the original
                        // failure (the send still failed; the daemon cleans up the
                        // stale binding in the background, and the next MCP-bridge
                        // subscribe rebinds via TPS.6 auto-boot).
                        if (err instanceof TopicGoneError) {
                            void this.handleTopicGone(err);
                        }
                        // Re-throw to the outer catch which formats the JSON-RPC
                        // failure (preserves the existing error contract).
                        throw err;
                    }
                }
                case "create_topic": {
                    const p = req.params;
                    if (!p ||
                        p.platform !== "telegram" ||
                        typeof p.chat_id !== "string" ||
                        typeof p.name !== "string") {
                        return failure(req.id, JSON_RPC_INVALID_PARAMS, "create_topic: platform='telegram', chat_id, name required");
                    }
                    const adapter = this.opts.gateway.getAdapter(p.platform);
                    if (!adapter ||
                        typeof adapter.createTopic !== "function") {
                        return failure(req.id, JSON_RPC_INTERNAL_ERROR, `${p.platform} adapter does not support topic creation (or not active)`);
                    }
                    const adapterAny = adapter;
                    const res = await adapterAny.createTopic(p.chat_id, p.name, {
                        iconColor: p.icon_color,
                        iconCustomEmojiId: p.icon_custom_emoji_id,
                    });
                    return success(req.id, res);
                }
                default:
                    return failure(req.id, JSON_RPC_METHOD_NOT_FOUND, `unknown method: ${req.method}`);
            }
        }
        catch (err) {
            return failure(req.id, JSON_RPC_INTERNAL_ERROR, err instanceof Error ? err.message : String(err));
        }
    }
}
// ---------------------------------------------------------------------
// JSON-RPC response helpers
// ---------------------------------------------------------------------
function success(id, result) {
    return { jsonrpc: "2.0", id, result };
}
function failure(id, code, message) {
    return { jsonrpc: "2.0", id, error: { code, message } };
}
function parseError() {
    return failure(null, JSON_RPC_PARSE_ERROR, "parse error");
}
function invalidRequest(id) {
    return failure(id, JSON_RPC_INVALID_REQUEST, "invalid jsonrpc request");
}
// ---------------------------------------------------------------------
// TPS.6 patch 4 (v0.5.128) — auto-boot helper
// ---------------------------------------------------------------------
/**
 * Try to resolve-or-create a Telegram forum topic for a workspace at
 * subscribe time. Returns null when the workspace can't be auto-bound
 * (no project, no routing file, no report_channel, or resolve threw).
 * Never throws — failures only log to stderr.
 */
async function tryAutoBootTopic(workspaceUuid, workspacePath, dataRoot) {
    if (!workspaceUuid || workspaceUuid === "no-project")
        return null;
    let chatId;
    try {
        const routing = await loadProjectChatRouting(workspaceUuid, dataRoot);
        chatId = routing?.telegram?.report_channel;
    }
    catch (err) {
        process.stderr.write(`[rpc-server] auto-boot: loadProjectChatRouting failed for ${workspaceUuid}: ${err instanceof Error ? err.message : String(err)}\n`);
        return null;
    }
    if (!chatId || chatId.length === 0)
        return null;
    try {
        const result = await resolveOrCreateTopic({
            workspaceUuid,
            workspacePath,
            chatId,
            mode: "auto-boot",
            ...(dataRoot !== undefined ? { dataRoot } : {}),
        });
        return { topicId: result.topicId, topicName: result.topicName };
    }
    catch (err) {
        process.stderr.write(`[rpc-server] auto-boot: resolveOrCreateTopic failed for ${workspaceUuid} (chatId=${chatId}): ${err instanceof Error ? err.message : String(err)}\n`);
        return null;
    }
}
