import { createHash } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import nodemailer, { type Transporter } from "nodemailer";
import { db } from "@workspace/db";
import {
  executiveMeetingNotificationsTable,
  executiveMeetingNotificationPrefsTable,
  notificationsTable,
  rolesTable,
  userRolesTable,
  groupRolesTable,
  userGroupsTable,
  usersTable,
} from "@workspace/db";
import { logger } from "./logger";

/**
 * Canonical list of notification event types the Executive Meetings
 * module fans out. Kept here (instead of in routes) because both the
 * fan-out helpers and the preferences API need it. The preferences UI
 * iterates this list to render one row per type. New event types must be
 * appended here so users can opt in/out of them.
 */
export const EXECUTIVE_MEETING_NOTIFICATION_TYPES = [
  "meeting_created",
  "request_submitted",
  "request_approved",
  "request_rejected",
  "request_needs_edit",
  "task_assigned",
  "task_completed",
] as const;

export type ExecutiveMeetingNotificationType =
  (typeof EXECUTIVE_MEETING_NOTIFICATION_TYPES)[number];

export type ExecutiveMeetingNotificationChannel = "inApp" | "email";

/**
 * Filter a list of recipient user ids down to those who have NOT
 * explicitly opted out of the (notificationType, channel) pair.
 *
 * Default-on semantics: if a user has no preference row at all for
 * this notificationType, they are kept in the list. Only an explicit
 * `false` on the relevant column removes them. This preserves legacy
 * behaviour for users who never visit the preferences UI.
 *
 * Recipient ids that fail validation (non-positive, non-integer) are
 * also dropped, matching `recordExecutiveMeetingNotifications`.
 */
export async function filterRecipientsByNotificationPref(
  recipientUserIds: ReadonlyArray<number>,
  notificationType: string,
  channel: ExecutiveMeetingNotificationChannel,
): Promise<number[]> {
  const valid = Array.from(
    new Set(
      recipientUserIds.filter((id) => Number.isInteger(id) && id > 0),
    ),
  );
  if (valid.length === 0) return [];
  const prefs = await db
    .select({
      userId: executiveMeetingNotificationPrefsTable.userId,
      inApp: executiveMeetingNotificationPrefsTable.inApp,
      email: executiveMeetingNotificationPrefsTable.email,
    })
    .from(executiveMeetingNotificationPrefsTable)
    .where(
      and(
        inArray(executiveMeetingNotificationPrefsTable.userId, valid),
        eq(
          executiveMeetingNotificationPrefsTable.notificationType,
          notificationType,
        ),
      ),
    );
  const optedOut = new Set<number>();
  for (const p of prefs) {
    const allowed = channel === "inApp" ? p.inApp : p.email;
    if (allowed === false) optedOut.add(p.userId);
  }
  return valid.filter((id) => !optedOut.has(id));
}

type DbExecutor =
  | typeof db
  | Parameters<Parameters<typeof db.transaction>[0]>[0];

export type ExecMeetingNotificationInput = {
  recipientUserIds: number[];
  meetingId: number | null;
  notificationType: string;
  titleAr: string;
  titleEn: string;
  bodyAr?: string | null;
  bodyEn?: string | null;
  relatedType?: string;
  relatedId?: number | null;
  excludeUserId?: number | null;
};

export type PendingExecMeetingNotification = {
  recipientUserIds: number[];
  meetingId: number | null;
  notificationType: string;
  titleAr: string;
  titleEn: string;
  bodyAr: string | null;
  bodyEn: string | null;
  email: boolean;
};

/**
 * Insert per-recipient rows into both `executive_meeting_notifications`
 * (the audit/list table shown on the page's Notifications tab) and the
 * shared `notifications` table (the global bell). Returns the actual
 * recipient ids that were notified, so the caller can broadcast Socket.IO
 * events after the surrounding transaction commits.
 *
 * Self-notifications are skipped — an actor never gets pinged for their
 * own action — and the recipient list is de-duplicated.
 */
export async function recordExecutiveMeetingNotifications(
  executor: DbExecutor,
  input: ExecMeetingNotificationInput,
): Promise<number[]> {
  const exclude = input.excludeUserId ?? null;
  const candidates = Array.from(
    new Set(
      input.recipientUserIds.filter(
        (id) => Number.isInteger(id) && id > 0 && id !== exclude,
      ),
    ),
  );
  if (candidates.length === 0) return [];

  // Drop anyone who has explicitly muted the in-app channel for this
  // event type. Users with no preference row stay in (default = on).
  const recipients = await filterRecipientsByNotificationPref(
    candidates,
    input.notificationType,
    "inApp",
  );
  if (recipients.length === 0) return [];

  const now = new Date();
  await executor.insert(executiveMeetingNotificationsTable).values(
    recipients.map((uid) => ({
      meetingId: input.meetingId ?? null,
      userId: uid,
      notificationType: input.notificationType,
      scheduledAt: now,
      sentAt: now,
      status: "sent",
    })),
  );

  await executor.insert(notificationsTable).values(
    recipients.map((uid) => ({
      userId: uid,
      titleAr: truncate(input.titleAr, 300),
      titleEn: truncate(input.titleEn, 300),
      bodyAr: input.bodyAr ?? null,
      bodyEn: input.bodyEn ?? null,
      type: "executive_meeting",
      relatedType: input.relatedType ?? "executive_meeting",
      relatedId: input.relatedId ?? input.meetingId ?? null,
    })),
  );

  return recipients;
}

