/**
 * agent_bridge — chokidar-watched inbox JSONL → typed event emission
 * (WAB.2, 0.5.94).
 *
 * Authoritative source: `docs/tasks/WAB.1-architecture.md` decisions (a) +
 * (b); `docs/tasks/T-warm-agent-chat-bridge.md` WAB.2 Part B spec.
 *
 * Reads the CANONICAL per-project inbox path:
 *   `~/.opensquid/projects/<projectUuid>/inbox/<platform>.jsonl`
 *
 * NO env-override hack. The WAB.1 revision #1 lifted the per-project
 * routing fix into WAB.2 Part A precisely so this bridge could read the
 * correct path day one. If the bridge ever ends up watching the orphan
 * inbox, that's a routing bug — fix it in Part A, not here.
 *
 * Boundary discipline:
 *   1. Inbox JSONL rows are written by the chat daemon (`src/channels`)
 *      in the on-disk row shape (snake_case). The bridge adapts that shape
 *      to the modern `InboundChatEvent` (camelCase, zod-validated).
 *   2. Validation happens at the boundary (every parsed line goes
 *      through `inboundChatEventSchema.parse` — malformed rows surface
 *      as a structured warn-and-skip, not a crash).
 *   3. Byte-offset cursor per file — append-only is the file contract
 *      (`O_APPEND` writes ≤ PIPE_BUF are atomic per POSIX); the bridge
 *      reads only the unread tail, never re-emits already-consumed rows.
 *   4. Truncation safety — if file size < cursor, the bridge resets the
 *      cursor to 0 and re-reads from the start (matches the legacy
 *      log-rotation pattern; chokidar fires `unlink` + `add` on rotate,
 *      and the cursor reset on `add` handles in-place truncate too).
 *
 * chokidar config (per spec):
 *   - `awaitWriteFinish: {stabilityThreshold: 50, pollInterval: 25}`
 *     suppresses partial-write events while keeping latency low
 *     (50ms ≪ typical Telegram inter-message gap; the legacy daemon
 *     batches inbox appends, so 50ms is enough headroom).
 *   - `ignoreInitial: false` — on bridge startup, we want to consume
 *     the existing tail (cursor starts at 0 → reads the whole file
 *     once → cursor advances → idle until next change). This is the
 *     correct restart-resume semantic for a long-running daemon.
 *
 * Imports from: chokidar, node:fs/promises, node:path, node:os, zod,
 *   ./event_bus.js, ./types.js.
 * Imported by: (future) dispatcher.ts, daemon.ts.
 */
import { promises as fs } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { watch } from 'chokidar';
import { umbrellaInboxDir } from '../paths.js';
import { inboundChatEventSchema } from './types.js';
// ---------------------------------------------------------------------------
// Constants (top-of-file for ops visibility)
// ---------------------------------------------------------------------------
const AWAIT_WRITE_FINISH_STABILITY_MS = 50;
const AWAIT_WRITE_FINISH_POLL_MS = 25;
/** TBW.1: bound on the chokidar ready wait — pathological only (real inboxes
 *  scan in milliseconds); the diagnostic names the pre-research. */
