import { ok, err, type CommandContext, type ReadonlyDB } from "@tailor-platform/erp-kit/core";
import { parseVariableSchema } from "../command/createNotificationTemplate";
import type { DB, Transaction } from "../generated/kysely-tailordb";
import type { DispatchAdapters } from "../lib/dispatchAdapters";
import {
  DEFAULT_LOCALE,
  loadChannelByChannelId,
  loadTemplateWithFallback,
} from "../lib/dispatchQueries";
import { CategoryNotFoundError } from "../lib/errors.generated";
import {
  isCriticalReason,
  type NotificationReason,
  type NotificationRecipientHint,
  reasonRank,
} from "../lib/events";
import { interpolate, validatePayloadVars } from "../lib/templateRendering";
import { planDestinations, type PlannedDestination } from "./planDestinations";

function isRecord(value: unknown): value is Record<string, unknown> {
  return Boolean(value) && typeof value === "object";
}

export interface PlanNotificationInput {
  eventType: string;
  sourceType: string;
  sourceId: string;
  // The emitter's reason-tagged naturally-interested set (role-holders
  // only the emitter can compute: ASSIGNED / AUTHOR / MENTION).
  recipients?: NotificationRecipientHint[];
  // Actor that triggered the event; suppressed from the audience unless
  // `notifySelf` is set.
  actorUserId?: string | null;
  notifySelf?: boolean;
  payloadVars: Record<string, unknown> | string;
  idempotencyKey?: string;
  locale?: string;
}

interface ResolvedRecipient {
  userId: string;
  reason: NotificationReason;
}

// A planned PERSONAL row. QUEUED rows are handed to the delivery worker; FAILED
// rows reached a terminal state at plan time (no template / invalid vars /
// missing address) and are never delivered.
interface PlannedNotification {
  id: string;
  recipientUserId: string;
  channelId: string;
  deliveryStatus: "QUEUED" | "FAILED";
  reason?: string;
}

// Why a (recipient[, channel]) pair was silently suppressed before any
// Notification row could be anchored. Surfaced on the plan result so
// callers/operators can distinguish "muted" from "lost".
export type PlanSkipReason = "RECIPIENT_UNRESOLVED" | "CHANNEL_DISABLED" | "PREFERENCE_MUTED";

export interface SkippedRecipient {
  recipientUserId: string;
  /** Channel key (e.g. IN_APP / EMAIL); absent for recipient-level skips. */
  channelId?: string;
  reason: PlanSkipReason;
}

interface PlanResult {
  notifications: PlannedNotification[];
  deduped: PlannedNotification[];
  destinations: PlannedDestination[];
  skipped: SkippedRecipient[];
}

interface NotificationRow {
  id: string;
  recipientUserId: string;
  channelId: string;
  eventType: string;
  sourceType: string;
  sourceId: string;
  reason: NotificationReason;
  locale: string;
  payloadVars: string | null;
  subject: string;
  body: string;
  htmlBody: string | null;
  idempotencyKey: string;
  deliveryStatus: "QUEUED" | "SENT" | "DELIVERED" | "FAILED" | "BOUNCED";
  engagementStatuses: string[];
  seenAt: Date | null;
  readAt: Date | null;
  archivedAt: Date | null;
  adapterMessageId: string | null;
  createdAt: Date;
  updatedAt: Date | null;
}

function dayBucket(now: Date): string {
  return now.toISOString().slice(0, 10);
}

function deriveIdempotencyKey(
  eventType: string,
  sourceId: string,
  recipientUserId: string,
  now: Date,
): string {
  return `${eventType}:${sourceId}:${recipientUserId}:${dayBucket(now)}`;
}

function serializePayloadVars(payloadVars: Record<string, unknown> | string): {
  json: string;
  obj: Record<string, unknown>;
} {
  if (typeof payloadVars === "string") {
    try {
      const parsed: unknown = JSON.parse(payloadVars);
      const obj: Record<string, unknown> = isRecord(parsed) ? parsed : {};
      return { json: payloadVars, obj };
    } catch {
      return { json: payloadVars, obj: {} };
    }
  }
  return { json: JSON.stringify(payloadVars), obj: payloadVars };
}