function truncate(s: string, max: number): string {
  if (typeof s !== "string") return "";
  return s.length <= max ? s : s.slice(0, max);
}

/**
 * Fan out an `executive_meeting_notification_created` event to each
 * recipient (so their bell + notifications panel refresh) and a single
 * `executive_meeting_notifications_changed` broadcast (so the page-level
 * Notifications tab also re-queries). Safe to call after the transaction
 * commits — does no DB work.
 */
export async function broadcastExecutiveMeetingNotifications(
  recipientUserIds: number[],
  notificationType: string,
  meetingId: number | null,
): Promise<void> {
  if (recipientUserIds.length === 0) return;
  const { io } = await import("../index.js");
  const payload = {
    type: "executive_meeting",
    notificationType,
    meetingId,
  };
  for (const uid of recipientUserIds) {
    io.to(`user:${uid}`).emit("notification_created", payload);
  }
  io.emit("executive_meeting_notifications_changed", {
    notificationType,
    meetingId,
  });
}

/**
 * Resolve every user that currently holds any of the given role names —
 * either directly via `user_roles` or indirectly through a group via
 * `group_roles` -> `user_groups`. Returns a de-duplicated user-id list.
 */
export async function getUserIdsForRoleNames(
  roleNames: ReadonlyArray<string>,
): Promise<number[]> {
  if (roleNames.length === 0) return [];
  const roles = await db
    .select({ id: rolesTable.id })
    .from(rolesTable)
    .where(inArray(rolesTable.name, roleNames as string[]));
  const roleIds = roles.map((r) => r.id);
  if (roleIds.length === 0) return [];

  const direct = await db
    .select({ userId: userRolesTable.userId })
    .from(userRolesTable)
    .where(inArray(userRolesTable.roleId, roleIds));

  const groupRows = await db
    .select({ groupId: groupRolesTable.groupId })
    .from(groupRolesTable)
    .where(inArray(groupRolesTable.roleId, roleIds));
  const groupIds = Array.from(new Set(groupRows.map((g) => g.groupId)));

  const indirect =
    groupIds.length > 0
      ? await db
          .select({ userId: userGroupsTable.userId })
          .from(userGroupsTable)
          .where(inArray(userGroupsTable.groupId, groupIds))
      : [];

  return Array.from(
    new Set(
      [...direct.map((r) => r.userId), ...indirect.map((r) => r.userId)].filter(
        (id) => Number.isInteger(id) && id > 0,
      ),
    ),
  );
}

/**
 * Lazily-built nodemailer transporter, cached for the life of the
 * process. We rebuild it if the relevant SMTP_* env vars change so
 * tests / hot-config tweaks don't need a server restart.
 */
type CachedTransporter = {
  signature: string;
  transporter: Transporter;
  from: string;
};
let cachedTransporter: CachedTransporter | null = null;

function smtpConfigSignature(): string {
  const passFingerprint = process.env.SMTP_PASS
    ? createHash("sha256")
        .update(process.env.SMTP_PASS)
        .digest("hex")
        .slice(0, 16)
    : "";
  return [
    process.env.SMTP_HOST ?? "",
    process.env.SMTP_PORT ?? "",
    process.env.SMTP_USER ?? "",
    passFingerprint,
    process.env.SMTP_SECURE ?? "",
    process.env.SMTP_FROM ?? "",
  ].join("|");
}

function getTransporter(): CachedTransporter | null {
  const host = process.env.SMTP_HOST;
  if (!host) return null;
  const signature = smtpConfigSignature();
  if (cachedTransporter && cachedTransporter.signature === signature) {
    return cachedTransporter;
  }
  const portRaw = process.env.SMTP_PORT;
  const port = portRaw ? Number(portRaw) : 587;
  if (!Number.isFinite(port) || port <= 0) {
    logger.warn(
      { portRaw },
      "SMTP_PORT is not a valid number — refusing to build SMTP transporter",
    );
    return null;
  }
  const secureRaw = (process.env.SMTP_SECURE ?? "").toLowerCase();
  const secure =
    secureRaw === "true" || secureRaw === "1" || secureRaw === "yes"
      ? true
      : secureRaw === "false" || secureRaw === "0" || secureRaw === "no"
        ? false
        : port === 465;
  const user = process.env.SMTP_USER;
  const pass = process.env.SMTP_PASS;
  const transporter = nodemailer.createTransport({
    host,
    port,
    secure,
    auth: user && pass ? { user, pass } : undefined,
  });
  const from =
    process.env.SMTP_FROM && process.env.SMTP_FROM.length > 0
      ? process.env.SMTP_FROM
      : user && user.length > 0
        ? user
        : "no-reply@localhost";
  cachedTransporter = { signature, transporter, from };
  return cachedTransporter;
}

