import { prisma } from '../config/database';

// Workspace automation cron. Ticks every 30 minutes and processes three jobs
// per owner: auto-checkout, auto-cancel, and visit-reminder. Each owner has
// their own configurable HH:MM time in server local; the tick fires a job for
// an owner only when (a) we've crossed that time today and (b) we haven't
// already stamped lastAuto*At today. That dedupe makes the sweep idempotent
// across server restarts and lets us survive any single missed tick — the
// next tick within the day still catches up.
//
// setInterval beats node-cron here: zero new dependencies, runs in the API
// process which the user already keeps long-lived via pm2.

const TICK_MS = 30 * 60 * 1000; // 30 minutes

let started = false;

export function startVisitReminderCron(): void {
  if (started) return; // guard against double-start in tests / HMR
  started = true;
  const tick = async () => {
    try { await runAllOwners(); } catch (e) { console.error('[cron] tick failed', e); }
  };
  // Run once on boot so a server restart doesn't skip a tick. Then every
  // TICK_MS afterwards.
  tick();
  setInterval(tick, TICK_MS);
  console.log(`[cron] started — tick every ${TICK_MS / 60000} minutes (auto-checkout + auto-cancel + reminders, per-owner)`);
}

// "HH:MM" → minutes-from-midnight. Falls back to 0 on bad input rather than
// throwing, so a single malformed row can't kill the whole tick.
function timeToMinutes(hhmm: string | null | undefined): number {
  if (!hhmm) return 0;
  const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
  if (!m) return 0;
  const h = Number(m[1]);
  const mm = Number(m[2]);
  if (!Number.isFinite(h) || !Number.isFinite(mm)) return 0;
  return Math.min(23, Math.max(0, h)) * 60 + Math.min(59, Math.max(0, mm));
}

// True when the configured time-of-day has already arrived today AND we
// haven't yet stamped a run for today. Used to gate the per-owner sweeps so
// each job fires once per calendar day.
function shouldRunToday(now: Date, hhmm: string, lastRunAt: Date | null): boolean {
  const target = timeToMinutes(hhmm);
  const nowMin = now.getHours() * 60 + now.getMinutes();
  if (nowMin < target) return false;
  if (!lastRunAt) return true;
  // Compare calendar dates: a run earlier today suppresses, a run yesterday
  // (or older) does not.
  const last = new Date(lastRunAt);
  return (
    last.getFullYear() !== now.getFullYear() ||
    last.getMonth() !== now.getMonth() ||
    last.getDate() !== now.getDate()
  );
}

async function runAllOwners(): Promise<void> {
  // If this code deploys BEFORE the 20260526120000_owner_automation_timings
  // migration, the new columns don't exist yet and this query throws. Swallow
  // that single class of error so the API process stays healthy — the cron
  // will start working as soon as ops applies the migration; no restart needed.
  let owners: Array<{
    id: string;
    autoCheckoutTime: string;
    autoCancelTime: string;
    reminderHoursBefore: number;
    lastAutoCheckoutAt: Date | null;
    lastAutoCancelAt: Date | null;
  }>;
  try {
    owners = await prisma.owner.findMany({
      where: { suspendedAt: null },
      select: {
        id: true,
        autoCheckoutTime: true,
        autoCancelTime: true,
        reminderHoursBefore: true,
        lastAutoCheckoutAt: true,
        lastAutoCancelAt: true,
      },
    });
  } catch (e: any) {
    // Postgres error 42703 = "column does not exist". Anything else is a real
    // failure and re-throws so the outer tick catch logs it.
    if (typeof e?.message === 'string' && /column .* does not exist/i.test(e.message)) {
      console.warn('[cron] owner.automation columns missing — apply 20260526120000 migration to enable cron');
      return;
    }
    throw e;
  }
  const now = new Date();
  for (const o of owners) {
    if (shouldRunToday(now, o.autoCheckoutTime, o.lastAutoCheckoutAt)) {
      try { await runAutoCheckoutSweep(o.id, now); }
      catch (e) { console.error(`[checkout-cron] ${o.id} failed`, e); }
    }
    if (shouldRunToday(now, o.autoCancelTime, o.lastAutoCancelAt)) {
      try { await runAutoCancelSweep(o.id, now); }
      catch (e) { console.error(`[cancel-cron] ${o.id} failed`, e); }
    }
    try { await runReminderTick(o.id, o.reminderHoursBefore); }
    catch (e) { console.error(`[reminder-cron] ${o.id} failed`, e); }
  }
}

// Compose a Date out of (visitDate, visitTime "HH:MM"). Returns null when
// either is missing.
function composeVisitMoment(visitDate: Date | null, visitTime: string | null): Date | null {
  if (!visitDate || !visitTime) return null;
  const [hh, mm] = visitTime.split(':').map(Number);
  if (Number.isNaN(hh) || Number.isNaN(mm)) return null;
  const m = new Date(visitDate);
  m.setHours(hh, mm, 0, 0);
  return m;
}