// Validate against the same schema grammar the query path uses; a malformed
// variableSchema is itself a validation failure (matching renderNotificationTemplate).
function validatePayloadVarsJson(
  variableSchemaJson: string,
  vars: Record<string, unknown>,
): string | null {
  const schema = parseVariableSchema(variableSchemaJson);
  if (!schema) return "invalid variableSchema";
  return validatePayloadVars(schema, vars);
}

async function loadEventBinding(db: ReadonlyDB<DB>, eventType: string) {
  return db
    .selectFrom("EventCategoryBinding")
    .selectAll()
    .where("eventType", "=", eventType)
    .executeTakeFirst();
}

async function loadPreference(
  db: ReadonlyDB<DB>,
  userId: string,
  categoryId: string,
  channelDbId: string,
) {
  return db
    .selectFrom("NotificationPreference")
    .selectAll()
    .where("userId", "=", userId)
    .where("categoryId", "=", categoryId)
    .where("channelId", "=", channelDbId)
    .executeTakeFirst();
}

async function loadExistingNotification(
  db: ReadonlyDB<DB>,
  recipientUserId: string,
  channelDbId: string,
  idempotencyKey: string,
): Promise<NotificationRow | undefined> {
  return (await db
    .selectFrom("Notification")
    .selectAll()
    .where("recipientUserId", "=", recipientUserId)
    .where("channelId", "=", channelDbId)
    .where("idempotencyKey", "=", idempotencyKey)
    .executeTakeFirst()) as NotificationRow | undefined;
}

type AuditEventType = "QUEUED" | "FAILED";

async function writeAudit(
  db: Transaction,
  notificationId: string,
  eventType: AuditEventType,
  occurredAt: Date,
  occurredBy: string,
  errorClass: string | null = null,
  errorDetail: string | null = null,
) {
  await db
    .insertInto("NotificationDeliveryAudit")
    .values({
      id: crypto.randomUUID(),
      notificationId,
      eventType,
      occurredAt,
      occurredBy,
      errorClass,
      errorDetail,
      createdAt: occurredAt,
      updatedAt: occurredAt,
    })
    .execute();
}

// Internal SUBSCRIBED-watcher resolution: read NotificationSubscription rows
// following the event's (sourceType, sourceId).
async function loadWatcherUserIds(
  db: ReadonlyDB<DB>,
  sourceType: string,
  sourceId: string,
): Promise<string[]> {
  const rows = await db
    .selectFrom("NotificationSubscription")
    .select(["userId"])
    .where("sourceType", "=", sourceType)
    .where("sourceId", "=", sourceId)
    .execute();
  return rows.map((r) => r.userId);
}

// Union the emitter's reason-tagged set and the internally-resolved SUBSCRIBED
// watchers; dedup by userId keeping the highest-precedence reason; suppress the
// actor unless notifySelf is set.
function resolveAudience(
  input: PlanNotificationInput,
  watcherUserIds: string[],
): ResolvedRecipient[] {
  const byUser = new Map<string, NotificationReason>();
  const consider = (userId: string, reason: NotificationReason) => {
    if (!userId) return;
    const current = byUser.get(userId);
    if (current === undefined || reasonRank(reason) > reasonRank(current)) {
      byUser.set(userId, reason);
    }
  };

  for (const hint of input.recipients ?? []) consider(hint.userId, hint.reason);
  for (const userId of watcherUserIds) consider(userId, "SUBSCRIBED");

  const actorUserId = input.actorUserId ?? null;
  const out: ResolvedRecipient[] = [];
  for (const [userId, reason] of byUser) {
    if (!input.notifySelf && actorUserId !== null && userId === actorUserId) continue;
    out.push({ userId, reason });
  }
  return out;
}

