/**
 * Chat Service — facade over ChatV2Service.
 *
 * Phase 6 of the unified-chat-message-store spec
 * (`specs/2026-05-14-unified-chat-message-store.md`):
 * the legacy JSON-file storage at `~/.crewly/chat/` is retired.
 * All chat persistence and read paths now flow through the canonical
 * `ChatV2Service` (SQLite at `~/.crewly/chat.db`). This file remains
 * temporarily as a deprecation shim so the many legacy callers
 * (`chat.controller.ts`, `chat.gateway.ts`, `slack-orchestrator-bridge.ts`,
 * `notify-reconciliation.service.ts`, `message-replay.service.ts`,
 * `index.ts`) continue to compile and run while they are migrated to
 * call ChatV2Service directly. Each facade method translates between
 * the legacy `ChatMessage` / `ChatConversation` DTOs and chat-v2's
 * `ChatMessageDTO` / `ChatChannelDTO` so existing event subscribers
 * (chat.gateway.ts) see the same shapes they always have.
 *
 * @module services/chat/chat.service
 */
import { EventEmitter } from 'events';
import { LoggerService } from '../core/logger.service.js';
/**
 * Default page size for legacy `getMessages` calls without an explicit
 * `filter.limit`. Matches the historical hardcoded value so the change
 * to honor `filter.limit` is a strict superset of the prior behavior.
 */
const LEGACY_DEFAULT_PAGE_SIZE = 200;
/**
 * Hard cap on `getMessages` limit, applied after the caller-supplied
 * value. Keeps a buggy/malicious caller from asking chat-v2 to load
 * the entire channel history into a single response.
 */
const LEGACY_MAX_PAGE_SIZE = 1000;
/**
 * Pick the chat-v2 `metadata.source` for a legacy `addAgentMessage`
 * /`addDirectMessage` call. Returns the caller-provided
 * `metadata.source` when it is one of the closed chat-v2 source enum
 * values, otherwise falls back to `defaultSource` (the historical
 * hardcoded value for the call site).
 *
 * @param metadata - Legacy metadata blob (possibly undefined)
 * @param defaultSource - Source to use when metadata has no valid source
 * @returns A chat-v2 `RecordTurnSource` value
 */
function resolveLegacyRecordSource(metadata, defaultSource) {
    const raw = metadata?.source;
    if (raw === 'web' ||
        raw === 'slack' ||
        raw === 'pty-runtime' ||
        raw === 'in-process-runtime' ||
        raw === 'reply-tool' ||
        raw === 'system') {
        return raw;
    }
    return defaultSource;
}
import { getChatV2Service } from '../chat-v2/chat-v2.singleton.js';
import { SYSTEM_PRINCIPAL, senderToV2, v2MessageToLegacy, v2ChannelToLegacy, inferSourceFromLegacyMetadata, synthesizeSlackConversationId, } from '../chat-v2/legacy-dto.utils.js';
// =============================================================================
// Error classes — preserved for callers that catch them by name
// =============================================================================
export class ConversationNotFoundError extends Error {
    conversationId;
    constructor(conversationId) {
        super(`Conversation not found: ${conversationId}`);
        this.conversationId = conversationId;
        this.name = 'ConversationNotFoundError';
    }
}
export class MessageValidationError extends Error {
    constructor(message) {
        super(message);
        this.name = 'MessageValidationError';
    }
}
// =============================================================================
// ChatService facade
// =============================================================================
/**
 * Deprecated façade over ChatV2Service.
 *
 * Preserves the public surface of the original `ChatService` so the
 * remaining legacy callers compile unchanged. Internally every method
 * delegates to `ChatV2Service`. The original ~/.crewly/chat/*.json
 * storage layer has been removed.
 *
 * New code MUST NOT depend on this class; call `getChatV2Service()`
 * directly. Phase 6c of the spec deletes this file once all callers
 * are migrated.
 */
