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 { refreshCredentialsIfNeeded } from "../lib/credential-refresh.js";
import { classifyOutboundError, isReauthError } from "../lib/error-classification.js";
import {
  buildBodyFooter,
  buildReferencesId,
  getOrCreateThreadToken
} from "../lib/thread-token.js";
import { stringOrUndefined, stripBrackets } from "../lib/email-mime.js";
import { isUniqueViolation } from "../lib/pg-errors.js";
import { Message } from "../../messages/data/entities.js";
import {
  ChannelThreadMapping,
  CommunicationChannel,
  ExternalMessage,
  MessageChannelLink
} from "../data/entities.js";
import { createLogger } from "@open-mercato/shared/lib/logger";
const moduleLogger = createLogger("communication_channels").child({ component: "deliver-outbound-message" });
const NO_THREAD_MAPPING_RESULT = { status: "no_channel_link" };
const deliverInputSchema = z.object({
  messageId: z.string().uuid(),
  scope: z.object({
    tenantId: z.string().uuid(),
    organizationId: z.string().uuid().nullable()
  }),
  /**
   * If true, force a credential refresh before sending — used by retry attempts
   * after a 401 from the provider.
   */
  forceCredentialRefresh: z.boolean().optional()
});
const COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID = "communication_channels.message.deliver_outbound";
const deliverOutboundMessageCommand = {
  id: COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID,
  async execute(rawInput, ctx) {
    const input = deliverInputSchema.parse(rawInput);
    const em = ctx.container.resolve("em").fork();
    const dscope = {
      tenantId: input.scope.tenantId,
      organizationId: input.scope.organizationId ?? null
    };
    const message = await findOneWithDecryption(
      em,
      Message,
      {
        id: input.messageId,
        tenantId: input.scope.tenantId,
        organizationId: input.scope.organizationId ?? null,
        deletedAt: null
      },
      void 0,
      dscope
    );
    if (!message) {
      return NO_THREAD_MAPPING_RESULT;
    }
    if (!message.threadId) {
      return NO_THREAD_MAPPING_RESULT;
    }
    const mapping = await findOneWithDecryption(
      em,
      ChannelThreadMapping,
      {
        messageThreadId: message.threadId,
        tenantId: input.scope.tenantId,
        organizationId: input.scope.organizationId ?? null
      },
      void 0,
      dscope
    );
    if (!mapping) {
      return NO_THREAD_MAPPING_RESULT;
    }
    const channel = await findOneWithDecryption(
      em,
      CommunicationChannel,
      {
        id: mapping.channelId,
        tenantId: input.scope.tenantId,
        organizationId: input.scope.organizationId ?? null,
        deletedAt: null
      },
      void 0,
      dscope
    );
    if (!channel) {
      throw new Error(
        `[internal] Channel ${mapping.channelId} not found for tenant ${input.scope.tenantId} (or has been deleted)`
      );
    }
    if (!channel.isActive) {
      throw new Error(`[internal] Channel ${mapping.channelId} is inactive; refusing to deliver outbound`);
    }
    const adapterRegistry = ctx.container.resolve("channelAdapterRegistry");
    const adapter = adapterRegistry.get(channel.providerKey);
    if (!adapter) {
      throw new Error(
        `[internal] No ChannelAdapter registered for providerKey '${channel.providerKey}'. Check that the provider package is enabled in modules.ts.`
      );
    }
    let link = await findOneWithDecryption(
      em,
      MessageChannelLink,
      {
        messageId: message.id,
        tenantId: input.scope.tenantId,
        organizationId: input.scope.organizationId ?? null
      },
      void 0,
      dscope
    );
    if (link && (link.deliveryStatus === "queued" || link.deliveryStatus === "sent" || link.deliveryStatus === "delivered" || link.deliveryStatus === "read")) {
      return {
        status: "already_delivered",
        messageId: message.id,
        channelLinkId: link.id
      };
    }
    if (!link) {
      link = em.create(MessageChannelLink, {
        messageId: message.id,
        externalConversationId: mapping.externalConversationId,
        providerKey: channel.providerKey,
        channelType: channel.channelType,
        direction: "outbound",
        deliveryStatus: "pending",
        tenantId: input.scope.tenantId,
        organizationId: input.scope.organizationId ?? null
      });
      em.persist(link);
      try {
        await em.flush();
      } catch (flushErr) {
        if (isUniqueViolation(flushErr)) {
          const winner = await findOneWithDecryption(
            em.fork(),
            MessageChannelLink,
            {
              messageId: message.id,
              tenantId: input.scope.tenantId,
              organizationId: input.scope.organizationId ?? null
            },
            void 0,
            dscope
          );
          if (winner) {
            return {
              status: "already_delivered",
              messageId: message.id,
              channelLinkId: winner.id
            };
          }
        }
        throw flushErr;
      }
    }
    let credentialsService = null;
    try {
      credentialsService = ctx.container.resolve(
        "integrationCredentialsService"
      );
    } catch {
      credentialsService = null;
    }
    const credentialsScope = {
      tenantId: input.scope.tenantId,
      organizationId: input.scope.organizationId ?? input.scope.tenantId,
      userId: channel.userId ?? null
    };
    let credentials = {};
    if (channel.credentialsRef && credentialsService) {
      try {
        credentials = await credentialsService.resolve(`channel_${channel.providerKey}`, credentialsScope) ?? {};
      } catch {
        credentials = {};
      }
    }
    let integrationLog = null;
    try {
      integrationLog = ctx.container.resolve("integrationLogService");
    } catch {
      integrationLog = null;
    }
    const refreshResult = await refreshCredentialsIfNeeded(
      {
        adapter,
        channelId: channel.id,
        credentials,
        scope: credentialsScope,
        force: Boolean(input.forceCredentialRefresh)
      },
      {
        credentialsService,
        logger: (...args) => moduleLogger.warn("credential refresh diagnostic", { details: args })
      }
    );
    credentials = refreshResult.credentials;
    let threadToken = null;
    try {
      const { token } = await getOrCreateThreadToken(em, {
        tenantId: input.scope.tenantId,
        organizationId: input.scope.organizationId ?? null,
        messageThreadId: mapping.messageThreadId
      });
      threadToken = token;
    } catch (tokenErr) {
      moduleLogger.warn("thread token unavailable, proceeding without it", { err: tokenErr });
    }
    try {
      const outboundPayload = link.channelPayload ?? {};
      const outboundHtml = typeof outboundPayload.html === "string" ? outboundPayload.html : null;
      const outboundText = typeof outboundPayload.text === "string" ? outboundPayload.text : null;
      let outboundBody = outboundHtml ?? outboundText ?? message.body ?? "";
      const outboundBodyFormat = outboundHtml ? "html" : message.bodyFormat ?? "text";
      const baseMetadata = link.channelMetadata ?? {};
      const existingRefs = Array.isArray(baseMetadata.references) ? baseMetadata.references.filter(
        (value) => typeof value === "string"
      ) : [];
      let mergedReferences = existingRefs;
      if (threadToken && !outboundBody.includes(`[OM:${threadToken}]`)) {
        const footer = buildBodyFooter(threadToken);
        if (outboundBodyFormat === "html") {
          const closingBody = outboundBody.lastIndexOf("</body>");
          outboundBody = closingBody >= 0 ? `${outboundBody.slice(0, closingBody)}${footer.html}${outboundBody.slice(closingBody)}` : `${outboundBody}${footer.html}`;
        } else {
          outboundBody = `${outboundBody}${footer.plain}`;
        }
        const refId = buildReferencesId(threadToken);
        if (!mergedReferences.includes(refId)) {
          mergedReferences = [...mergedReferences, refId];
        }
      }
      const converted = await adapter.convertOutbound({
        body: outboundBody,
        bodyFormat: outboundBodyFormat,
        channelMetadata: {
          thread_id: mapping.externalThreadRef,
          ...baseMetadata,
          references: mergedReferences,
          ...threadToken ? { omThreadToken: threadToken } : {}
        }
      });
      const sendResult = await adapter.sendMessage({
        conversationId: mapping.externalThreadRef,
        content: converted.content,
        credentials,
        scope: {
          tenantId: input.scope.tenantId,
          organizationId: input.scope.organizationId ?? input.scope.tenantId
        },
        metadata: converted.metadata
      });
      if (sendResult.status === "failed") {
        throw new Error(sendResult.error ?? `Adapter '${adapter.providerKey}' reported send failure`);
      }
      const externalMessageRowId = randomUUID();
      const externalMessage = em.create(ExternalMessage, {
        id: externalMessageRowId,
        channelId: channel.id,
        conversationId: mapping.externalConversationId,
        externalMessageId: sendResult.externalMessageId,
        direction: "outbound",
        senderIdentifier: null,
        senderDisplayName: null,
        providerTimestamp: /* @__PURE__ */ new Date(),
        tenantId: input.scope.tenantId,
        organizationId: input.scope.organizationId ?? null
      });
      em.persist(externalMessage);
      if (channel.status === "requires_reauth" || channel.status === "error") {
        channel.status = "connected";
      }
      link.deliveryStatus = sendResult.status === "sent" ? "sent" : "queued";
      link.externalMessageId = externalMessageRowId;
      link.channelMetadata = {
        ...link.channelMetadata ?? {},
        ...converted.metadata ?? {},
        externalMessageId: sendResult.externalMessageId,
        // Always persist the RFC2822 Message-ID so inbound reply matching (JWZ
        // strategy in lib/thread-matcher) and sent-folder dedup can resolve this
        // outbound message. Adapters that build the message themselves (Gmail)
        // return it in `converted.metadata.messageId`; IMAP/SMTP lets
        // the transport mint it, surfacing it only as `sendResult.externalMessageId`
        // (the RFC2822 id the recipient replies to) — fall back to that.
        // Store it bracket-stripped to match the inbound convention
        // (`normalizeMimeInbound` strips), so the JWZ matcher and sent-folder dedup —
        // which compare against stripped ids — resolve it. `assembleRfc2822`
        // re-applies brackets when this id is later used to build reply headers.
        messageId: stripBrackets(
          stringOrUndefined(converted.metadata?.messageId) ?? sendResult.externalMessageId
        )
      };
      await em.flush();
      await emitCommunicationChannelsEvent(
        "communication_channels.message.sent",
        {
          messageId: message.id,
          externalMessageId: externalMessage.id,
          channelLinkId: link.id,
          conversationId: mapping.externalConversationId,
          channelId: channel.id,
          providerKey: channel.providerKey,
          channelType: channel.channelType,
          direction: "outbound",
          tenantId: input.scope.tenantId,
          organizationId: input.scope.organizationId ?? null
        },
        { persistent: true }
      );
      return {
        status: "delivered",
        messageId: message.id,
        channelLinkId: link.id,
        externalMessageId: externalMessage.id,
        providerKey: channel.providerKey
      };
    } catch (sendErr) {
      const classification = classifyOutboundError(sendErr);
      const requiresReauth = isReauthError(classification);
      link.deliveryStatus = "failed";
      link.channelMetadata = {
        ...link.channelMetadata ?? {},
        lastError: classification.message,
        lastErrorAt: (/* @__PURE__ */ new Date()).toISOString(),
        transient: classification.transient,
        requiresReauth
      };
      if (requiresReauth) {
        channel.status = "requires_reauth";
      }
      await em.flush();
      try {
        await integrationLog?.error?.({
          integrationId: `channel_${channel.providerKey}`,
          tenantId: input.scope.tenantId,
          organizationId: input.scope.organizationId ?? null,
          channelId: channel.id,
          messageId: message.id,
          status: classification.status ?? null,
          transient: classification.transient,
          message: classification.message
        });
      } catch {
      }
      if (requiresReauth) {
        await emitCommunicationChannelsEvent(
          "communication_channels.channel.requires_reauth",
          {
            channelId: channel.id,
            providerKey: channel.providerKey,
            channelType: channel.channelType,
            reason: classification.message,
            tenantId: input.scope.tenantId,
            organizationId: input.scope.organizationId ?? null
          },
          { persistent: true }
        );
      }
      await emitCommunicationChannelsEvent(
        "communication_channels.message.delivery_failed",
        {
          messageId: message.id,
          channelLinkId: link.id,
          conversationId: mapping.externalConversationId,
          channelId: channel.id,
          providerKey: channel.providerKey,
          channelType: channel.channelType,
          transient: classification.transient,
          error: classification.message,
          status: classification.status ?? null,
          tenantId: input.scope.tenantId,
          organizationId: input.scope.organizationId ?? null
        },
        { persistent: true }
      );
      return {
        status: "failed",
        messageId: message.id,
        channelLinkId: link.id,
        providerKey: channel.providerKey,
        error: classification.message,
        transient: classification.transient,
        requiresReauth
      };
    }
  }
};
registerCommand(deliverOutboundMessageCommand);
var deliver_outbound_message_default = deliverOutboundMessageCommand;
export {
  COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID,
  deliver_outbound_message_default as default
};
//# sourceMappingURL=deliver-outbound-message.js.map