async function persistNotification(
  db: Transaction,
  row: NotificationRow,
): Promise<NotificationRow> {
  return (
    ((await db.insertInto("Notification").values(row).returningAll().executeTakeFirstOrThrow()) as
      | NotificationRow
      | undefined) ?? row
  );
}

async function markFailed(
  db: Transaction,
  notificationId: string,
  now: Date,
  occurredBy: string,
  errorClass: string,
  errorDetail: string,
) {
  await db
    .updateTable("Notification")
    .set({ deliveryStatus: "FAILED", updatedAt: now })
    .where("id", "=", notificationId)
    .execute();
  await writeAudit(db, notificationId, "FAILED", now, occurredBy, errorClass, errorDetail);
}

/**
 * Resolves an event's audience, applies preference/transactional/critical-bypass gates, renders per recipient, and persists Notification (QUEUED) and DestinationDeliveryLog (PENDING) outbox rows. Performs no external delivery — the delivery worker drains the outbox rows this produces.
 *
 * Internal plan engine: not a registered command. The source-event ingress
 * is `logNotificationEvent`; this engine is invoked only by the CDC dispatch
 * and re-drain executors via `internal/planEvent`.
 */
export async function planNotificationInternal(
  db: Transaction,
  input: PlanNotificationInput,
  ctx: CommandContext,
  adapters: DispatchAdapters,
) {
  const now = adapters.now ? adapters.now() : new Date();
  const result: PlanResult = {
    notifications: [],
    deduped: [],
    destinations: [],
    skipped: [],
  };

  const binding = await loadEventBinding(db, input.eventType);
  if (!binding) {
    return err(new CategoryNotFoundError(input.eventType));
  }

  // The binding's `transactional` flag and the category's `optOutAllowed=false`
  // both mark must-deliver events; either one suppresses the optional-path
  // preference gate.
  const category = await db
    .selectFrom("NotificationCategory")
    .select(["id", "optOutAllowed"])
    .where("id", "=", binding.categoryId)
    .executeTakeFirst();
  const transactional = Boolean(binding.transactional) || category?.optOutAllowed === false;

  const { json: payloadVarsJson, obj: payloadVarsObj } = serializePayloadVars(input.payloadVars);

  const watcherUserIds = await loadWatcherUserIds(db, input.sourceType, input.sourceId);
  const audience = resolveAudience(input, watcherUserIds);

  // Stage 1 (PERSONAL fan-out) iterates the resolved audience; an empty audience
  // is a natural no-op. The DESTINATION stage runs afterward regardless of the
  // recipient set, so we do NOT early-return on an empty audience here.
  for (const { userId: recipientUserId, reason } of audience) {
    const profile = await adapters.resolveRecipient(db, recipientUserId);
    if (!profile) {
      result.skipped.push({ recipientUserId, reason: "RECIPIENT_UNRESOLVED" });
      continue;
    }

    const locale = input.locale ?? profile.locale ?? DEFAULT_LOCALE;

    for (const channelKey of binding.defaultChannels ?? []) {
      const channel = await loadChannelByChannelId(db, channelKey);
      if (!channel || !channel.enabled) {
        result.skipped.push({ recipientUserId, channelId: channelKey, reason: "CHANNEL_DISABLED" });
        continue;
      }
      // DESTINATION channels are not part of the per-recipient fan-out; they are
      // handled once per binding in the Stage 2 loop below.
      if (channel.kind === "DESTINATION") continue;

      const channelDbId = channel.id;

      const idempotencyKey =
        input.idempotencyKey ??
        deriveIdempotencyKey(input.eventType, input.sourceId, recipientUserId, now);

      const existing = await loadExistingNotification(
        db,
        recipientUserId,
        channelDbId,
        idempotencyKey,
      );
      if (existing) {
        result.deduped.push({
          id: existing.id,
          recipientUserId,
          channelId: channelDbId,
          deliveryStatus: existing.deliveryStatus === "FAILED" ? "FAILED" : "QUEUED",
        });
        continue;
      }

      if (!transactional) {
        const allowed = (await loadPreference(db, recipientUserId, binding.categoryId, channelDbId))
          ?.allowed;
        // Critical bypass: an ASSIGNED / MENTION recipient is delivered past a
        // preference mute.
        if (allowed === false && !isCriticalReason(reason)) {
          result.skipped.push({
            recipientUserId,
            channelId: channelKey,
            reason: "PREFERENCE_MUTED",
          });
          continue;
        }
      }

      const template = await loadTemplateWithFallback(db, input.eventType, channelDbId, locale);

      const validationError = template
        ? validatePayloadVarsJson(template.variableSchema, payloadVarsObj)
        : null;
      const recipientAddress = profile.addresses[channelKey] ?? null;

      const subject = template ? interpolate(template.subject, payloadVarsObj, false) : "";
      const body = template ? interpolate(template.body, payloadVarsObj, false) : "";
      const htmlBody = template?.htmlBody
        ? interpolate(template.htmlBody, payloadVarsObj, true)
        : null;

      const notificationId = crypto.randomUUID();
      const baseRow: NotificationRow = {
        id: notificationId,
        recipientUserId,
        channelId: channelDbId,
        eventType: input.eventType,
        sourceType: input.sourceType,
        sourceId: input.sourceId,
        reason,
        locale,
        payloadVars: payloadVarsJson,
        subject,
        body,
        htmlBody,
        idempotencyKey,
        deliveryStatus: "QUEUED",
        engagementStatuses: [],
        seenAt: null,
        readAt: null,
        archivedAt: null,
        adapterMessageId: null,
        createdAt: now,
        updatedAt: now,
      };

      const persisted = await persistNotification(db, baseRow);
      await writeAudit(db, persisted.id, "QUEUED", now, ctx.actorId);

      // Terminal plan-time failures: the row can never be delivered, so it is
      // FAILED here and excluded from the deliverable set.
      if (!template) {
        await markFailed(
          db,
          persisted.id,
          now,
          ctx.actorId,
          "TemplateNotFound",
          `${input.eventType}:${channelKey}:${locale}`,
        );
        result.notifications.push({
          id: persisted.id,
          recipientUserId,
          channelId: channelDbId,
          deliveryStatus: "FAILED",
          reason: "TEMPLATE_NOT_FOUND",
        });
        continue;
      }

      if (validationError !== null) {
        await markFailed(
          db,
          persisted.id,
          now,
          ctx.actorId,
          "TemplateVarValidationFailed",
          validationError,
        );
        result.notifications.push({
          id: persisted.id,
          recipientUserId,
          channelId: channelDbId,
          deliveryStatus: "FAILED",
          reason: "TEMPLATE_VAR_VALIDATION_FAILED",
        });
        continue;
      }

      if (recipientAddress === null) {
        await markFailed(db, persisted.id, now, ctx.actorId, "MissingRecipientAddress", channelKey);
        result.notifications.push({
          id: persisted.id,
          recipientUserId,
          channelId: channelDbId,
          deliveryStatus: "FAILED",
          reason: "MISSING_RECIPIENT_ADDRESS",
        });
        continue;
      }

      result.notifications.push({
        id: persisted.id,
        recipientUserId,
        channelId: channelDbId,
        deliveryStatus: "QUEUED",
      });
    }
  }

  // Stage 2: DESTINATION resolution loop — plan one PENDING log per active
  // binding (see internal/planDestinations).
  if (adapters.destination) {
    result.destinations = await planDestinations(
      db,
      {
        eventType: input.eventType,
        sourceType: input.sourceType,
        sourceId: input.sourceId,
        // Mirror the PERSONAL path: dedup destination logs on the event's
        // logical identity. planEvent always supplies the payloadHash-bearing
        // key; the fallback keeps direct callers deterministic.
        idempotencyKey:
          input.idempotencyKey ?? `${input.eventType}:${input.sourceType}:${input.sourceId}`,
        locale: input.locale,
        payloadVars: payloadVarsObj,
      },
      now,
    );
  }

  return ok(result);
}