const READY_TIMEOUT_MS = 30_000;
const noopWarn = () => {
    /* default sink */
};
// ---------------------------------------------------------------------------
// InboxTransportBridge
// ---------------------------------------------------------------------------
export class InboxTransportBridge {
    opts;
    watcher = null;
    /** TBW.1: resolves an in-flight start() ready-wait when shutdown() runs —
     *  code-owned close semantics (chokidar's close()-mid-init emissions are
     *  undocumented; we never depend on them). */
    closeRequested = null;
    /** Per-file byte-offset cursor. Key = absolute file path. */
    cursors = new Map();
    /** Per-file in-flight read serializer — chokidar may fire `change` twice
     *  for one write burst; we want exactly one read at a time per file. */
    readChains = new Map();
    warn;
    inboxDir;
    fileGlob;
    stopped = false;
    constructor(opts) {
        this.opts = opts;
        this.warn = opts.onWarn ?? noopWarn;
        this.inboxDir =
            opts.inboxRoot ??
                (opts.umbrellaId !== undefined
                    ? umbrellaInboxDir(opts.umbrellaId)
                    : join(homedir(), '.opensquid', 'projects', opts.projectUuid, 'inbox'));
        this.fileGlob = opts.fileGlob ?? '*.jsonl';
    }
    /** Open the chokidar watcher. Re-call after `shutdown()` throws. */
    async start() {
        if (this.watcher !== null) {
            throw new Error('InboxTransportBridge.start: already started');
        }
        if (this.stopped) {
            throw new Error('InboxTransportBridge.start: cannot restart a stopped bridge');
        }
        // Ensure the inbox dir exists before watching — chokidar swallows
        // ENOENT silently on the watched root, which would manifest as
        // "no events ever fire" with no diagnostic.
        await fs.mkdir(this.inboxDir, { recursive: true });
        const pattern = join(this.inboxDir, this.fileGlob);
        this.watcher = watch(pattern, {
            awaitWriteFinish: {
                stabilityThreshold: AWAIT_WRITE_FINISH_STABILITY_MS,
                pollInterval: AWAIT_WRITE_FINISH_POLL_MS,
            },
            // ignoreInitial=false → on startup, chokidar fires `add` for
            // each existing file → bridge reads the existing tail with
            // cursor=0 → emits already-buffered messages on resume. This
            // is the correct daemon-restart semantic.
            ignoreInitial: false,
            ...(this.opts.usePolling !== undefined ? { usePolling: this.opts.usePolling } : {}),
        });
        this.watcher.on('add', (path) => {
            this.opts.onEvent?.('add', path);
            this.scheduleConsume(path);
        });
        this.watcher.on('change', (path) => {
            this.opts.onEvent?.('change', path);
            this.scheduleConsume(path);
        });
        this.watcher.on('unlink', (path) => {
            // Rotation / truncation — drop cursor so next `add` starts at 0.
            this.opts.onEvent?.('unlink', path);
            this.cursors.delete(path);
        });
        this.watcher.on('error', (err) => {
            this.warn(`[agent_bridge.transport] watcher error: ${err instanceof Error ? err.message : String(err)}`);
        });
        // TBW.1 — the watcher is NOT live until chokidar's `ready`: under both
        // backends a file created during the initial-scan window can produce
        // ZERO events forever (the 2026-06-10 captured flake: rows on disk,
        // events {}). Gate start() on ready, then self-heal-scan the dir once.
        const watcher = this.watcher;
        await new Promise((resolveReady, rejectReady) => {
            const onReady = () => {
                cleanup();
                resolveReady();
            };
            const onError = (err) => {
                cleanup();
                rejectReady(err instanceof Error ? err : new Error(String(err)));
            };
            const timer = setTimeout(() => {
                cleanup();
                rejectReady(new Error('InboxTransportBridge.start: watcher ready not reached in 30s (see T-fix-transport-bridge-watcher-race pre-research)'));
            }, READY_TIMEOUT_MS);
            const cleanup = () => {
                clearTimeout(timer);
                watcher.off('ready', onReady);
                watcher.off('error', onError);
                this.closeRequested = null;
            };
            this.closeRequested = () => {
                cleanup();
                resolveReady(); // shutdown() owns the close; start() resolves clean
            };
            watcher.once('ready', onReady);
            watcher.once('error', onError);
        });
        if (this.stopped)
            return; // shutdown won the race — nothing to self-heal
        // Self-heal: files created during the scan window exist on disk but may
        // have produced no events (the captured profile). One readdir, fileGlob-
        // filtered; cursor idempotency makes a duplicate consume a no-op.
        const entries = await fs.readdir(this.inboxDir).catch(() => []);
        for (const name of entries) {
            if (this.matchesFileGlob(name))
                this.scheduleConsume(join(this.inboxDir, name));
        }
    }
    /** The bridge's fileGlob is a basename pattern ('*.jsonl' or a literal like
     *  'telegram.jsonl'); `*` is the one supported wildcard. */
    matchesFileGlob(name) {
        if (!this.fileGlob.includes('*'))
            return name === this.fileGlob;
        const escaped = this.fileGlob
            .split('*')
            .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
            .join('.*');
        return new RegExp(`^${escaped}$`).test(name);
    }
    /** Close the watcher; drain in-flight reads. Idempotent. */
    async shutdown() {
        if (this.stopped)
            return;
        this.stopped = true;
        this.closeRequested?.();
        const w = this.watcher;
        this.watcher = null;
        if (w !== null)
            await w.close();
        // Drain any in-flight reads so callers awaiting shutdown can trust
        // that no further emits will happen after resolve.
        await Promise.allSettled(Array.from(this.readChains.values()));
        this.readChains.clear();
    }
    /**
     * Test-only hook — synchronously reports the current cursor for a file.
     * Production callers do not depend on this; exported for invariant
     * assertions in the audit harness.
     */
    cursorFor(path) {
        return this.cursors.get(path);
    }
    // --- private ---
    /** Serialize reads per-file to prevent overlapping consumers. */
    scheduleConsume(path) {
        if (this.stopped)
            return;
        const prev = this.readChains.get(path) ?? Promise.resolve();
        const next = prev
            .catch(() => undefined) // never let a prior failure poison the chain
            .then(() => this.consumeTail(path));
        this.readChains.set(path, next);
    }
    /**
     * Read from the cursor to end-of-file; parse each newline-delimited row;
     * validate via zod; emit `inbound`. Cursor advances by the byte length
     * of every line we consumed (including the trailing `\n`), so a
     * partial trailing line is held for the next `change` event.
     */
    async consumeTail(path) {
        if (this.stopped)
            return;
        this.opts.onEvent?.('consume', path);
        let stat;
        try {
            stat = await fs.stat(path);
        }
        catch (err) {
            const code = err.code;
            if (code === 'ENOENT') {
                this.cursors.delete(path);
                return;
            }
            this.warn(`[agent_bridge.transport] stat failed ${path}: ${err instanceof Error ? err.message : String(err)}`);
            return;
        }
        const size = stat.size;
        let cursor = this.cursors.get(path) ?? 0;
        if (size < cursor) {
            // Truncation in-place — reset to 0 and re-read.
            cursor = 0;
        }
        if (size === cursor)
            return;
        let handle;
        try {
            handle = await fs.open(path, 'r');
            const length = size - cursor;
            const buf = Buffer.alloc(length);
            const { bytesRead } = await handle.read(buf, 0, length, cursor);
            if (bytesRead === 0)
                return;
            // Split on newlines; preserve a trailing partial (no \n) by NOT
            // emitting its bytes — leave them under the cursor for next time.
            const slice = buf.subarray(0, bytesRead).toString('utf8');
            let consumedBytes = 0;
            let lineStart = 0;
            while (lineStart < slice.length) {
                const newlineIdx = slice.indexOf('\n', lineStart);
                if (newlineIdx === -1)
                    break; // partial trailing line — leave for next read
                const line = slice.slice(lineStart, newlineIdx);
                // Byte length of the consumed segment (including the \n).
                // We measure on the original slice because multibyte chars
                // shift index↔byte mapping; Buffer.byteLength of just `line`
                // misses the \n itself.
                consumedBytes += Buffer.byteLength(line, 'utf8') + 1;
                lineStart = newlineIdx + 1;
                const trimmed = line.trim();
                if (trimmed === '')
                    continue;
                this.emitRow(path, trimmed);
            }
            this.cursors.set(path, cursor + consumedBytes);
        }
        catch (err) {
            this.warn(`[agent_bridge.transport] read failed ${path}: ${err instanceof Error ? err.message : String(err)}`);
        }
        finally {
            await handle?.close().catch(() => {
                /* close errors are non-fatal */
            });
        }
    }
    /** Parse one JSONL line, adapt to InboundChatEvent, emit. */
    emitRow(path, line) {
        let parsed;
        try {
            parsed = JSON.parse(line);
        }
        catch (err) {
            this.warn(`[agent_bridge.transport] malformed JSON in ${path}: ${err instanceof Error ? err.message : String(err)}`);
            return;
        }
        const adapted = this.adaptLegacyRow(parsed);
        if (adapted === null)
            return; // warn already logged inside adapter
        const result = inboundChatEventSchema.safeParse(adapted);
        if (!result.success) {
            this.warn(`[agent_bridge.transport] schema mismatch in ${path}: ${result.error.message}`);
            return;
        }
        this.opts.bus.emit('inbound', result.data);
    }
    /**
     * Map legacy snake_case `InboxMessage` shape → modern camelCase
     * `InboundChatEvent`. Returns null + warns when the row doesn't look
     * like a legacy InboxMessage at all (forward-compat: future writers
     * with a `kind: 'inbound_message'` field that already matches the
     * modern schema would skip this adapter; we let zod take the shot).
     */
    adaptLegacyRow(raw) {
        if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
            this.warn(`[agent_bridge.transport] not an object: ${JSON.stringify(raw)}`);
            return null;
        }
        const r = raw;
        // If the row is already in modern shape (has `kind: 'inbound_message'`),
        // pass through; zod schema parse below will validate it.
        if (r.kind === 'inbound_message')
            return r;
        // Legacy shape: build SessionKey from platform + channel + thread_id.
        if (typeof r.platform !== 'string' || typeof r.channel !== 'string') {
            this.warn(`[agent_bridge.transport] missing platform/channel: ${JSON.stringify(r).slice(0, 120)}`);
            return null;
        }
        if (r.platform !== 'telegram' && r.platform !== 'discord' && r.platform !== 'slack') {
            this.warn(`[agent_bridge.transport] unsupported platform '${r.platform}'`);
            return null;
        }
        // After the negative guard above, r.platform is narrowed to the union.
        // chatId is the native id portion of the channel (`<platform>:<native>`).
        const colonIdx = r.channel.indexOf(':');
        if (colonIdx === -1) {
            this.warn(`[agent_bridge.transport] malformed channel '${r.channel}'`);
            return null;
        }
        const chatId = r.channel.slice(colonIdx + 1);
        const sessionKey = {
            platform: r.platform,
            chatId,
            ...(typeof r.thread_id === 'string' && r.thread_id.length > 0
                ? { threadId: r.thread_id }
                : {}),
        };
        const event = {
            kind: 'inbound_message',
            sessionKey,
            messageId: String(r.id ?? ''),
            sender: {
                id: String(r.sender_id ?? ''),
                ...(typeof r.sender === 'string' && r.sender.length > 0 ? { name: r.sender } : {}),
            },
            text: typeof r.text === 'string' ? r.text : '',
            receivedAt: String(r.received_at ?? ''),
            enqueuedAt: String(r.enqueued_at ?? ''),
            projectUuid: this.opts.projectUuid,
            // Stamp the owning umbrella (CAT.5) so the dispatcher's arbitration
            // reads the umbrella lease, not the project lease.
            ...(this.opts.umbrellaId !== undefined ? { umbrellaId: this.opts.umbrellaId } : {}),
            raw: r,
        };
        return event;
    }
}
//# sourceMappingURL=transport_bridge.js.map