import {
  findOneWithDecryption,
  findWithDecryption
} from "@open-mercato/shared/lib/encryption/find";
import { Organization } from "@open-mercato/core/modules/directory/data/entities";
import {
  AiChatConversation,
  AiChatConversationParticipant,
  AiChatMessage
} from "../entities.js";
const DEFAULT_LIST_LIMIT = 50;
const MAX_LIST_LIMIT = 100;
const DEFAULT_TRANSCRIPT_LIMIT = 100;
const MAX_TRANSCRIPT_LIMIT = 200;
class AiChatConversationAccessError extends Error {
  constructor(message = "Conversation is not accessible to the caller.") {
    super(message);
    this.name = "AiChatConversationAccessError";
  }
}
class AiChatConversationDuplicateParticipantError extends Error {
  constructor(message = "User is already an active participant in this conversation.") {
    super(message);
    this.name = "AiChatConversationDuplicateParticipantError";
  }
}
class AiChatParticipantNotFoundError extends Error {
  constructor(message = "Participant not found or already revoked.") {
    super(message);
    this.name = "AiChatParticipantNotFoundError";
  }
}
class AiChatConversationOrgNotFoundError extends Error {
  constructor(message = "Organization does not exist or is inactive for this tenant.") {
    super(message);
    this.name = "AiChatConversationOrgNotFoundError";
  }
}
class AiChatConversationRepository {
  constructor(em) {
    this.em = em;
  }
  /**
   * Idempotent create. If a non-deleted conversation already exists for the
   * caller in this tenant/org with the same `conversationId`, returns the
   * existing row. The owner-participant row is created in the same
   * transaction; a partial failure leaves no orphan conversation.
   */
  async createOrGet(input, ctx) {
    assertContext(ctx, "createOrGet");
    if (!input?.agentId) {
      throw new Error("AiChatConversationRepository.createOrGet requires agentId");
    }
    const now = input.now ?? /* @__PURE__ */ new Date();
    const conversationId = (input.conversationId ?? "").trim() || generateConversationId();
    return this.em.transactional(async (tx) => {
      const existing = await findOneAccessibleConversation(
        tx,
        conversationId,
        ctx
      );
      if (existing) {
        if (existing.ownerUserId !== ctx.userId) {
          throw new AiChatConversationAccessError();
        }
        return existing;
      }
      await assertOrganizationExists(tx, ctx);
      const conversation = tx.create(AiChatConversation, {
        tenantId: ctx.tenantId,
        organizationId: ctx.organizationId ?? null,
        conversationId,
        agentId: input.agentId,
        ownerUserId: ctx.userId,
        title: normalizeTitle(input.title),
        status: "open",
        visibility: "private",
        pageContext: input.pageContext ?? null,
        lastMessageAt: null,
        importedFromLocalAt: input.importedFromLocal ? now : null,
        createdAt: now,
        updatedAt: now,
        deletedAt: null
      });
      const participant = tx.create(AiChatConversationParticipant, {
        tenantId: ctx.tenantId,
        organizationId: ctx.organizationId ?? null,
        conversationId,
        userId: ctx.userId,
        role: "owner",
        lastReadAt: null,
        createdAt: now,
        updatedAt: now
      });
      await tx.persist(conversation).persist(participant).flush();
      return conversation;
    });
  }
  /** Fetch within tenant/org. View-only callers see only their own conversations. */
  async getById(conversationId, ctx) {
    assertContext(ctx, "getById");
    if (!conversationId) return null;
    const row = await findOneAccessibleConversation(this.em, conversationId, ctx);
    if (!row) return null;
    const isParticipant = !canManageConversations(ctx) && row.ownerUserId !== ctx.userId ? await this.loadParticipantFlag(
      this.em,
      ctx.tenantId,
      ctx.organizationId,
      row.conversationId,
      ctx.userId
    ) : false;
    if (!canAccessConversation(row, ctx, isParticipant)) return null;
    return row;
  }
  /** Owner-scoped list unless the caller has tenant/org manage access. Participants also see shared conversations. */
  async list(ctx, options = {}) {
    assertContext(ctx, "list");
    const limit = clampLimit(options.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
    const where = {
      tenantId: ctx.tenantId,
      organizationId: ctx.organizationId ?? null,
      deletedAt: null
    };
    if (!canManageConversations(ctx)) {
      const participantFilter = {
        tenantId: ctx.tenantId,
        userId: ctx.userId,
        deletedAt: null,
        ...ctx.organizationId ? { organizationId: ctx.organizationId } : {}
      };
      const participantRows = await findWithDecryption(
        this.em,
        AiChatConversationParticipant,
        participantFilter,
        { fields: ["conversationId"] },
        { tenantId: ctx.tenantId ?? null, organizationId: ctx.organizationId ?? null }
      );
      const participantConvIds = participantRows.map((p) => p.conversationId);
      if (participantConvIds.length > 0) {
        where.$or = [
          { ownerUserId: ctx.userId },
          { conversationId: { $in: participantConvIds } }
        ];
      } else {
        where.ownerUserId = ctx.userId;
      }
    }
    if (options.agentId) where.agentId = options.agentId;
    if (options.status) where.status = options.status;
    if (options.cursor) {
      const cursorDate = parseIso(options.cursor);
      if (cursorDate) {
        where.lastMessageAt = { $lt: cursorDate };
      }
    }
    const rows = await findWithDecryption(
      this.em,
      AiChatConversation,
      where,
      {
        orderBy: [{ lastMessageAt: "desc" }, { createdAt: "desc" }],
        limit: limit + 1
      },
      {
        tenantId: ctx.tenantId ?? null,
        organizationId: ctx.organizationId ?? null
      }
    );
    let nextCursor = null;
    if (rows.length > limit) {
      const lastIncluded = rows[limit - 1];
      const cursorValue = lastIncluded.lastMessageAt ?? lastIncluded.createdAt;
      nextCursor = cursorValue ? cursorValue.toISOString() : null;
    }
    return { items: rows.slice(0, limit), nextCursor };
  }
  /** Update within tenant/org. View-only callers can update only their own conversations. */
  async update(conversationId, patch, ctx) {
    assertContext(ctx, "update");
    if (!conversationId) {
      throw new Error("AiChatConversationRepository.update requires conversationId");
    }
    return this.em.transactional(async (tx) => {
      const existing = await findOneAccessibleConversation(
        tx,
        conversationId,
        ctx
      );
      if (!existing) {
        throw new AiChatConversationAccessError(
          `Conversation "${conversationId}" was not found for the caller.`
        );
      }
      if (!canAccessConversation(existing, ctx)) {
        throw new AiChatConversationAccessError();
      }
      const now = patch.now ?? /* @__PURE__ */ new Date();
      if (Object.prototype.hasOwnProperty.call(patch, "title")) {
        existing.title = normalizeTitle(patch.title);
      }
      if (patch.status) existing.status = patch.status;
      if (Object.prototype.hasOwnProperty.call(patch, "pageContext")) {
        existing.pageContext = patch.pageContext ?? null;
      }
      existing.updatedAt = now;
      await tx.persist(existing).flush();
      return existing;
    });
  }
  /** Soft-delete the conversation and all its messages in one transaction. */
  async softDelete(conversationId, ctx, now = /* @__PURE__ */ new Date()) {
    assertContext(ctx, "softDelete");
    if (!conversationId) {
      throw new Error("AiChatConversationRepository.softDelete requires conversationId");
    }
    await this.em.transactional(async (tx) => {
      const existing = await findOneAccessibleConversation(
        tx,
        conversationId,
        ctx
      );
      if (!existing) {
        throw new AiChatConversationAccessError(
          `Conversation "${conversationId}" was not found for the caller.`
        );
      }
      if (!canAccessConversation(existing, ctx)) {
        throw new AiChatConversationAccessError();
      }
      existing.deletedAt = now;
      existing.status = "closed";
      existing.updatedAt = now;
      await tx.persist(existing).flush();
      const messages = await findWithDecryption(
        tx,
        AiChatMessage,
        {
          tenantId: ctx.tenantId,
          organizationId: ctx.organizationId ?? null,
          conversationId,
          deletedAt: null
        },
        {},
        {
          tenantId: ctx.tenantId ?? null,
          organizationId: ctx.organizationId ?? null
        }
      );
      for (const msg of messages) {
        msg.deletedAt = now;
        msg.updatedAt = now;
        tx.persist(msg);
      }
      if (messages.length > 0) await tx.flush();
    });
  }
  /**
   * Owner-only transcript hydration. Internally fetched DESC so the `before`
   * cursor naturally advances toward older messages, then reversed so the
   * response contract (`messages` array ordered ascending by `createdAt`)
   * stays stable for callers. `nextCursor` points to the OLDEST message in
   * the returned page — the next call with `before=<cursor>` fetches the
   * next-older window.
   */
  async getTranscript(conversationId, ctx, options = {}) {
    assertContext(ctx, "getTranscript");
    if (!conversationId) return null;
    const conversation = await this.getById(conversationId, ctx);
    if (!conversation) return null;
    const limit = clampLimit(options.limit, DEFAULT_TRANSCRIPT_LIMIT, MAX_TRANSCRIPT_LIMIT);
    const where = {
      tenantId: ctx.tenantId,
      organizationId: ctx.organizationId ?? null,
      conversationId,
      deletedAt: null
    };
    if (options.before) {
      const beforeDate = parseIso(options.before);
      if (beforeDate) {
        where.createdAt = { $lt: beforeDate };
      }
    }
    const rows = await findWithDecryption(
      this.em,
      AiChatMessage,
      where,
      {
        orderBy: { createdAt: "desc" },
        limit: limit + 1
      },
      {
        tenantId: ctx.tenantId ?? null,
        organizationId: ctx.organizationId ?? null
      }
    );
    let nextCursor = null;
    let pageDesc;
    if (rows.length > limit) {
      pageDesc = rows.slice(0, limit);
      const oldestIncluded = pageDesc[pageDesc.length - 1];
      nextCursor = oldestIncluded?.createdAt ? oldestIncluded.createdAt.toISOString() : null;
    } else {
      pageDesc = rows;
    }
    const messages = [...pageDesc].reverse();
    return { conversation, messages, nextCursor };
  }
  /**
   * Append a single message to an owner-accessible conversation. Honors
   * `clientMessageId` idempotency: if a non-deleted message with the same
   * client id already exists, returns it untouched.
   */
  async appendMessage(conversationId, input, ctx, options = {}) {
    assertContext(ctx, "appendMessage");
    if (!conversationId) {
      throw new Error("AiChatConversationRepository.appendMessage requires conversationId");
    }
    return this.em.transactional(async (tx) => {
      const conversation = await findOneAccessibleConversation(
        tx,
        conversationId,
        ctx
      );
      if (!conversation) {
        throw new AiChatConversationAccessError(
          `Conversation "${conversationId}" was not found for the caller.`
        );
      }
      if (conversation.ownerUserId !== ctx.userId) {
        throw new AiChatConversationAccessError();
      }
      const now = options.createdAt ?? /* @__PURE__ */ new Date();
      if (input.clientMessageId) {
        const existing = await findOneWithDecryption(
          tx,
          AiChatMessage,
          {
            tenantId: ctx.tenantId,
            organizationId: ctx.organizationId ?? null,
            conversationId,
            clientMessageId: input.clientMessageId,
            deletedAt: null
          },
          {},
          {
            tenantId: ctx.tenantId ?? null,
            organizationId: ctx.organizationId ?? null
          }
        );
        if (existing) return existing;
      }
      const message = tx.create(AiChatMessage, {
        tenantId: ctx.tenantId,
        organizationId: ctx.organizationId ?? null,
        conversationId,
        clientMessageId: input.clientMessageId ?? null,
        role: input.role,
        content: input.content,
        uiParts: normalizeArray(input.uiParts),
        attachmentIds: normalizeArray(input.attachmentIds),
        filesMetadata: normalizeArray(input.files),
        model: input.model ?? null,
        metadata: input.metadata ?? null,
        createdByUserId: options.createdByUserId === void 0 ? input.role === "user" ? ctx.userId : null : options.createdByUserId,
        createdAt: now,
        updatedAt: now,
        deletedAt: null
      });
      conversation.lastMessageAt = now;
      conversation.updatedAt = now;
      await tx.persist(message).persist(conversation).flush();
      return message;
    });
  }
  /**
   * Lazy migration entrypoint: create-or-get the conversation and append the
   * provided messages with `clientMessageId` dedupe. Designed to be safe to
   * call repeatedly — repeated imports of the same payload return the same
   * counts of imported/skipped rows.
   */
  async importLocalConversation(input, ctx, now = /* @__PURE__ */ new Date()) {
    assertContext(ctx, "importLocalConversation");
    const conversation = await this.createOrGet(
      { ...input.conversation, importedFromLocal: true, now },
      ctx
    );
    if (input.conversation.status && conversation.status !== input.conversation.status) {
      await this.update(
        conversation.conversationId,
        { status: input.conversation.status, now },
        ctx
      );
    }
    let imported = 0;
    let skipped = 0;
    for (const message of input.messages) {
      if (!message.clientMessageId) {
        skipped += 1;
        continue;
      }
      const before = await findOneWithDecryption(
        this.em,
        AiChatMessage,
        {
          tenantId: ctx.tenantId,
          organizationId: ctx.organizationId ?? null,
          conversationId: conversation.conversationId,
          clientMessageId: message.clientMessageId,
          deletedAt: null
        },
        {},
        {
          tenantId: ctx.tenantId ?? null,
          organizationId: ctx.organizationId ?? null
        }
      );
      if (before) {
        skipped += 1;
        continue;
      }
      await this.appendMessage(
        conversation.conversationId,
        message,
        ctx,
        { createdAt: now }
      );
      imported += 1;
    }
    return {
      conversation,
      importedMessageCount: imported,
      skippedMessageCount: skipped
    };
  }
  async listParticipants(conversationId, ctx) {
    assertContext(ctx, "listParticipants");
    const conv = await findOneAccessibleConversation(this.em, conversationId, ctx);
    if (!conv) {
      throw new AiChatConversationAccessError(
        `Conversation "${conversationId}" was not found for the caller.`
      );
    }
    if (conv.ownerUserId !== ctx.userId && !canManageConversations(ctx)) {
      throw new AiChatConversationAccessError(
        "Only the conversation owner or a manager can list participants."
      );
    }
    const filter = {
      tenantId: ctx.tenantId,
      conversationId,
      deletedAt: null,
      ...ctx.organizationId ? { organizationId: ctx.organizationId } : {}
    };
    return findWithDecryption(
      this.em,
      AiChatConversationParticipant,
      filter,
      { orderBy: { createdAt: "asc" } },
      { tenantId: ctx.tenantId ?? null, organizationId: ctx.organizationId ?? null }
    );
  }
  async addParticipant(conversationId, userId, role, ctx) {
    assertContext(ctx, "addParticipant");
    return this.em.transactional(async (tx) => {
      const conv = await findOneAccessibleConversation(
        tx,
        conversationId,
        ctx
      );
      if (!conv) {
        throw new AiChatConversationAccessError(
          `Conversation "${conversationId}" was not found for the caller.`
        );
      }
      if (conv.ownerUserId !== ctx.userId) {
        throw new AiChatConversationAccessError(
          "Only the conversation owner can add participants."
        );
      }
      const existingFilter = {
        tenantId: ctx.tenantId,
        conversationId,
        userId,
        ...ctx.organizationId ? { organizationId: ctx.organizationId } : {}
      };
      const existing = await findOneWithDecryption(
        tx,
        AiChatConversationParticipant,
        existingFilter
      );
      if (existing) {
        if (existing.deletedAt === null) {
          throw new AiChatConversationDuplicateParticipantError();
        }
        existing.deletedAt = null;
        existing.role = role;
        await tx.persist(existing).flush();
        if (conv.visibility === "private") {
          conv.visibility = "shared";
          await tx.persist(conv).flush();
        }
        return existing;
      }
      const participant = tx.create(AiChatConversationParticipant, {
        tenantId: ctx.tenantId,
        organizationId: ctx.organizationId ?? null,
        conversationId,
        userId,
        role
      });
      if (conv.visibility === "private") {
        conv.visibility = "shared";
      }
      await tx.persist(participant).persist(conv).flush();
      return participant;
    });
  }
  async revokeParticipant(conversationId, targetUserId, ctx) {
    assertContext(ctx, "revokeParticipant");
    await this.em.transactional(async (tx) => {
      const conv = await findOneAccessibleConversation(
        tx,
        conversationId,
        ctx
      );
      if (!conv) {
        throw new AiChatConversationAccessError(
          `Conversation "${conversationId}" was not found for the caller.`
        );
      }
      if (conv.ownerUserId !== ctx.userId) {
        throw new AiChatConversationAccessError(
          "Only the conversation owner can revoke participants."
        );
      }
      if (targetUserId === conv.ownerUserId) {
        throw new AiChatConversationAccessError("Cannot revoke the conversation owner.");
      }
      const participantFilter = {
        tenantId: ctx.tenantId,
        conversationId,
        userId: targetUserId,
        deletedAt: null,
        ...ctx.organizationId ? { organizationId: ctx.organizationId } : {}
      };
      const participant = await findOneWithDecryption(
        tx,
        AiChatConversationParticipant,
        participantFilter
      );
      if (!participant) throw new AiChatParticipantNotFoundError();
      participant.deletedAt = /* @__PURE__ */ new Date();
      const remainingCount = await tx.count(AiChatConversationParticipant, {
        tenantId: ctx.tenantId,
        conversationId,
        deletedAt: null,
        role: { $ne: "owner" }
      });
      if (remainingCount <= 1) {
        conv.visibility = "private";
        await tx.persist(conv);
      }
      await tx.persist(participant).flush();
    });
  }
  async getParticipantCount(tenantId, organizationId, conversationId) {
    return this.em.count(AiChatConversationParticipant, {
      tenantId,
      conversationId,
      deletedAt: null,
      role: { $ne: "owner" },
      ...organizationId ? { organizationId } : {}
    });
  }
  async loadParticipantFlag(em, tenantId, organizationId, conversationId, userId) {
    const row = await findOneWithDecryption(
      em,
      AiChatConversationParticipant,
      {
        tenantId,
        conversationId,
        userId,
        deletedAt: null,
        ...organizationId ? { organizationId } : {}
      }
    );
    return row !== null;
  }
}
function assertContext(ctx, method) {
  if (!ctx?.tenantId) {
    throw new Error(`AiChatConversationRepository.${method} requires tenantId`);
  }
  if (!ctx?.userId) {
    throw new Error(`AiChatConversationRepository.${method} requires userId`);
  }
}
function canManageConversations(ctx) {
  return ctx.canManageConversations === true;
}
function canAccessConversation(row, ctx, isParticipant = false) {
  return canManageConversations(ctx) || row.ownerUserId === ctx.userId || isParticipant;
}
async function assertOrganizationExists(em, ctx) {
  if (!ctx.organizationId) return;
  const org = await findOneWithDecryption(
    em,
    Organization,
    {
      id: ctx.organizationId,
      tenant: ctx.tenantId,
      deletedAt: null,
      isActive: true
    },
    {},
    {
      tenantId: ctx.tenantId ?? null,
      organizationId: ctx.organizationId ?? null
    }
  );
  if (!org) {
    throw new AiChatConversationOrgNotFoundError(
      `Organization "${ctx.organizationId}" does not exist or is inactive in tenant "${ctx.tenantId}".`
    );
  }
}
async function findOneAccessibleConversation(em, conversationId, ctx) {
  const row = await findOneWithDecryption(
    em,
    AiChatConversation,
    {
      tenantId: ctx.tenantId,
      organizationId: ctx.organizationId ?? null,
      conversationId,
      deletedAt: null
    },
    {},
    {
      tenantId: ctx.tenantId ?? null,
      organizationId: ctx.organizationId ?? null
    }
  );
  return row ?? null;
}
function normalizeTitle(title) {
  if (title === void 0) return null;
  if (title === null) return null;
  const trimmed = title.trim();
  return trimmed.length > 0 ? trimmed : null;
}
function normalizeArray(value) {
  if (!Array.isArray(value) || value.length === 0) return null;
  return value;
}
function clampLimit(value, fallback, max) {
  if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
  return Math.max(1, Math.min(Math.floor(value), max));
}
function parseIso(value) {
  if (!value) return null;
  const date = new Date(value);
  return Number.isNaN(date.getTime()) ? null : date;
}
function generateConversationId() {
  const cryptoMod = typeof globalThis === "object" ? globalThis.crypto : void 0;
  if (cryptoMod?.randomUUID) return cryptoMod.randomUUID();
  return `chat_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
}
var AiChatConversationRepository_default = AiChatConversationRepository;
export {
  AiChatConversationAccessError,
  AiChatConversationDuplicateParticipantError,
  AiChatConversationOrgNotFoundError,
  AiChatConversationRepository,
  AiChatParticipantNotFoundError,
  AiChatConversationRepository_default as default
};
//# sourceMappingURL=AiChatConversationRepository.js.map