export class ChatService extends EventEmitter {
    logger;
    chatV2;
    constructor(_options) {
        super();
        this.logger = LoggerService.getInstance().createComponentLogger('ChatService');
        this.chatV2 = getChatV2Service();
    }
    // ---------------------------------------------------------------------------
    // Lifecycle (no-ops — chat-v2 manages its own DB lifecycle)
    // ---------------------------------------------------------------------------
    async initialize() {
        // chat-v2 lazy-initializes on first access; nothing to do.
    }
    isInitialized() {
        return true;
    }
    // ---------------------------------------------------------------------------
    // Writes
    // ---------------------------------------------------------------------------
    /**
     * Send a user message. Idempotency via legacy callers' own
     * metadata.clientMessageId when present.
     */
    async sendMessage(input) {
        const conversationId = input.conversationId ?? this.synthesizeConversationId(input);
        const channel = this.chatV2.ensureChannelForLegacyConversation({
            conversationId,
            agentSession: 'crewly-orc',
        });
        const senderId = (typeof input.metadata?.userId === 'string' && input.metadata.userId) || 'user';
        const { message } = this.chatV2.recordTurn({
            channelId: channel.id,
            senderType: 'user',
            senderId,
            content: input.content,
            clientMessageId: typeof input.metadata?.clientMessageId === 'string'
                ? input.metadata.clientMessageId
                : undefined,
            // Source resolution here is intentionally STRICTER than
            // `recordViaFacade` (which uses `resolveLegacyRecordSource`).
            // `sendMessage` always writes `senderType: 'user'`, so the only
            // legitimate sources are 'web' or 'slack'. Caller-supplied values
            // like 'reply-tool' or 'pty-runtime' are agent-reply tags and
            // would be nonsensical on a user-authored row — `inferSource…`
            // downgrades them to 'system' on purpose. Phase 6α follow-up #5.
            metadata: {
                ...(input.metadata ?? {}),
                source: inferSourceFromLegacyMetadata(input.metadata),
            },
        });
        const conversation = v2ChannelToLegacy(channel, this.chatV2.countChannelMessages(channel.id, SYSTEM_PRINCIPAL));
        const legacyMessage = v2MessageToLegacy(message);
        // Phase 6α follow-up #6: chat-v2 already emits 'chat_message' for
        // every fresh recordTurn write (chat-v2.service.ts:936/1063). The
        // chat.gateway WebSocket subscriber listens on the chat-v2
        // EventEmitter directly, so a second emit here would double-broadcast
        // to every connected client. The 'conversation_updated' event has no
        // chat-v2 equivalent yet, so we keep emitting that one until chat-v2
        // grows a channel-touched event.
        this.emit('conversation_updated', {
            type: 'conversation_updated',
            data: conversation,
        });
        return { conversation, message: legacyMessage };
    }
    /**
     * Add an agent reply extracted from raw terminal output. The legacy
     * regex extraction (`[RESPONSE]` / `[CHAT_RESPONSE]` markers) is gone
     * — callers should pass already-clean content. Kept for source
     * compatibility with `chat.gateway.processTerminalOutput`, which has
     * no production callers post Phase 4 discovery.
     */
    async addAgentMessage(conversationId, rawOutput, sender, metadata) {
        // Phase 6α follow-up #5: source defaults to 'pty-runtime' (the
        // historical caller) but the caller can override via
        // `metadata.source` — e.g. an in-process runtime route should tag
        // its replies 'in-process-runtime', not 'pty-runtime'. Falling back
        // through inferSourceFromLegacyMetadata preserves the previous
        // default for callers that don't tag.
        const source = resolveLegacyRecordSource(metadata, 'pty-runtime');
        return this.recordViaFacade(conversationId, rawOutput, sender, metadata, source);
    }
    /**
     * Persist a pre-extracted markdown reply. Primary call site is the
     * PTY `[NOTIFY]` path in `chat.gateway.processNotifyMessage`.
     */
    async addDirectMessage(conversationId, content, sender, metadata) {
        // Phase 6α follow-up #5: see addAgentMessage. Same default + override.
        const source = resolveLegacyRecordSource(metadata, 'pty-runtime');
        return this.recordViaFacade(conversationId, content, sender, metadata, source);
    }
    /**
     * Add a server-side system note (progress markers, errors, etc.).
     */
    async addSystemMessage(conversationId, content, metadata) {
        return this.recordViaFacade(conversationId, content, { type: 'system', id: 'system', name: 'System' }, metadata, 'system');
    }
    async recordViaFacade(conversationId, content, sender, metadata, source) {
        const channel = this.chatV2.ensureChannelForLegacyConversation({
            conversationId,
            agentSession: 'crewly-orc',
        });
        const { type: senderType, id: senderId } = senderToV2(sender);
        const { message } = this.chatV2.recordTurn({
            channelId: channel.id,
            senderType,
            senderId,
            content,
            clientMessageId: typeof metadata?.clientMessageId === 'string' ? metadata.clientMessageId : undefined,
            // `source` AFTER the spread: the resolved source from
            // resolveLegacyRecordSource already incorporated metadata.source
            // (if valid) or fell back to the default. Putting it last
            // guarantees a value from the closed enum lands in the row
            // regardless of what the legacy caller passed.
            metadata: { ...(metadata ?? {}), source },
        });
        const legacyMessage = v2MessageToLegacy(message);
        // Phase 6α follow-up #6: chat-v2 already emits 'chat_message';
        // chat.gateway subscribes to chat-v2 directly. No re-emit here to
        // avoid double-broadcasting to WebSocket clients.
        return legacyMessage;
    }
    // ---------------------------------------------------------------------------
    // Reads
    // ---------------------------------------------------------------------------
    async getMessages(filter) {
        if (!filter.conversationId) {
            // Legacy callers occasionally call with no conversationId to get
            // everything. chat-v2 has no global query — return [] and let
            // the migration of those call sites surface explicit filters.
            return [];
        }
        // Phase 6α follow-up #4: honor filter.limit. Defaults to the
        // previous hardcoded 200, capped at 1000 so a malicious or buggy
        // caller can't ask for an unbounded slice. Sub-1 values fall back
        // to the default.
        const requested = typeof filter.limit === 'number' && Number.isFinite(filter.limit) && filter.limit > 0
            ? Math.floor(filter.limit)
            : LEGACY_DEFAULT_PAGE_SIZE;
        const limit = Math.min(requested, LEGACY_MAX_PAGE_SIZE);
        const page = this.chatV2.listMessages({
            channelId: filter.conversationId,
            principal: SYSTEM_PRINCIPAL,
            limit,
            direction: 'forward',
        });
        return page.messages.map(v2MessageToLegacy);
    }
    async getMessageCount(filter) {
        if (!filter.conversationId)
            return 0;
        return this.chatV2.countChannelMessages(filter.conversationId, SYSTEM_PRINCIPAL);
    }
    async getMessage(_conversationId, _messageId) {
        // chat-v2 has no per-message lookup yet; return null. Callers that
        // depend on this should be migrated to read the row from the
        // `chat_messages` table directly.
        return null;
    }
    async updateMessageMetadata(_conversationId, messageId, metadataPatch) {
        const dto = this.chatV2.updateMessageMetadata(messageId, metadataPatch);
        return dto ? v2MessageToLegacy(dto) : null;
    }
    async getMessagesWithPendingSlackDelivery(maxAgeMs) {
        return this.chatV2.findMessagesWithPendingSlackDelivery(maxAgeMs).map(v2MessageToLegacy);
    }
    async getConversations(_filter) {
        const channels = this.chatV2.listChannels({ principal: SYSTEM_PRINCIPAL });
        return channels.map((c) => v2ChannelToLegacy(c, this.chatV2.countChannelMessages(c.id, SYSTEM_PRINCIPAL)));
    }
    async getConversation(id) {
        try {
            const channel = this.chatV2.getChannel(id, SYSTEM_PRINCIPAL);
            return v2ChannelToLegacy(channel, this.chatV2.countChannelMessages(id, SYSTEM_PRINCIPAL));
        }
        catch {
            return null;
        }
    }
    async createNewConversation(title, idOverride, _channelType) {
        const conversationId = idOverride ?? `web-conv-${Date.now()}`;
        const channel = this.chatV2.ensureChannelForLegacyConversation({
            conversationId,
            agentSession: 'crewly-orc',
            name: title ?? conversationId,
        });
        return v2ChannelToLegacy(channel, 0);
    }
    async updateConversationTitle(id, title) {
        const channel = this.chatV2.renameChannel(id, title, SYSTEM_PRINCIPAL);
        return v2ChannelToLegacy(channel, this.chatV2.countChannelMessages(id, SYSTEM_PRINCIPAL));
    }
    async archiveConversation(id) {
        this.chatV2.archiveChannel(id, SYSTEM_PRINCIPAL);
    }
    async unarchiveConversation(id) {
        this.chatV2.unarchiveChannel(id, SYSTEM_PRINCIPAL);
    }
    async deleteConversation(id) {
        this.chatV2.deleteChannel(id, SYSTEM_PRINCIPAL);
    }
    async clearConversation(id) {
        this.chatV2.clearChannel(id, SYSTEM_PRINCIPAL);
    }
    async getCurrentConversation() {
        // Frontend should adopt "latest channel" via listChannels;
        // here we approximate by returning the most recently touched channel.
        const channels = this.chatV2.listChannels({ principal: SYSTEM_PRINCIPAL });
        if (channels.length === 0)
            return null;
        const newest = channels.reduce((a, b) => (a.lastMessageAt ?? a.createdAt) > (b.lastMessageAt ?? b.createdAt) ? a : b);
        return v2ChannelToLegacy(newest, this.chatV2.countChannelMessages(newest.id, SYSTEM_PRINCIPAL));
    }
    emitTypingIndicator(conversationId, sender, isTyping) {
        this.emit('chat_typing', {
            type: 'chat_typing',
            data: { conversationId, sender, isTyping },
        });
    }
    emitProgress(conversationId, text) {
        // Transient — emit but don't persist. Frontend renders progress
        // ephemerally. Legacy behaviour preserved.
        this.emit('chat_message', {
            type: 'chat_message',
            data: {
                id: `progress-${Date.now()}`,
                conversationId,
                from: { type: 'system', id: 'system', name: 'System' },
                content: text,
                contentType: 'system',
                status: 'sent',
                timestamp: new Date().toISOString(),
                metadata: { ephemeral: true },
            },
        });
    }
    async getStatistics() {
        const s = this.chatV2.getStatistics();
        return {
            totalConversations: s.totalChannels,
            activeConversations: s.activeChannels,
            archivedConversations: s.archivedChannels,
            totalMessages: s.totalMessages,
        };
    }
    // ---------------------------------------------------------------------------
    // Helpers
    // ---------------------------------------------------------------------------
    /**
     * Generate a conversationId for a brand-new sendMessage call that
     * didn't supply one. Web-chat-style id; Slack callers always pass
     * their own slack-CHANNEL-TS identifier.
     */
    synthesizeConversationId(input) {
        if (typeof input.metadata?.channelId === 'string') {
            // Slack source — derive from channel + timestamp pattern.
            const channel = input.metadata.channelId;
            const ts = typeof input.metadata.threadTs === 'string' ? input.metadata.threadTs : `${Date.now()}`;
            return synthesizeSlackConversationId(channel, ts);
        }
        return `web-conv-${Date.now()}`;
    }
    /**
     * @deprecated Will be removed when the façade is retired.
     */
    async ensureInitialized() {
        /* no-op */
    }
}
// =============================================================================
// Singleton accessors — preserved for source compatibility
// =============================================================================
let _instance = null;
export function getChatService(options) {
    if (!_instance) {
        _instance = new ChatService(options);
    }
    return _instance;
}
export function resetChatService() {
    _instance = null;
}
//# sourceMappingURL=chat.service.js.map