// Event-ingress helpers shared by logNotificationEvent and the dispatch
// executors. Kept free of any DB / command import so it stays a pure,
// bundle-clean leaf (the Tailor function runtime ships no `node:*` builtins,
// so hashing uses Web Crypto — see the comment on hashPayload).

/**
 * Why a recipient was included. Carried through the event payload by the
 * emitter as a hint and consumed by the dispatcher: the reason is
 * unioned with internally-resolved SUBSCRIBED watchers, deduped by precedence,
 * persisted on the Notification row, and drives the critical bypass
 * (`ASSIGNED` / `MENTION`).
 */
export const NOTIFICATION_REASONS = ["SUBSCRIBED", "ASSIGNED", "AUTHOR", "MENTION"] as const;

/** One of the {@link NOTIFICATION_REASONS} inclusion reasons. */
export type NotificationReason = (typeof NOTIFICATION_REASONS)[number];

/** Emitter-supplied pairing of a recipient with the reason they were included. */
export interface NotificationRecipientHint {
  userId: string;
  reason: NotificationReason;
}

/**
 * The two reasons that mark a recipient as personally accountable for the
 * source — directly assigned or @-mentioned. These trigger the dispatcher's
 * critical bypass: an `allowed = false` NotificationPreference mute does not
 * suppress delivery. `SUBSCRIBED` / `AUTHOR` do not bypass.
 */
export function isCriticalReason(reason: NotificationReason): boolean {
  return reason === "ASSIGNED" || reason === "MENTION";
}

/**
 * Precedence used to dedup the resolved audience: when one user is reached for
 * more than one reason (e.g. a SUBSCRIBED watcher who is also ASSIGNED), the
 * highest-rank reason is kept so critical-bypass behavior is preserved.
 */
export function reasonRank(reason: NotificationReason): number {
  switch (reason) {
    case "MENTION":
      return 4;
    case "ASSIGNED":
      return 3;
    case "AUTHOR":
      return 2;
    case "SUBSCRIBED":
      return 1;
  }
}

/**
 * The cross-module event payload contract. This shape is the stable seam
 * between every emitter (via a host-app `emitNotificationEvent` helper) and the
 * notification module; it is stored as canonical key-sorted JSON on
 * `NotificationEvent.payload` and
 * mapped to the dispatcher input by the dispatch executor.
 */
export interface NotificationEventPayload {
  /** Naturally-interested recipients the emitter already resolved. */
  recipients?: NotificationRecipientHint[];
  /** When true, the actor is not suppressed from the recipient set. */
  notifySelf?: boolean;
  /** Locale override for template rendering. */
  locale?: string;
  /** Template variables (includes title/body/deepLinkUrl used by seeded templates). */
  title?: string;
  body?: string;
  deepLinkUrl?: string;
  // Emitters may carry additional template variables.
  [key: string]: unknown;
}

export function parseNotificationEventPayload(payload: string): NotificationEventPayload | null {
  try {
    const parsed: unknown = JSON.parse(payload);
    if (typeof parsed !== "object" || parsed === null) return null;
    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- runtime JSON boundary
    return parsed as NotificationEventPayload;
  } catch {
    return null;
  }
}

// Recursively sort object keys so the serialized form (and therefore the
// payloadHash) is independent of key insertion order and whitespace.
function canonicalize(value: unknown): unknown {
  if (Array.isArray(value)) return value.map(canonicalize);
  if (value !== null && typeof value === "object") {
    const record = value as Record<string, unknown>;
    const out: Record<string, unknown> = {};
    for (const key of Object.keys(record).sort()) {
      out[key] = canonicalize(record[key]);
    }
    return out;
  }
  return value;
}

export function normalizePayload(payload: string | NotificationEventPayload): {
  raw: string;
  parsed: NotificationEventPayload | null;
} {
  const parsed =
    typeof payload === "string" ? parseNotificationEventPayload(payload) : structuredClone(payload);
  if (!parsed) {
    return { raw: typeof payload === "string" ? payload : JSON.stringify(payload), parsed: null };
  }
  return { raw: JSON.stringify(canonicalize(parsed)), parsed };
}

/**
 * SHA-256 hex of the normalized payload, used as the idempotency discriminator.
 * Uses Web Crypto (not `node:crypto`) so the file stays loadable inside the
 * Tailor function runtime, which bundles no `node:*` builtins.
 */
export async function hashPayload(payload: string): Promise<string> {
  const encoded = new TextEncoder().encode(payload);
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoded));
  let hex = "";
  for (let i = 0; i < digest.length; i++) {
    hex += digest[i].toString(16).padStart(2, "0");
  }
  return hex;
}