function pickLang(
  lang: string | null | undefined,
  values: { ar: string | null; en: string | null },
): string {
  const isArabic =
    typeof lang === "string" && lang.toLowerCase().startsWith("ar");
  const primary = isArabic ? values.ar : values.en;
  const fallback = isArabic ? values.en : values.ar;
  return (primary ?? fallback ?? "").trim();
}

/**
 * Best-effort email side-channel. When SMTP is configured we deliver
 * via nodemailer; otherwise we fall back to logging the outbound
 * message at info level so operators can see what would have gone
 * out. The function never throws — failure to send email must not
 * roll back a meeting/request mutation.
 */
export async function sendExecutiveMeetingEmail(
  recipientUserIds: number[],
  subject: { ar: string; en: string },
  body: { ar: string | null; en: string | null },
  notificationType: string,
): Promise<void> {
  if (recipientUserIds.length === 0) return;
  try {
    // Drop anyone who has explicitly muted the email channel for this
    // event type. Users with no preference row stay in (default = on).
    const allowedIds = await filterRecipientsByNotificationPref(
      recipientUserIds,
      notificationType,
      "email",
    );
    if (allowedIds.length === 0) return;
    const recipients = await db
      .select({
        id: usersTable.id,
        email: usersTable.email,
        preferredLanguage: usersTable.preferredLanguage,
      })
      .from(usersTable)
      .where(inArray(usersTable.id, allowedIds));
    const addressed = recipients.filter(
      (u) => typeof u.email === "string" && u.email.length > 0,
    );
    if (addressed.length === 0) return;

    const cached = getTransporter();
    if (!cached) {
      const reason = process.env.SMTP_HOST
        ? "SMTP misconfigured — not delivered"
        : "no SMTP_HOST configured — not delivered";
      logger.info(
        {
          notificationType,
          recipients: addressed.map((r) => ({
            id: r.id,
            email: r.email,
            lang: r.preferredLanguage,
          })),
          subject,
          body,
        },
        `executive-meeting email outbox (${reason})`,
      );
      return;
    }

    const { transporter, from } = cached;
    await Promise.all(
      addressed.map(async (r) => {
        const langSubject = pickLang(r.preferredLanguage, {
          ar: subject.ar,
          en: subject.en,
        });
        const langBody = pickLang(r.preferredLanguage, body);
        try {
          const info = await transporter.sendMail({
            from,
            to: r.email!,
            subject: langSubject,
            text: langBody,
          });
          const accepted = Array.isArray(info.accepted) ? info.accepted : [];
          const rejected = Array.isArray(info.rejected) ? info.rejected : [];
          if (rejected.length > 0) {
            logger.warn(
              {
                notificationType,
                recipientId: r.id,
                email: r.email,
                rejected,
                response: info.response,
                messageId: info.messageId,
              },
              "executive-meeting email rejected by SMTP server",
            );
          } else {
            logger.info(
              {
                notificationType,
                recipientId: r.id,
                email: r.email,
                accepted,
                messageId: info.messageId,
              },
              "executive-meeting email sent",
            );
          }
        } catch (err) {
          logger.warn(
            {
              err,
              notificationType,
              recipientId: r.id,
              email: r.email,
            },
            "executive-meeting email send failed",
          );
        }
      }),
    );
  } catch (err) {
    logger.warn(
      { err, notificationType, count: recipientUserIds.length },
      "executive-meeting email side-channel failed",
    );
  }
}

/**
 * Look up the canonical Arabic + English display names for a user, with
 * fallbacks to the username so we always have something to render in a
 * notification title/body.
 */
export async function getUserDisplay(
  userId: number,
): Promise<{ ar: string; en: string } | null> {
  if (!Number.isInteger(userId) || userId <= 0) return null;
  const [row] = await db
    .select({
      username: usersTable.username,
      displayNameAr: usersTable.displayNameAr,
      displayNameEn: usersTable.displayNameEn,
    })
    .from(usersTable)
    .where(eq(usersTable.id, userId));
  if (!row) return null;
  return {
    ar: row.displayNameAr || row.displayNameEn || row.username,
    en: row.displayNameEn || row.displayNameAr || row.username,
  };
}
