import { randomUUID } from "node:crypto";
import { z } from "zod";
import { registerCommand } from "@open-mercato/shared/lib/commands";
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
import { emitCommunicationChannelsEvent } from "../events.js";
import { resolveContact } from "../lib/contact-resolver.js";
import { matchThread } from "../lib/thread-matcher.js";
import {
  ChannelThreadMapping,
  CommunicationChannel,
  ExternalConversation,
  ExternalMessage,
  MessageChannelLink
} from "../data/entities.js";
import { normalizedInboundMessageSchema } from "../data/validators.js";
import { resolveCommunicationChannelsSystemUserId } from "../lib/system-user.js";
import { isUniqueViolation } from "../lib/pg-errors.js";
import { createLogger } from "@open-mercato/shared/lib/logger";
const logger = createLogger("communication_channels").child({ component: "ingest-inbound-message" });
const ingestInputSchema = z.object({
  channelId: z.string().uuid(),
  providerKey: z.string().min(1),
  channelType: z.string().min(1),
  scope: z.object({
    tenantId: z.string().uuid(),
    organizationId: z.string().uuid().nullable()
  }),
  message: normalizedInboundMessageSchema
});
const COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID = "communication_channels.message.ingest_inbound";
const ingestInboundMessageCommand = {
  id: COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID,
  async execute(rawInput, ctx) {
    const input = ingestInputSchema.parse(rawInput);
    const em = ctx.container.resolve("em").fork();
    const adapterRegistry = ctx.container.resolve("channelAdapterRegistry");
    const dscope = {
      tenantId: input.scope.tenantId,
      organizationId: input.scope.organizationId ?? null
    };
    const existingExternal = await findOneWithDecryption(
      em,
      ExternalMessage,
      {
        channelId: input.channelId,
        externalMessageId: input.message.externalMessageId,
        tenantId: input.scope.tenantId,
        organizationId: input.scope.organizationId ?? null
      },
      void 0,
      dscope
    );
    if (existingExternal) {
      return {
        status: "duplicate",
        externalConversationId: existingExternal.conversationId,
        externalMessageId: existingExternal.id
      };
    }
    const incomingMessageId = (() => {
      const fromMeta = input.message.channelMetadata?.messageId;
      if (typeof fromMeta === "string" && fromMeta.length > 0) return fromMeta;
      return null;
    })();
    if (incomingMessageId) {
      try {
        const sentFolderHit = await em.getConnection().execute(
          `SELECT link.id FROM message_channel_links AS link
             INNER JOIN external_conversations AS conv
               ON conv.id = link.external_conversation_id
            WHERE link.tenant_id = ?
              AND ((?::uuid IS NULL AND link.organization_id IS NULL) OR link.organization_id = ?::uuid)
              AND conv.tenant_id = ?
              AND ((?::uuid IS NULL AND conv.organization_id IS NULL) OR conv.organization_id = ?::uuid)
              AND conv.channel_id = ?
              AND link.direction = 'outbound'
              AND link.channel_metadata->>'messageId' = ?
            LIMIT 1`,
          [
            input.scope.tenantId,
            input.scope.organizationId ?? null,
            input.scope.organizationId ?? null,
            input.scope.tenantId,
            input.scope.organizationId ?? null,
            input.scope.organizationId ?? null,
            input.channelId,
            incomingMessageId
          ]
        );
        if (Array.isArray(sentFolderHit) && sentFolderHit.length > 0) {
          return {
            status: "duplicate",
            externalConversationId: input.message.externalConversationId,
            externalMessageId: input.message.externalMessageId
          };
        }
      } catch (dedupErr) {
        logger.warn("sent-folder dedup query failed, continuing", { err: dedupErr });
      }
    }
    const channel = await findOneWithDecryption(
      em,
      CommunicationChannel,
      {
        id: input.channelId,
        tenantId: input.scope.tenantId,
        organizationId: input.scope.organizationId ?? null,
        deletedAt: null
      },
      void 0,
      dscope
    );
    if (!channel) {
      throw new Error(
        `[internal] Channel ${input.channelId} not found for tenant ${input.scope.tenantId} (or has been deleted)`
      );
    }
    if (!channel.isActive) {
      throw new Error(`[internal] Channel ${input.channelId} is inactive; refusing to ingest`);
    }
    const adapter = adapterRegistry.get(input.providerKey);
    if (!adapter) {
      throw new Error(
        `[internal] No ChannelAdapter registered for providerKey '${input.providerKey}'. Check that the provider package is enabled in modules.ts.`
      );
    }
    const m = input.message;
    let conversation = await findOneWithDecryption(
      em,
      ExternalConversation,
      {
        channelId: input.channelId,
        externalConversationId: m.externalConversationId,
        tenantId: input.scope.tenantId,
        organizationId: input.scope.organizationId ?? null
      },
      void 0,
      dscope
    );
    let conversationCreated = false;
    if (!conversation) {
      conversation = em.create(ExternalConversation, {
        channelId: input.channelId,
        externalConversationId: m.externalConversationId,
        subject: m.subject ?? null,
        tenantId: input.scope.tenantId,
        organizationId: input.scope.organizationId ?? null,
        lastMessageAt: m.timestamp ?? /* @__PURE__ */ new Date()
      });
      em.persist(conversation);
      conversationCreated = true;
    }
    let mapping = await findOneWithDecryption(
      em,
      ChannelThreadMapping,
      {
        externalConversationId: conversation.id,
        tenantId: input.scope.tenantId,
        organizationId: input.scope.organizationId ?? null
      },
      void 0,
      dscope
    );
    if (!conversationCreated && m.timestamp && (!conversation.lastMessageAt || m.timestamp > conversation.lastMessageAt)) {
      conversation.lastMessageAt = m.timestamp;
    }
    await em.flush();
    const metaForMatcher = m.channelMetadata ?? {};
    let threadMatch = null;
    try {
      threadMatch = await matchThread(
        {
          channelId: input.channelId,
          tenantId: input.scope.tenantId,
          organizationId: input.scope.organizationId ?? null,
          messageId: extractStringFromMeta(metaForMatcher, "messageId") ?? m.externalMessageId,
          inReplyTo: m.replyToExternalId ?? extractStringFromMeta(metaForMatcher, "inReplyTo"),
          references: extractStringArrayFromMeta(metaForMatcher, "references"),
          subject: m.subject ?? "",
          fromAddress: extractStringFromMeta(metaForMatcher, "from") ?? m.senderIdentifier,
          toAddresses: extractStringArrayFromMeta(metaForMatcher, "to"),
          ccAddresses: extractStringArrayFromMeta(metaForMatcher, "cc"),
          bodyPlain: m.bodyFormat === "html" ? null : m.body ?? null,
          bodyHtml: m.bodyFormat === "html" ? m.body ?? null : null,
          receivedAt: m.timestamp ?? /* @__PURE__ */ new Date()
        },
        { em }
      );
    } catch (matcherErr) {
      logger.warn("thread matcher failed, falling back to conversation mapping", { err: matcherErr });
    }
    let contactHint = null;
    try {
      contactHint = await resolveContact(
        {
          adapter,
          senderIdentifier: m.senderIdentifier,
          senderDisplayName: m.senderDisplayName,
          channelMetadata: m.channelMetadata,
          credentials: {},
          // credentials decrypted at the webhook route; resolver doesn't re-fetch
          scope: {
            tenantId: input.scope.tenantId,
            organizationId: input.scope.organizationId ?? input.scope.tenantId
          }
        },
        { container: ctx.container }
      );
    } catch (contactErr) {
      logger.warn("contact resolution failed, continuing without a CRM match", { err: contactErr });
      contactHint = null;
    }
    const matchedPersonId = contactHint?.matchedPersonId ?? null;
    if (matchedPersonId && conversation.contactPersonId !== matchedPersonId) {
      conversation.contactPersonId = matchedPersonId;
      await em.flush();
    }
    const MAX_COMPOSE_BODY = 5e4;
    const TRUNCATE_MARKER = "\n\n[\u2026message truncated by Open Mercato \u2014 full body preserved in ExternalMessage.rawPayload]";
    const rawBody = m.body ?? "";
    const truncatedBody = rawBody.length > MAX_COMPOSE_BODY ? rawBody.slice(0, MAX_COMPOSE_BODY - TRUNCATE_MARKER.length) + TRUNCATE_MARKER : rawBody;
    const safeSubject = (m.subject ?? "").trim() || "(no subject)";
    const composeInput = {
      type: `channel.${input.providerKey}`,
      visibility: "public",
      sourceEntityType: "communication_channels.external_conversation",
      sourceEntityId: conversation.id,
      externalEmail: contactHint?.email ?? void 0,
      externalName: contactHint?.displayName ?? m.senderDisplayName,
      recipients: mapping?.assignedUserId ? [{ userId: mapping.assignedUserId, type: "to" }] : [],
      subject: safeSubject,
      body: truncatedBody,
      bodyFormat: m.bodyFormat === "html" ? "text" : m.bodyFormat,
      priority: "normal",
      sendViaEmail: false,
      // Spec B: matcher-resolved thread id takes priority over the existing
      // conversation-based mapping. Falls through to `mapping?.messageThreadId`
      // when the matcher returned null (no token / JWZ / subject hit).
      parentMessageId: threadMatch?.messageThreadId ?? mapping?.messageThreadId,
      isDraft: false,
      // Stable dedup key so a retried ingest (after a transient failure between
      // compose and the ExternalMessage anchor insert) reuses the message
      // composed by the first attempt instead of duplicating it. Mirrors the
      // (channel, externalMessageId) ExternalMessage anchor's natural key.
      idempotencyKey: m.externalMessageId ? `cc:${input.channelId}:${m.externalMessageId}` : void 0,
      tenantId: input.scope.tenantId,
      organizationId: input.scope.organizationId,
      userId: await resolveCommunicationChannelsSystemUserId(
        em,
        input.scope.tenantId,
        mapping?.assignedUserId ?? null
      )
    };
    const commandBus = ctx.container.resolve("commandBus");
    const composeResult = await commandBus.execute(
      "messages.messages.compose",
      {
        input: composeInput,
        ctx: passthroughCommandCtx(ctx, input.scope)
      }
    );
    const message = composeResult.result;
    if (!message?.id) {
      throw new Error("messages.messages.compose did not return a message id");
    }
    if (!mapping) {
      mapping = em.create(ChannelThreadMapping, {
        externalConversationId: conversation.id,
        messageThreadId: message.threadId ?? message.id,
        channelId: input.channelId,
        providerKey: input.providerKey,
        externalThreadRef: m.externalConversationId,
        tenantId: input.scope.tenantId,
        organizationId: input.scope.organizationId ?? null
      });
      em.persist(mapping);
    }
    const externalMessageRowId = randomUUID();
    const channelLinkRowId = randomUUID();
    const externalMessage = em.create(ExternalMessage, {
      id: externalMessageRowId,
      channelId: input.channelId,
      conversationId: conversation.id,
      externalMessageId: m.externalMessageId,
      direction: "inbound",
      senderIdentifier: m.senderIdentifier,
      senderDisplayName: m.senderDisplayName ?? null,
      providerTimestamp: m.timestamp,
      tenantId: input.scope.tenantId,
      organizationId: input.scope.organizationId ?? null
    });
    em.persist(externalMessage);
    const matcherAnnotatedMetadata = {
      ...m.channelMetadata ?? {},
      threadMatchStrategy: threadMatch?.matchedBy ?? "new-thread",
      threadMatchConfidence: threadMatch?.confidence ?? "low"
    };
    const channelLink = em.create(MessageChannelLink, {
      id: channelLinkRowId,
      messageId: message.id,
      externalConversationId: conversation.id,
      externalMessageId: externalMessageRowId,
      providerKey: input.providerKey,
      channelType: input.channelType,
      direction: "inbound",
      deliveryStatus: "received",
      channelPayload: m.channelPayload,
      channelContentType: m.channelContentType,
      channelMetadata: matcherAnnotatedMetadata,
      tenantId: input.scope.tenantId,
      organizationId: input.scope.organizationId ?? null
    });
    em.persist(channelLink);
    try {
      await em.flush();
    } catch (flushErr) {
      if (isUniqueViolation(flushErr)) {
        return {
          status: "duplicate",
          externalConversationId: conversation.id,
          externalMessageId: m.externalMessageId
        };
      }
      throw flushErr;
    }
    if (conversationCreated) {
      await emitCommunicationChannelsEvent(
        "communication_channels.conversation.created",
        {
          conversationId: conversation.id,
          channelId: input.channelId,
          providerKey: input.providerKey,
          channelType: input.channelType,
          externalConversationId: m.externalConversationId,
          tenantId: input.scope.tenantId,
          organizationId: input.scope.organizationId ?? null
        },
        { persistent: true }
      );
    }
    if (matchedPersonId) {
      await emitCommunicationChannelsEvent(
        "communication_channels.contact.resolved",
        {
          conversationId: conversation.id,
          contactPersonId: matchedPersonId,
          providerKey: input.providerKey,
          tenantId: input.scope.tenantId,
          organizationId: input.scope.organizationId ?? null
        },
        { persistent: true }
      );
    }
    await emitCommunicationChannelsEvent(
      "communication_channels.message.received",
      {
        messageId: message.id,
        externalMessageId: externalMessage.id,
        channelLinkId: channelLink.id,
        conversationId: conversation.id,
        channelId: input.channelId,
        providerKey: input.providerKey,
        channelType: input.channelType,
        direction: "inbound",
        tenantId: input.scope.tenantId,
        organizationId: input.scope.organizationId ?? null
      },
      { persistent: true }
    );
    return {
      status: "created",
      messageId: message.id,
      externalConversationId: conversation.id,
      externalMessageId: externalMessage.id,
      channelLinkId: channelLink.id,
      threadMappingId: mapping.id,
      contactPersonId: matchedPersonId
    };
  }
};
function passthroughCommandCtx(parent, scope) {
  return {
    container: parent.container,
    auth: null,
    organizationScope: null,
    selectedOrganizationId: scope.organizationId ?? null,
    organizationIds: scope.organizationId ? [scope.organizationId] : null
  };
}
function extractStringFromMeta(meta, key) {
  const value = meta[key];
  if (typeof value === "string" && value.length > 0) return value;
  return null;
}
function extractStringArrayFromMeta(meta, key) {
  const value = meta[key];
  if (!Array.isArray(value)) return [];
  return value.filter((item) => typeof item === "string");
}
registerCommand(ingestInboundMessageCommand);
var ingest_inbound_message_default = ingestInboundMessageCommand;
export {
  COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID,
  ingest_inbound_message_default as default
};
//# sourceMappingURL=ingest-inbound-message.js.map