// Auto-checkout sweep — every ARRIVED visitor for this owner whose arrivedAt
// was on a day strictly before today's local-server date gets stamped now.
// Idempotent (only touches rows where checkedOutAt IS NULL), so re-running
// within the same calendar day is a no-op. lastAutoCheckoutAt stamps the
// owner row to prevent duplicate "swept X rows" log lines.
async function runAutoCheckoutSweep(ownerId: string, now: Date): Promise<void> {
  const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
  const result = await prisma.visitor.updateMany({
    where: {
      ownerId,
      status: 'ARRIVED',
      arrivedAt: { lt: startOfToday },
      // Frequent visitors stay ARRIVED indefinitely between scans —
      // the next scan is what would re-arrive them. Don't sweep them
      // into CHECKED_OUT at midnight; reception can manually click
      // "Check out" if they really need to flip the state.
      isFrequent: false,
    },
    data: { status: 'CHECKED_OUT', checkedOutAt: now },
  });
  await prisma.owner.update({ where: { id: ownerId }, data: { lastAutoCheckoutAt: now } });
  if (result.count > 0) {
    console.log(`[checkout-cron] ${ownerId} — swept ${result.count} stale ARRIVED → CHECKED_OUT`);
  }
}

// Auto-cancel sweep — EXPECTED + AWAITING_APPROVAL visitors whose visitDate
// is strictly before today get flipped to CANCELLED. Catches no-shows and
// invites that nobody acted on. Visitors without a visitDate are left
// alone (we can't infer staleness for them). When a visitor later tries
// to scan their QR the scanner returns "Invite cancelled" so reception
// knows the pass is dead.
//
// Bounded lookback: we only cancel rows whose visitDate is within the
// last 7 days. Older rows are presumed historical noise the operator
// already accepted as "ancient pending" — silently mass-flipping them on
// the day this feature ships would be a confusing surprise. Steady-state
// the daily sweep still catches every yesterday no-show.
const AUTO_CANCEL_LOOKBACK_DAYS = 7;

async function runAutoCancelSweep(ownerId: string, now: Date): Promise<void> {
  const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
  const lookbackStart = new Date(startOfToday);
  lookbackStart.setDate(lookbackStart.getDate() - AUTO_CANCEL_LOOKBACK_DAYS);
  const result = await prisma.visitor.updateMany({
    where: {
      ownerId,
      status: { in: ['EXPECTED', 'AWAITING_APPROVAL'] },
      visitDate: { gte: lookbackStart, lt: startOfToday },
      // Frequent visitors with no-shows aren't "cancelled" — they're
      // expected to come back another day. Skip them in the sweep.
      isFrequent: false,
    },
    data: { status: 'CANCELLED' },
  });
  await prisma.owner.update({ where: { id: ownerId }, data: { lastAutoCancelAt: now } });
  if (result.count > 0) {
    console.log(`[cancel-cron] ${ownerId} — cancelled ${result.count} stale EXPECTED/AWAITING rows (last ${AUTO_CANCEL_LOOKBACK_DAYS} days)`);
  }
}

async function runReminderTick(ownerId: string, hoursBefore: number): Promise<void> {
  const leadMs = Math.max(1, hoursBefore) * 60 * 60 * 1000;
  const windowMs = TICK_MS; // tolerate one tick width on either side
  const target = Date.now() + leadMs;
  const windowLow = new Date(target - windowMs);
  const windowHigh = new Date(target + windowMs);

  // Pull EXPECTED visitors whose visitDate could plausibly land in the
  // window. Per-row filter below picks the ones whose composed moment
  // actually lands inside (visitTime is a string, can't filter
  // server-side cheaply). A visitor with NEITHER email nor mobile would
  // never trigger a send, so we skip them up front to keep the candidate
  // set tight.
  const candidates = await prisma.visitor.findMany({
    where: {
      ownerId,
      status: 'EXPECTED',
      OR: [{ email: { not: null } }, { mobile: { not: null } }],
      reminderSentAt: null,
      visitDate: { gte: new Date(), lte: windowHigh },
    },
    take: 500,
  });
  if (candidates.length === 0) return;

  const { sendVisitReminder } = await import('../controllers/visitors.controller');
  let sent = 0;
  for (const v of candidates) {
    const moment = composeVisitMoment(v.visitDate, v.visitTime);
    if (!moment) continue;
    const t = moment.getTime();
    if (t < windowLow.getTime() || t > windowHigh.getTime()) continue;
    try {
      // Email reminder only. WhatsApp templates are never auto-sent — the host
      // sends them manually from the WhatsApp picker.
      await sendVisitReminder(v.ownerId, v);
      await prisma.visitor.update({ where: { id: v.id }, data: { reminderSentAt: new Date() } });
      sent++;
    } catch (e) {
      console.error(`[reminder-cron] send failed for ${v.shortId}`, e);
    }
  }
  if (sent > 0) console.log(`[reminder-cron] ${ownerId} — sent ${sent} reminder(s)`);
}
