import { and, eq, inArray, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
  executiveMeetingAlertStateTable,
  executiveMeetingsTable,
} from "@workspace/db";
import { logger } from "./logger";
import { sendPushToUser } from "./push";
import { getUserIdsForRoleNames } from "./executive-meeting-notify";

const TICK_MS = 60_000;
const LEAD_MINUTES = 5;
const EM_NOTIFY_ROLES = [
  "admin",
  "executive_ceo",
  "executive_office_manager",
] as const;

function pad2(n: number): string {
  return String(n).padStart(2, "0");
}

/**
 * Reduce a rich-text snippet (Tiptap HTML) to plain text safe for a
 * push-notification body. Meeting titles are stored as rich text so the
 * scheduler can't just shove them into the payload — iOS shows the raw
 * `<p style="text-align:center">…</p>` markup verbatim on the lock
 * screen otherwise.
 */
function stripHtmlToPlainText(input: string | null | undefined): string {
  if (!input) return "";
  // Drop tags, collapse whitespace, decode the handful of named
  // entities Tiptap emits. Anything more (full HTML parser) would
  // pull a dependency for zero benefit on a 1-line notification.
  const noTags = input.replace(/<[^>]*>/g, " ");
  const decoded = noTags
    .replace(/&nbsp;/g, " ")
    .replace(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'");
  return decoded.replace(/\s+/g, " ").trim();
}

// Arabic minute pluralisation for the "starts after N minutes" reminder
// body. Arabic uses three forms for small counts: 1 -> دقيقة, 2 -> دقيقتين
// (dual), 3+ -> دقائق. The scheduler only fires for deltas of 1-5 min, but
// the helper stays correct for the dual/plural boundary regardless.
function minutesAfterAr(n: number): string {
  if (n <= 1) return "بعد دقيقة";
  if (n === 2) return "بعد دقيقتين";
  return `بعد ${n} دقائق`;
}

function dateKey(d: Date): string {
  return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}

function parseHHMM(s: string | null | undefined): { h: number; m: number } | null {
  if (!s) return null;
  const m = /^(\d{1,2}):(\d{2})/.exec(s);
  if (!m) return null;
  const h = Number(m[1]);
  const mm = Number(m[2]);
  if (!Number.isFinite(h) || !Number.isFinite(mm)) return null;
  return { h, m: mm };
}

/**
 * Convert a meeting's (meetingDate, startTime) into an absolute Date in
 * the server's local timezone (matches how the client computes the
 * 5-minute window). Returns null if either field is malformed.
 */
function meetingStartLocal(meetingDate: string, startTime: string | null): Date | null {
  const t = parseHHMM(startTime);
  if (!t) return null;
  const d = new Date(`${meetingDate}T${pad2(t.h)}:${pad2(t.m)}:00`);
  if (Number.isNaN(d.getTime())) return null;
  return d;
}

async function tickOnce(): Promise<void> {
  const now = new Date();
  // Query today AND tomorrow so a meeting at 00:02 is still picked up
  // when the previous calendar day is finishing at 23:57. The eligible
  // filter below trims to the actual 5-minute window so this is cheap.
  const todayKey = dateKey(now);
  const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
  const tomorrowKey = dateKey(tomorrow);

  const meetings = await db
    .select({
      id: executiveMeetingsTable.id,
      titleAr: executiveMeetingsTable.titleAr,
      titleEn: executiveMeetingsTable.titleEn,
      meetingDate: executiveMeetingsTable.meetingDate,
      startTime: executiveMeetingsTable.startTime,
      status: executiveMeetingsTable.status,
    })
    .from(executiveMeetingsTable)
    .where(
      and(
        inArray(executiveMeetingsTable.meetingDate, [todayKey, tomorrowKey]),
        // #626: only ring for meetings whose status is still the
        // default "scheduled". Previously we used a denylist
        // (ne cancelled + ne completed), which let postponed /
        // rescheduled / in_progress and any future status sneak past
        // and trigger a false 5-minute push reminder. Whitelist is
        // safer: any status other than "scheduled" is silenced.
        eq(executiveMeetingsTable.status, "scheduled"),
      ),
    );

  const eligible = meetings
    .map((m) => {
      const start = meetingStartLocal(m.meetingDate, m.startTime);
      if (!start) return null;
      const deltaMs = start.getTime() - now.getTime();
      if (deltaMs <= 0) return null;
      const deltaMin = Math.ceil(deltaMs / 60_000);
      if (deltaMin > LEAD_MINUTES) return null;
      return { meeting: m, deltaMin };
    })
    .filter((x): x is { meeting: typeof meetings[number]; deltaMin: number } => !!x);

  if (eligible.length === 0) return;

  const recipients = await getUserIdsForRoleNames(EM_NOTIFY_ROLES);
  if (recipients.length === 0) return;

  for (const { meeting, deltaMin } of eligible) {
    // Atomic claim: ON CONFLICT only updates pushed_at when it's still
    // NULL, and we RETURN only the rows we actually mutated. Two ticks
    // (or two instances) racing on the same (meeting,user) can never
    // both win — Postgres serialises the conflict resolution. Rows
    // that the user has already acknowledged or dismissed are excluded
    // from the update so we don't ring after the user said "Done".
    const claimed = await db
      .insert(executiveMeetingAlertStateTable)
      .values(
        recipients.map((userId) => ({
          meetingId: meeting.id,
          userId,
          pushedAt: now,
        })),
      )
      .onConflictDoUpdate({
        target: [
          executiveMeetingAlertStateTable.meetingId,
          executiveMeetingAlertStateTable.userId,
        ],
        set: { pushedAt: now },
        setWhere: and(
          sql`${executiveMeetingAlertStateTable.pushedAt} IS NULL`,
          eq(executiveMeetingAlertStateTable.dismissed, false),
          eq(executiveMeetingAlertStateTable.acknowledged, false),
        ),
      })
      .returning({ userId: executiveMeetingAlertStateTable.userId });

    if (claimed.length === 0) continue;

    const titleAr = "تذكير اجتماع";
    const titleEn = "Meeting reminder";
    const subjectAr =
      stripHtmlToPlainText(meeting.titleAr) ||
      stripHtmlToPlainText(meeting.titleEn) ||
      "اجتماع";
    const subjectEn =
      stripHtmlToPlainText(meeting.titleEn) ||
      stripHtmlToPlainText(meeting.titleAr) ||
      "Meeting";
    const bodyAr = `${subjectAr} يبدأ ${minutesAfterAr(deltaMin)}`;
    const bodyEn = `${subjectEn} starts in ${deltaMin} min`;

    for (const { userId } of claimed) {
      void sendPushToUser(
        userId,
        {
          title: subjectAr,
          body: bodyAr,
          titleAr,
          titleEn,
          bodyAr,
          bodyEn,
          tag: `em-reminder-${meeting.id}`,
          url: `/meetings?meeting=${meeting.id}`,
          type: "executive_meeting",
          relatedId: meeting.id,
        },
        { ignoreConnected: true },
      ).catch((err) => {
        logger.warn(
          { err, meetingId: meeting.id, userId },
          "executive-meeting-scheduler push failed",
        );
      });
    }

    logger.info(
      { meetingId: meeting.id, deltaMin, recipients: claimed.length },
      "executive-meeting-scheduler reminder fired",
    );
  }
}

let started = false;
let timer: NodeJS.Timeout | null = null;
let running = false;

export function startExecutiveMeetingScheduler(): void {
  if (started) return;
  started = true;
  const run = () => {
    // In-process guard: if the previous tick is still running (slow
    // DB, long push round-trip), skip this tick so we never overlap
    // and re-claim/re-send. Multi-process deployments still rely on
    // the atomic Postgres claim above for correctness.
    if (running) return;
    running = true;
    tickOnce()
      .catch((err) => {
        logger.error({ err }, "executive-meeting-scheduler tick failed");
      })
      .finally(() => {
        running = false;
      });
  };
  setImmediate(run);
  timer = setInterval(run, TICK_MS);
  logger.info({ tickMs: TICK_MS }, "executive-meeting-scheduler started");
}

export function stopExecutiveMeetingScheduler(): void {
  if (timer) clearInterval(timer);
  timer = null;
  started = false;
  running = false;
}
