import { sql } from "kysely";
import { CrudHttpError, conflict } from "@open-mercato/shared/lib/crud/errors";
import { Notification } from "../data/entities.js";
import { NOTIFICATION_EVENTS, NOTIFICATION_SSE_EVENTS } from "./events.js";
import { findOneWithDecryption, findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
import {
  buildNotificationEntity,
  emitNotificationCreated,
  emitNotificationCreatedBatch
} from "./notificationFactory.js";
import { toNotificationDto } from "./notificationMapper.js";
import {
  getRecipientUserIdsForFeature,
  getRecipientUserIdsForRole,
  getScopedNotificationRecipientUserIds
} from "./notificationRecipients.js";
import { assertSafeNotificationHref, sanitizeNotificationActions } from "./safeHref.js";
import { createLogger } from "@open-mercato/shared/lib/logger";
const logger = createLogger("notifications").child({ component: "service" });
function debug(message, ...details) {
  logger.debug(message, details.length ? { details } : void 0);
}
function getDb(em) {
  return em.getKysely();
}
const UNIQUE_NOTIFICATION_ACTIVE_STATUSES = ["unread", "read", "actioned"];
function normalizeOrgScope(organizationId) {
  return organizationId ?? null;
}
async function assertNotificationRecipientsInScope(em, recipientUserIds, ctx) {
  const scopedRecipientUserIds = await getScopedNotificationRecipientUserIds(
    getDb(em),
    ctx.tenantId,
    normalizeOrgScope(ctx.organizationId),
    recipientUserIds
  );
  if (scopedRecipientUserIds.length !== recipientUserIds.length) {
    throw new CrudHttpError(404, { error: "Notification recipient not found" });
  }
}
function applyNotificationContent(notification, input, recipientUserId, ctx) {
  const actions = sanitizeNotificationActions(input.actions);
  const linkHref = assertSafeNotificationHref(input.linkHref);
  notification.recipientUserId = recipientUserId;
  notification.type = input.type;
  notification.titleKey = input.titleKey;
  notification.bodyKey = input.bodyKey;
  notification.titleVariables = input.titleVariables;
  notification.bodyVariables = input.bodyVariables;
  notification.title = input.title || input.titleKey || "";
  notification.body = input.body;
  notification.icon = input.icon;
  notification.severity = input.severity ?? "info";
  notification.actionData = actions ? {
    actions,
    primaryActionId: input.primaryActionId
  } : null;
  notification.sourceModule = input.sourceModule;
  notification.sourceEntityType = input.sourceEntityType;
  notification.sourceEntityId = input.sourceEntityId;
  notification.linkHref = linkHref;
  notification.groupKey = input.groupKey;
  notification.expiresAt = input.expiresAt ? new Date(input.expiresAt) : null;
  notification.tenantId = ctx.tenantId;
  notification.organizationId = normalizeOrgScope(ctx.organizationId);
  notification.status = "unread";
  notification.readAt = null;
  notification.actionedAt = null;
  notification.dismissedAt = null;
  notification.actionTaken = null;
  notification.actionResult = null;
  notification.createdAt = /* @__PURE__ */ new Date();
}
async function findScopedNotificationOrThrow(em, notificationId, ctx) {
  const notification = await findOneWithDecryption(
    em,
    Notification,
    {
      id: notificationId,
      recipientUserId: ctx.userId,
      tenantId: ctx.tenantId
    },
    void 0,
    {
      tenantId: ctx.tenantId,
      organizationId: ctx.organizationId ?? null
    }
  );
  if (!notification) {
    throw new CrudHttpError(404, { error: "Notification not found" });
  }
  return notification;
}
async function emitNotificationSseEvents(eventBus, notifications, ctx, recipientUserIds) {
  await eventBus.emit(NOTIFICATION_SSE_EVENTS.BATCH_CREATED, {
    tenantId: ctx.tenantId,
    organizationId: normalizeOrgScope(ctx.organizationId),
    recipientUserIds,
    count: notifications.length
  });
  for (const notification of notifications) {
    await eventBus.emit(NOTIFICATION_SSE_EVENTS.CREATED, {
      tenantId: notification.tenantId,
      organizationId: notification.organizationId ?? null,
      recipientUserId: notification.recipientUserId,
      notification: toNotificationDto(notification)
    });
  }
}
async function createOrRefreshNotification(em, input, recipientUserId, ctx) {
  if (input.groupKey && input.groupKey.trim().length > 0) {
    const orgScope = normalizeOrgScope(ctx.organizationId) ?? "global";
    const lockKey = `notifications:${ctx.tenantId}:${orgScope}:${recipientUserId}:${input.type}:${input.groupKey}`;
    try {
      const db = getDb(em);
      await sql`select pg_advisory_xact_lock(hashtext(${lockKey}))`.execute(db);
    } catch {
    }
    const existing = await em.findOne(Notification, {
      recipientUserId,
      tenantId: ctx.tenantId,
      organizationId: normalizeOrgScope(ctx.organizationId),
      type: input.type,
      groupKey: input.groupKey,
      status: { $in: UNIQUE_NOTIFICATION_ACTIVE_STATUSES }
    }, {
      orderBy: { createdAt: "desc" }
    });
    if (existing) {
      applyNotificationContent(existing, input, recipientUserId, ctx);
      return existing;
    }
  }
  return buildNotificationEntity(em, input, recipientUserId, ctx);
}
function createNotificationService(deps) {
  const { em: rootEm, eventBus, commandBus, container } = deps;
  return {
    async create(input, ctx) {
      const { recipientUserId, ...content } = input;
      const writeEm = rootEm.fork();
      const notification = await writeEm.transactional(async (tx) => {
        await assertNotificationRecipientsInScope(tx, [recipientUserId], ctx);
        const entity = await createOrRefreshNotification(tx, content, recipientUserId, ctx);
        await tx.flush();
        return entity;
      });
      await emitNotificationCreated(eventBus, notification, ctx);
      await eventBus.emit(NOTIFICATION_SSE_EVENTS.CREATED, {
        tenantId: notification.tenantId,
        organizationId: notification.organizationId ?? null,
        recipientUserId: notification.recipientUserId,
        notification: toNotificationDto(notification)
      });
      return notification;
    },
    async createBatch(input, ctx) {
      const recipientUserIds = Array.from(new Set(input.recipientUserIds));
      const { recipientUserIds: _recipientUserIds, ...content } = input;
      const notifications = [];
      const writeEm = rootEm.fork();
      await writeEm.transactional(async (tx) => {
        await assertNotificationRecipientsInScope(tx, recipientUserIds, ctx);
        for (const recipientUserId of recipientUserIds) {
          const notification = await createOrRefreshNotification(tx, content, recipientUserId, ctx);
          notifications.push(notification);
        }
        await tx.flush();
      });
      await emitNotificationCreatedBatch(eventBus, notifications, ctx);
      await emitNotificationSseEvents(eventBus, notifications, ctx, recipientUserIds);
      return notifications;
    },
    async createForRole(input, ctx) {
      const em = rootEm.fork();
      const db = getDb(em);
      const recipientUserIds = await getRecipientUserIdsForRole(db, ctx.tenantId, input.roleId);
      if (recipientUserIds.length === 0) {
        return [];
      }
      const { roleId: _roleId, ...content } = input;
      const notifications = [];
      const uniqueRecipientUserIds = Array.from(new Set(recipientUserIds));
      const writeEm = rootEm.fork();
      await writeEm.transactional(async (tx) => {
        for (const recipientUserId of uniqueRecipientUserIds) {
          const notification = await createOrRefreshNotification(tx, content, recipientUserId, ctx);
          notifications.push(notification);
        }
        await tx.flush();
      });
      await emitNotificationCreatedBatch(eventBus, notifications, ctx);
      await emitNotificationSseEvents(eventBus, notifications, ctx, uniqueRecipientUserIds);
      return notifications;
    },
    async createForFeature(input, ctx) {
      const em = rootEm.fork();
      const db = getDb(em);
      const recipientUserIds = await getRecipientUserIdsForFeature(db, ctx.tenantId, input.requiredFeature);
      if (recipientUserIds.length === 0) {
        debug("No users found with feature:", input.requiredFeature, "in tenant:", ctx.tenantId);
        return [];
      }
      debug("Creating notifications for", recipientUserIds.length, "user(s) with feature:", input.requiredFeature);
      const { requiredFeature: _requiredFeature, ...content } = input;
      const notifications = [];
      const uniqueRecipientUserIds = Array.from(new Set(recipientUserIds));
      const writeEm = rootEm.fork();
      await writeEm.transactional(async (tx) => {
        for (const recipientUserId of uniqueRecipientUserIds) {
          const notification = await createOrRefreshNotification(tx, content, recipientUserId, ctx);
          notifications.push(notification);
        }
        await tx.flush();
      });
      await emitNotificationCreatedBatch(eventBus, notifications, ctx);
      await emitNotificationSseEvents(eventBus, notifications, ctx, uniqueRecipientUserIds);
      return notifications;
    },
    async markAsRead(notificationId, ctx) {
      const em = rootEm.fork();
      const notification = await findScopedNotificationOrThrow(em, notificationId, ctx);
      if (notification.status === "unread") {
        notification.status = "read";
        notification.readAt = /* @__PURE__ */ new Date();
        await em.flush();
        await eventBus.emit(NOTIFICATION_EVENTS.READ, {
          notificationId: notification.id,
          userId: ctx.userId,
          tenantId: ctx.tenantId
        });
      }
      return notification;
    },
    async markAllAsRead(ctx) {
      const em = rootEm.fork();
      const db = getDb(em);
      const applyScope = (q) => {
        let chain = q.where("recipient_user_id", "=", ctx.userId).where("tenant_id", "=", ctx.tenantId).where("status", "=", "unread");
        if (ctx.organizationId) {
          chain = chain.where("organization_id", "=", ctx.organizationId);
        }
        return chain;
      };
      const targetRows = await applyScope(
        db.selectFrom("notifications").select([
          "id",
          "organization_id",
          "recipient_user_id"
        ])
      ).execute();
      if (!targetRows.length) {
        return 0;
      }
      const updateResult = await applyScope(
        db.updateTable("notifications").set({
          status: "read",
          read_at: sql`now()`
        })
      ).executeTakeFirst();
      const result = Number(updateResult?.numUpdatedRows ?? targetRows.length);
      const notifications = await findWithDecryption(em, Notification, {
        id: { $in: targetRows.map((row) => row.id) }
      }, void 0, {
        tenantId: ctx.tenantId,
        organizationId: ctx.organizationId ?? null
      });
      for (const notification of notifications) {
        await eventBus.emit(NOTIFICATION_EVENTS.READ, {
          notificationId: notification.id,
          userId: ctx.userId,
          tenantId: ctx.tenantId
        });
        await eventBus.emit(NOTIFICATION_SSE_EVENTS.CREATED, {
          tenantId: notification.tenantId,
          organizationId: notification.organizationId ?? null,
          recipientUserId: notification.recipientUserId,
          notification: toNotificationDto(notification)
        });
      }
      return result;
    },
    async dismiss(notificationId, ctx) {
      const em = rootEm.fork();
      const notification = await findScopedNotificationOrThrow(em, notificationId, ctx);
      notification.status = "dismissed";
      notification.dismissedAt = /* @__PURE__ */ new Date();
      await em.flush();
      await eventBus.emit(NOTIFICATION_EVENTS.DISMISSED, {
        notificationId: notification.id,
        userId: ctx.userId,
        tenantId: ctx.tenantId
      });
      return notification;
    },
    async restoreDismissed(notificationId, status, ctx) {
      const em = rootEm.fork();
      const notification = await findScopedNotificationOrThrow(em, notificationId, ctx);
      if (notification.status !== "dismissed") {
        return notification;
      }
      const targetStatus = status ?? "read";
      notification.status = targetStatus;
      notification.dismissedAt = null;
      if (targetStatus === "unread") {
        notification.readAt = null;
      } else if (!notification.readAt) {
        notification.readAt = /* @__PURE__ */ new Date();
      }
      await em.flush();
      await eventBus.emit(NOTIFICATION_EVENTS.RESTORED, {
        notificationId: notification.id,
        userId: ctx.userId,
        tenantId: ctx.tenantId,
        status: targetStatus
      });
      return notification;
    },
    async executeAction(notificationId, input, ctx) {
      const em = rootEm.fork();
      const notification = await findScopedNotificationOrThrow(em, notificationId, ctx);
      const actionData = notification.actionData;
      const action = actionData?.actions?.find((a) => a.id === input.actionId);
      if (!action) {
        throw new Error("Action not found");
      }
      if (notification.status === "actioned") {
        throw conflict("Notification action already executed");
      }
      const actionedAt = /* @__PURE__ */ new Date();
      const previousStatus = notification.status;
      const previousActionedAt = notification.actionedAt ?? null;
      const previousActionTaken = notification.actionTaken ?? null;
      const claimResult = await getDb(em).updateTable("notifications").set({
        status: "actioned",
        actioned_at: actionedAt,
        action_taken: input.actionId
      }).where("id", "=", notification.id).where("recipient_user_id", "=", ctx.userId).where("tenant_id", "=", ctx.tenantId).where("status", "!=", "actioned").executeTakeFirst();
      if (Number(claimResult?.numUpdatedRows ?? 0) === 0) {
        throw conflict("Notification action already executed");
      }
      const releaseClaim = async () => {
        await getDb(em).updateTable("notifications").set({
          status: previousStatus,
          actioned_at: previousActionedAt,
          action_taken: previousActionTaken
        }).where("id", "=", notification.id).where("recipient_user_id", "=", ctx.userId).where("tenant_id", "=", ctx.tenantId).where("status", "=", "actioned").executeTakeFirst();
      };
      let result = null;
      if (action.commandId && commandBus && container) {
        const commandInput = {
          id: notification.sourceEntityId,
          ...input.payload
        };
        const commandCtx = {
          container,
          auth: {
            sub: ctx.userId,
            tenantId: ctx.tenantId,
            orgId: ctx.organizationId
          },
          organizationScope: null,
          selectedOrganizationId: ctx.organizationId ?? null,
          organizationIds: ctx.organizationId ? [ctx.organizationId] : null
        };
        let commandResult;
        try {
          commandResult = await commandBus.execute(action.commandId, {
            input: commandInput,
            ctx: commandCtx,
            metadata: {
              tenantId: ctx.tenantId,
              organizationId: ctx.organizationId,
              resourceKind: "notifications"
            }
          });
        } catch (err) {
          try {
            await releaseClaim();
          } catch (releaseErr) {
            debug("failed to release notification action claim", releaseErr);
          }
          throw err;
        }
        result = commandResult.result;
      }
      notification.status = "actioned";
      notification.actionedAt = actionedAt;
      notification.actionTaken = input.actionId;
      notification.actionResult = result;
      if (!notification.readAt) {
        notification.readAt = /* @__PURE__ */ new Date();
      }
      await em.flush();
      await eventBus.emit(NOTIFICATION_EVENTS.ACTIONED, {
        notificationId: notification.id,
        actionId: input.actionId,
        userId: ctx.userId,
        tenantId: ctx.tenantId
      });
      return { notification, result };
    },
    async getUnreadCount(ctx) {
      const em = rootEm.fork();
      return em.count(Notification, {
        recipientUserId: ctx.userId,
        tenantId: ctx.tenantId,
        status: "unread"
      });
    },
    async getPollData(ctx, since) {
      const em = rootEm.fork();
      const filters = {
        recipientUserId: ctx.userId,
        tenantId: ctx.tenantId
      };
      if (since) {
        filters.createdAt = { $gt: new Date(since) };
      }
      const [notifications, unreadCount] = await Promise.all([
        em.find(Notification, filters, {
          orderBy: { createdAt: "desc" },
          limit: 50
        }),
        em.count(Notification, {
          recipientUserId: ctx.userId,
          tenantId: ctx.tenantId,
          status: "unread"
        })
      ]);
      const recent = notifications.map(toNotificationDto);
      const hasNew = since ? recent.length > 0 : false;
      return {
        unreadCount,
        recent,
        hasNew,
        lastId: recent[0]?.id
      };
    },
    async cleanupExpired() {
      const em = rootEm.fork();
      const db = getDb(em);
      const updateResult = await db.updateTable("notifications").set({
        status: "dismissed",
        dismissed_at: sql`now()`
      }).where("expires_at", "<", sql`now()`).where("status", "not in", ["actioned", "dismissed"]).executeTakeFirst();
      return Number(updateResult?.numUpdatedRows ?? 0);
    },
    async deleteBySource(sourceEntityType, sourceEntityId, ctx) {
      const em = rootEm.fork();
      const db = getDb(em);
      const deleteResult = await db.deleteFrom("notifications").where("source_entity_type", "=", sourceEntityType).where("source_entity_id", "=", sourceEntityId).where("tenant_id", "=", ctx.tenantId).executeTakeFirst();
      return Number(deleteResult?.numDeletedRows ?? 0);
    }
  };
}
function resolveNotificationService(container) {
  const em = container.resolve("em");
  const eventBus = container.resolve("eventBus");
  let commandBus;
  try {
    commandBus = container.resolve("commandBus");
  } catch {
    commandBus = void 0;
  }
  return createNotificationService({ em, eventBus, commandBus, container });
}
export {
  createNotificationService,
  resolveNotificationService
};
//# sourceMappingURL=notificationService.js.map
