const crypto = require("crypto");
const path = require("path");

const PLUGIN_ID = "birthday";
const TIMER_KEY = Symbol.for("lumi.birthday.interval");
const ALLOWED_PRIVACY = new Set(["public", "limited", "private"]);
const ALLOWED_PLACEHOLDERS = new Set([
  "username",
  "display_name",
  "pronoun",
  "pronoun_subject",
  "pronoun_object",
  "pronoun_possessive",
  "birthday",
  "birthday_day",
  "birthday_day_text",
  "birthday_month",
  "time_until_birthday",
  "days_until_birthday",
  "months_until_birthday",
  "age_before",
  "age_after",
  "birthday_weekday",
  "gift_amount",
  "gift_amount_text",
  "currency_name"
]);

const DEFAULT_FULL = [
  "Happy birthday, {display_name}! {display_name} is turning {age_after} today.",
  "Everyone wish {display_name} a happy birthday! From {age_before} to {age_after} - hope today is amazing."
];

const DEFAULT_PARTIAL = [
  "Happy birthday, {display_name}! Hope today is full of good vibes and cake.",
  "Everyone wish {display_name} a happy birthday today!"
];

const DEFAULTS = {
  enabled: "1",
  announcement_channel_id: "",
  timezone: "UTC",
  leap_day_policy: "feb28",
  gift_mode: "automatic",
  gift_amount: "0",
  birthday_check_interval_minutes: "60",
  response_templates: JSON.stringify({
    fullYear: DEFAULT_FULL.map((text) => newTemplate(text)),
    partialYear: DEFAULT_PARTIAL.map((text) => newTemplate(text))
  })
};

module.exports = {
  id: PLUGIN_ID,
  init({ web, db, settings, commandRouter, discordClient }) {
    ensureTables(db);
    ensureDefaults(db);
    registerCommands({ db, settings, commandRouter });

    const router = web.createRouter();

    router.get("/", async (req, res) => {
      const user = req.session.user || null;
      if (!canModerate(user)) {
        return renderDenied(res);
      }
      const config = getConfig(db);
      const diagnostics = await buildDiagnostics(discordClient, config);
      res.render(path.join(__dirname, "views", "birthday-admin.ejs"), {
        title: "Birthdays",
        config,
        diagnostics,
        textChannels: getTextChannels(discordClient),
        isAdmin: Boolean(user?.isAdmin),
        allowedPlaceholders: Array.from(ALLOWED_PLACEHOLDERS).sort(),
        previewTemplate,
        formatDateTime
      });
    });

    router.post("/settings", async (req, res) => {
      const user = req.session.user || null;
      if (!canModerate(user)) {
        return renderDenied(res);
      }
      const config = getConfig(db);
      const next = {
        enabled: req.body.enabled === "on" ? "1" : "0",
        announcement_channel_id: (req.body.announcement_channel_id || "").trim(),
        timezone: (req.body.timezone || "UTC").trim(),
        leap_day_policy: req.body.leap_day_policy === "mar1" ? "mar1" : "feb28",
        gift_mode: req.body.gift_mode === "manual" ? "manual" : "automatic",
        birthday_check_interval_minutes: String(clampInt(req.body.birthday_check_interval_minutes, 5, 1440, 60)),
        gift_amount: config.gift_amount,
        response_templates: JSON.stringify(config.response_templates)
      };
      if (!isValidTimezone(next.timezone)) {
        req.session.flash = { type: "error", message: "Timezone must be a valid IANA timezone." };
        return res.redirect(`/plugins/${PLUGIN_ID}`);
      }
      if (next.announcement_channel_id) {
        const channel = await validateTextChannel(discordClient, next.announcement_channel_id);
        if (!channel.valid) {
          req.session.flash = { type: "error", message: channel.message };
          return res.redirect(`/plugins/${PLUGIN_ID}`);
        }
      }
      if (user?.isAdmin) {
        next.gift_amount = String(Math.max(0, parseInt(req.body.gift_amount || "0", 10) || 0));
      } else if ((req.body.gift_amount || "").trim() !== String(config.gift_amount)) {
        req.session.flash = { type: "error", message: "Only admins can change the birthday gift amount." };
        return res.redirect(`/plugins/${PLUGIN_ID}`);
      }
      saveConfig(db, next);
      restartScheduler({ db, discordClient });
      req.session.flash = { type: "success", message: "Birthday settings saved." };
      res.redirect(`/plugins/${PLUGIN_ID}`);
    });

    router.post("/templates/create", (req, res) => {
      if (!canModerate(req.session.user)) {
        return renderDenied(res);
      }
      const pool = normalizePool(req.body.pool);
      const text = (req.body.text || "").trim();
      const error = validateTemplate(text);
      if (error) {
        req.session.flash = { type: "error", message: error };
        return res.redirect(`/plugins/${PLUGIN_ID}`);
      }
      const config = getConfig(db);
      config.response_templates[pool].push(newTemplate(text));
      saveConfig(db, serializeConfig(config));
      req.session.flash = { type: "success", message: "Template added." };
      res.redirect(`/plugins/${PLUGIN_ID}`);
    });

    router.post("/templates/:id/update", (req, res) => {
      if (!canModerate(req.session.user)) {
        return renderDenied(res);
      }
      const pool = normalizePool(req.body.pool);
      const text = (req.body.text || "").trim();
      const error = validateTemplate(text);
      if (error) {
        req.session.flash = { type: "error", message: error };
        return res.redirect(`/plugins/${PLUGIN_ID}`);
      }
      const config = getConfig(db);
      const template = config.response_templates[pool].find((item) => item.id === req.params.id);
      if (!template) {
        req.session.flash = { type: "error", message: "Template not found." };
        return res.redirect(`/plugins/${PLUGIN_ID}`);
      }
      template.text = text;
      template.enabled = req.body.enabled === "on";
      template.updatedAt = Date.now();
      saveConfig(db, serializeConfig(config));
      req.session.flash = { type: "success", message: "Template updated." };
      res.redirect(`/plugins/${PLUGIN_ID}`);
    });

    router.post("/templates/:id/duplicate", (req, res) => {
      if (!canModerate(req.session.user)) {
        return renderDenied(res);
      }
      const pool = normalizePool(req.body.pool);
      const config = getConfig(db);
      const source = config.response_templates[pool].find((item) => item.id === req.params.id);
      if (source) {
        config.response_templates[pool].push(newTemplate(source.text));
        saveConfig(db, serializeConfig(config));
      }
      req.session.flash = { type: "success", message: "Template duplicated." };
      res.redirect(`/plugins/${PLUGIN_ID}`);
    });

    router.post("/templates/:id/remove", (req, res) => {
      if (!canModerate(req.session.user)) {
        return renderDenied(res);
      }
      const pool = normalizePool(req.body.pool);
      const config = getConfig(db);
      config.response_templates[pool] = config.response_templates[pool].filter((item) => item.id !== req.params.id);
      saveConfig(db, serializeConfig(config));
      req.session.flash = { type: "success", message: "Template removed." };
      res.redirect(`/plugins/${PLUGIN_ID}`);
    });

    router.post("/profile", (req, res) => {
      const user = req.session.user || null;
      if (!user?.id) {
        return renderDenied(res);
      }
      const parsed = parseBirthdayInput(req.body.birthday || "");
      if (!parsed.ok) {
        req.session.flash = { type: "error", message: parsed.message };
        return res.redirect("/profile");
      }
      const privacy = normalizePrivacy(req.body.privacy);
      upsertBirthday(db, user.id, { ...parsed, privacy });
      req.session.flash = {
        type: "success",
        message: `Stored birthday: ${formatBirthday(parsed)}. Privacy: ${privacy}.`
      };
      res.redirect("/profile");
    });

    router.post("/profile/unset", (req, res) => {
      const user = req.session.user || null;
      if (!user?.id) {
        return renderDenied(res);
      }
      deleteBirthday(db, user.id);
      req.session.flash = { type: "success", message: "Birthday removed." };
      res.redirect("/profile");
    });

    router.get("/u/:username", (req, res) => {
      const viewer = req.session.user || null;
      const target = findUser(db, req.params.username);
      if (!target) {
        return res.status(404).render("error", {
          title: "Birthday not found",
          message: "No matching Lumi user was found."
        });
      }
      const birthday = getBirthday(db, target.id);
      const canView = birthday && canViewBirthday({ viewer, ownerId: target.id, privacy: birthday.privacy, commandContext: false });
      res.render(path.join(__dirname, "views", "profile-birthday.ejs"), {
        title: `${target.internal_username} Birthday`,
        target,
        birthday,
        canView,
        viewer,
        formatBirthday,
        formatBirthdayDateOnly,
        publicUrl: `/plugins/${PLUGIN_ID}/u/${encodeURIComponent(target.internal_username)}`
      });
    });

    web.mount(`/plugins/${PLUGIN_ID}`, router, {
      label: "Birthdays",
      role: "mod",
      section: "plugins"
    });
    ensureSidebarNavItem(settings);

    if (typeof web.addProfileSection === "function") {
      web.addProfileSection({
        id: PLUGIN_ID,
        label: "Birthday",
        view: path.join(__dirname, "views", "profile-birthday.ejs"),
        order: 45,
        locals: {
          profileSection: true,
          getBirthday: (userId) => getBirthday(db, userId),
          formatBirthday,
          formatBirthdayDateOnly
        }
      });
    }

    restartScheduler({ db, discordClient });
  }
};

function ensureTables(db) {
  db.exec(`
    CREATE TABLE IF NOT EXISTS birthday_profiles (
      user_id TEXT PRIMARY KEY REFERENCES user_profiles(id) ON DELETE CASCADE,
      year INTEGER NULL,
      month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
      day INTEGER NOT NULL CHECK(day BETWEEN 1 AND 31),
      privacy TEXT NOT NULL DEFAULT 'limited' CHECK(privacy IN ('public','limited','private')),
      created_at INTEGER NOT NULL,
      updated_at INTEGER NOT NULL
    );

    CREATE TABLE IF NOT EXISTS birthday_deliveries (
      id TEXT PRIMARY KEY,
      user_id TEXT NOT NULL,
      delivery_key TEXT NOT NULL,
      delivery_type TEXT NOT NULL CHECK(delivery_type IN ('announcement','gift')),
      status TEXT NOT NULL CHECK(status IN ('sent','skipped','failed','claimed')),
      details TEXT NULL,
      created_at INTEGER NOT NULL,
      UNIQUE(user_id, delivery_key, delivery_type)
    );

    CREATE INDEX IF NOT EXISTS birthday_profiles_month_day_idx ON birthday_profiles(month, day);
    CREATE INDEX IF NOT EXISTS birthday_deliveries_key_idx ON birthday_deliveries(delivery_key, delivery_type);
  `);
}

function ensureDefaults(db) {
  const now = Date.now();
  for (const [key, value] of Object.entries(DEFAULTS)) {
    db.prepare(
      "INSERT OR IGNORE INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?)"
    ).run(PLUGIN_ID, key, value, now);
  }
}

function getConfig(db) {
  const rows = db.prepare("SELECT key, value FROM plugin_settings WHERE plugin_id = ?").all(PLUGIN_ID);
  const raw = { ...DEFAULTS };
  for (const row of rows) {
    raw[row.key] = row.value;
  }
  let templates;
  try {
    templates = JSON.parse(raw.response_templates);
  } catch {
    templates = {};
  }
  return {
    enabled: raw.enabled === "1",
    announcement_channel_id: raw.announcement_channel_id || "",
    timezone: isValidTimezone(raw.timezone) ? raw.timezone : "UTC",
    leap_day_policy: raw.leap_day_policy === "mar1" ? "mar1" : "feb28",
    gift_mode: raw.gift_mode === "manual" ? "manual" : "automatic",
    gift_amount: Math.max(0, parseInt(raw.gift_amount || "0", 10) || 0),
    birthday_check_interval_minutes: clampInt(raw.birthday_check_interval_minutes, 5, 1440, 60),
    response_templates: normalizeTemplates(templates)
  };
}

function serializeConfig(config) {
  return {
    enabled: config.enabled ? "1" : "0",
    announcement_channel_id: config.announcement_channel_id || "",
    timezone: config.timezone || "UTC",
    leap_day_policy: config.leap_day_policy === "mar1" ? "mar1" : "feb28",
    gift_mode: config.gift_mode === "manual" ? "manual" : "automatic",
    gift_amount: String(Math.max(0, parseInt(config.gift_amount || "0", 10) || 0)),
    birthday_check_interval_minutes: String(clampInt(config.birthday_check_interval_minutes, 5, 1440, 60)),
    response_templates: JSON.stringify(normalizeTemplates(config.response_templates))
  };
}

function saveConfig(db, values) {
  const now = Date.now();
  for (const [key, value] of Object.entries(values)) {
    db.prepare(
      "INSERT INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?) " +
        "ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
    ).run(PLUGIN_ID, key, String(value), now);
  }
}

function normalizeTemplates(value) {
  return {
    fullYear: normalizeTemplateList(value?.fullYear, DEFAULT_FULL),
    partialYear: normalizeTemplateList(value?.partialYear, DEFAULT_PARTIAL)
  };
}

function normalizeTemplateList(items, defaults) {
  const source = Array.isArray(items) && items.length ? items : defaults.map((text) => newTemplate(text));
  return source
    .map((item) => ({
      id: item?.id || crypto.randomUUID(),
      text: (typeof item === "string" ? item : item?.text || "").toString(),
      enabled: item?.enabled !== false,
      createdAt: Number(item?.createdAt) || Date.now(),
      updatedAt: Number(item?.updatedAt) || Date.now()
    }))
    .filter((item) => item.text.trim());
}

function newTemplate(text) {
  const now = Date.now();
  return { id: crypto.randomUUID(), text, enabled: true, createdAt: now, updatedAt: now };
}

function registerCommands({ db, settings, commandRouter }) {
  if (!commandRouter) {
    return;
  }
  commandRouter.registerCommands(PLUGIN_ID, [
    {
      id: "birthday",
      triggers: ["birthday", "bday"],
      platforms: ["discord", "twitch", "youtube"],
      handler: (ctx) => handleBirthdayCommand({ ctx, db, settings })
    }
  ]);
}

async function handleBirthdayCommand({ ctx, db, settings }) {
  const prefix = settings?.getSetting ? settings.getSetting("command_prefix", "!") : "!";
  const sub = (ctx.args[0] || "").toLowerCase();
  if (!sub) {
    const own = getBirthday(db, ctx.user.id);
    const suffix = own ? ` Your birthday is ${formatBirthday(own)} (${own.privacy}).` : "";
    await ctx.reply(`Usage: ${prefix}birthday set YYYY/MM/DD, ${prefix}birthday set MM/DD, ${prefix}birthday unset, ${prefix}birthday <user>, or ${prefix}birthday claim.${suffix}`);
    return true;
  }
  if (sub === "set") {
    const input = ctx.args[1] || "";
    if (!input) {
      await ctx.reply("Use YYYY/MM/DD for a full birthday or MM/DD if you do not want to store the year. Year is optional but recommended for age-based messages.");
      return true;
    }
    const parsed = parseBirthdayInput(input);
    if (!parsed.ok) {
      await ctx.reply(parsed.message);
      return true;
    }
    const existing = getBirthday(db, ctx.user.id);
    const privacy = existing?.privacy || "limited";
    upsertBirthday(db, ctx.user.id, { ...parsed, privacy });
    await ctx.reply(`Stored birthday: ${formatBirthday(parsed)}. Privacy: ${privacy}.`);
    return true;
  }
  if (sub === "unset") {
    deleteBirthday(db, ctx.user.id);
    await ctx.reply("Birthday removed.");
    return true;
  }
  if (sub === "claim") {
    await ctx.reply(await claimGift(db, ctx.user.id));
    return true;
  }
  const targetText = ctx.argsText.trim();
  const target = findUser(db, targetText);
  if (!target) {
    await ctx.reply("No matching Lumi user was found.");
    return true;
  }
  const birthday = getBirthday(db, target.id);
  if (!birthday) {
    await ctx.reply("No birthday on file for that user.");
    return true;
  }
  if (!canViewBirthday({ viewer: { id: ctx.user.id }, ownerId: target.id, privacy: birthday.privacy, commandContext: true })) {
    await ctx.reply("That birthday is private.");
    return true;
  }
  await ctx.reply(`${target.internal_username}'s birthday is ${formatBirthdayDateOnly(birthday)}.`);
  return true;
}

function parseBirthdayInput(input) {
  const text = (input || "").trim();
  const full = text.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/);
  const partial = text.match(/^(\d{1,2})\/(\d{1,2})$/);
  const nowYear = new Date().getUTCFullYear();
  if (full) {
    const year = parseInt(full[1], 10);
    const month = parseInt(full[2], 10);
    const day = parseInt(full[3], 10);
    if (year < 1900 || year > nowYear) {
      return { ok: false, message: `Year must be between 1900 and ${nowYear}.` };
    }
    if (!isValidDate(year, month, day)) {
      return { ok: false, message: "Birthday must be a real calendar date in YYYY/MM/DD format." };
    }
    return { ok: true, year, month, day };
  }
  if (partial) {
    const month = parseInt(partial[1], 10);
    const day = parseInt(partial[2], 10);
    if (!isValidMonthDay(month, day)) {
      return { ok: false, message: "Birthday must be a real calendar date in MM/DD format." };
    }
    return { ok: true, year: null, month, day };
  }
  return { ok: false, message: "Use slash-delimited YYYY/MM/DD or MM/DD. DD/MM and dash-separated dates are not accepted." };
}

function isValidDate(year, month, day) {
  const date = new Date(Date.UTC(year, month - 1, day));
  return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
}

function isValidMonthDay(month, day) {
  return month === 2 && day === 29 ? true : isValidDate(2000, month, day);
}

function upsertBirthday(db, userId, birthday) {
  const now = Date.now();
  db.prepare(
    "INSERT INTO birthday_profiles (user_id, year, month, day, privacy, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) " +
      "ON CONFLICT(user_id) DO UPDATE SET year = excluded.year, month = excluded.month, day = excluded.day, privacy = excluded.privacy, updated_at = excluded.updated_at"
  ).run(userId, birthday.year || null, birthday.month, birthday.day, normalizePrivacy(birthday.privacy), now, now);
}

function deleteBirthday(db, userId) {
  db.prepare("DELETE FROM birthday_profiles WHERE user_id = ?").run(userId);
}

function getBirthday(db, userId) {
  return userId ? db.prepare("SELECT * FROM birthday_profiles WHERE user_id = ?").get(userId) : null;
}

function findUser(db, text) {
  const raw = (text || "").trim();
  if (!raw) {
    return null;
  }
  const mention = raw.match(/^<@!?(\d+)>$/);
  if (mention) {
    const row = db.prepare("SELECT p.id, p.internal_username FROM user_profiles p JOIN user_identities i ON i.user_id = p.id WHERE i.provider = 'discord' AND i.provider_user_id = ?").get(mention[1]);
    if (row) return row;
  }
  const cleaned = raw.replace(/^@/, "");
  return db.prepare("SELECT id, internal_username FROM user_profiles WHERE lower(internal_username) = lower(?) LIMIT 1").get(cleaned) ||
    db.prepare("SELECT p.id, p.internal_username FROM user_profiles p JOIN user_identities i ON i.user_id = p.id WHERE lower(i.display_name) = lower(?) OR i.provider_user_id = ? LIMIT 1").get(cleaned, cleaned);
}

function canViewBirthday({ viewer, ownerId, privacy, commandContext }) {
  if (viewer?.id && viewer.id === ownerId) {
    return true;
  }
  if (viewer?.isAdmin || viewer?.isMod) {
    return true;
  }
  if (privacy === "public") {
    return true;
  }
  if (privacy === "limited") {
    return commandContext ? Boolean(viewer?.id) : Boolean(viewer?.id);
  }
  return false;
}

function normalizePrivacy(value) {
  return ALLOWED_PRIVACY.has(value) ? value : "limited";
}

function restartScheduler({ db, discordClient }) {
  if (global[TIMER_KEY]) {
    clearInterval(global[TIMER_KEY]);
    global[TIMER_KEY] = null;
  }
  const config = getConfig(db);
  if (!config.enabled) {
    return;
  }
  checkBirthdays({ db, discordClient }).catch((error) => console.error("Birthday check failed", error));
  global[TIMER_KEY] = setInterval(() => {
    checkBirthdays({ db, discordClient }).catch((error) => console.error("Birthday check failed", error));
  }, config.birthday_check_interval_minutes * 60 * 1000);
}

async function checkBirthdays({ db, discordClient }) {
  const config = getConfig(db);
  if (!config.enabled) {
    return;
  }
  const today = getZonedDateParts(config.timezone);
  const effectiveDates = getEffectiveDates(today, config.leap_day_policy);
  const channelResult = config.announcement_channel_id
    ? await validateTextChannel(discordClient, config.announcement_channel_id)
    : { valid: false, message: "No birthday channel configured." };
  const birthdays = db.prepare(
    "SELECT b.*, p.internal_username FROM birthday_profiles b JOIN user_profiles p ON p.id = b.user_id WHERE " +
      effectiveDates.map(() => "(b.month = ? AND b.day = ?)").join(" OR ")
  ).all(...effectiveDates.flatMap((date) => [date.month, date.day]));
  for (const birthday of birthdays) {
    const deliveryKey = `${today.year}-${pad2(today.month)}-${pad2(today.day)}`;
    const announceReserved = reserveDelivery(db, birthday.user_id, deliveryKey, "announcement", channelResult.valid ? "sent" : "skipped", channelResult.message);
    if (!announceReserved) {
      continue;
    }
    let gift = null;
    if (config.gift_mode === "automatic") {
      gift = grantGiftOnce(db, birthday.user_id, deliveryKey, "automatic");
    }
    if (!channelResult.valid || !channelResult.channel) {
      continue;
    }
    const message = buildBirthdayMessage({ db, config, birthday, today, gift });
    await channelResult.channel.send({ content: message, allowedMentions: { parse: [] } });
  }
}

function reserveDelivery(db, userId, deliveryKey, type, status, details) {
  try {
    db.prepare(
      "INSERT INTO birthday_deliveries (id, user_id, delivery_key, delivery_type, status, details, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
    ).run(crypto.randomUUID(), userId, deliveryKey, type, status, details || null, Date.now());
    return true;
  } catch {
    return false;
  }
}

function grantGiftOnce(db, userId, deliveryKey, mode) {
  const config = getConfig(db);
  const amount = config.gift_amount;
  const framework = global.lumiFrameworks?.economy;
  if (!amount || !framework || typeof framework.addBalance !== "function") {
    return null;
  }
  if (!reserveDelivery(db, userId, deliveryKey, "gift", mode === "manual" ? "claimed" : "sent", "Gift reserved.")) {
    return null;
  }
  try {
    framework.addBalance({
      userId,
      amount,
      note: "Birthday Gift",
      meta: { source: "birthday", deliveryKey, mode },
      allowFrozen: false
    });
    return { amount, currencyName: getCurrencyName(framework) };
  } catch (error) {
    db.prepare("UPDATE birthday_deliveries SET status = 'failed', details = ? WHERE user_id = ? AND delivery_key = ? AND delivery_type = 'gift'")
      .run(error?.message || String(error), userId, deliveryKey);
    return null;
  }
}

async function claimGift(db, userId) {
  const config = getConfig(db);
  if (config.gift_mode !== "manual") {
    return "Birthday gifts are automatic right now.";
  }
  if (!config.gift_amount) {
    return "Birthday gifts are not configured right now.";
  }
  const birthday = getBirthday(db, userId);
  if (!birthday) {
    return "You do not have a birthday on file.";
  }
  const today = getZonedDateParts(config.timezone);
  if (!isBirthdayToday(birthday, today, config.leap_day_policy)) {
    return "Birthday gifts can only be claimed on your birthday.";
  }
  const deliveryKey = `${today.year}-${pad2(today.month)}-${pad2(today.day)}`;
  const gift = grantGiftOnce(db, userId, deliveryKey, "manual");
  return gift ? `Birthday gift claimed: ${gift.amount}.` : "Birthday gift is unavailable or was already claimed.";
}

function buildBirthdayMessage({ db, config, birthday, today, gift }) {
  const profile = db.prepare("SELECT internal_username FROM user_profiles WHERE id = ?").get(birthday.user_id);
  const tokens = buildTokens({ db, profile, birthday, today, gift });
  const pool = birthday.year ? config.response_templates.fullYear : config.response_templates.partialYear;
  const eligible = pool.filter((item) => item.enabled && templateUsable(item.text, tokens));
  const template = eligible.length ? eligible[Math.floor(Math.random() * eligible.length)].text : "Happy birthday, {display_name}!";
  return renderTemplate(template, tokens);
}

function buildTokens({ db, profile, birthday, today, gift }) {
  const upcoming = getUpcomingBirthday(birthday, today);
  const pronouns = getPronouns(db, birthday.user_id);
  const ageAfter = birthday.year ? upcoming.year - birthday.year : null;
  const daysUntil = daysBetween(today, upcoming);
  const currencyName = gift?.currencyName || "coins";
  return {
    username: profile?.internal_username || "Lumi user",
    display_name: profile?.internal_username || "Lumi user",
    pronoun: pronouns.value,
    pronoun_subject: pronouns.subject,
    pronoun_object: pronouns.object,
    pronoun_possessive: pronouns.possessive,
    birthday: formatBirthdayDateOnly(birthday),
    birthday_day: String(birthday.day),
    birthday_day_text: ordinal(birthday.day),
    birthday_month: monthName(birthday.month),
    time_until_birthday: daysUntil === 0 ? "today" : `${daysUntil} day(s)`,
    days_until_birthday: String(daysUntil),
    months_until_birthday: String(Math.floor(daysUntil / 30)),
    age_before: ageAfter === null ? null : String(ageAfter - 1),
    age_after: ageAfter === null ? null : String(ageAfter),
    birthday_weekday: weekdayName(upcoming.year, upcoming.month, upcoming.day),
    gift_amount: gift ? String(gift.amount) : null,
    gift_amount_text: gift ? `${gift.amount} ${currencyName}` : null,
    currency_name: gift ? currencyName : null
  };
}

function templateUsable(text, tokens) {
  for (const match of text.matchAll(/\{([^{}]+)\}/g)) {
    const key = match[1].trim();
    if (!Object.prototype.hasOwnProperty.call(tokens, key) || tokens[key] === null || tokens[key] === undefined) {
      return false;
    }
  }
  return true;
}

function renderTemplate(text, tokens) {
  return text.replace(/\{([^{}]+)\}/g, (full, key) => {
    const value = tokens[key.trim()];
    return value === null || value === undefined ? full : String(value);
  });
}

function validateTemplate(text) {
  if (!text) {
    return "Template text is required.";
  }
  if (text.length > 1800) {
    return "Template text is too long.";
  }
  for (const match of text.matchAll(/\{([^{}]+)\}/g)) {
    const key = match[1].trim();
    if (!ALLOWED_PLACEHOLDERS.has(key)) {
      return `Unknown placeholder: {${key}}.`;
    }
  }
  return null;
}

function previewTemplate(text) {
  return renderTemplate(text, {
    username: "lumi_user",
    display_name: "Lumi User",
    pronoun: "they/them",
    pronoun_subject: "they",
    pronoun_object: "them",
    pronoun_possessive: "their",
    birthday: "April 17",
    birthday_day: "17",
    birthday_day_text: "17th",
    birthday_month: "April",
    time_until_birthday: "2 months, 5 days",
    days_until_birthday: "66",
    months_until_birthday: "2",
    age_before: "30",
    age_after: "31",
    birthday_weekday: "Friday",
    gift_amount: "100",
    gift_amount_text: "100 coins",
    currency_name: "coins"
  });
}

function getPronouns(db, userId) {
  try {
    const row = db.prepare("SELECT pronoun_set, subject_pronoun FROM welcome_message_pronouns WHERE user_id = ?").get(userId);
    return pronounParts(row?.pronoun_set || row?.subject_pronoun);
  } catch {
    return pronounParts(null);
  }
}

function pronounParts(value) {
  const raw = (value || "they/them").toString().toLowerCase();
  if (raw.startsWith("he/")) return { value: raw, subject: "he", object: "him", possessive: "his" };
  if (raw.startsWith("she/")) return { value: raw, subject: "she", object: "her", possessive: "her" };
  if (raw.startsWith("it/")) return { value: raw, subject: "it", object: "it", possessive: "its" };
  return { value: raw || "they/them", subject: "they", object: "them", possessive: "their" };
}

function getZonedDateParts(timezone) {
  const parts = new Intl.DateTimeFormat("en-CA", {
    timeZone: timezone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit"
  }).formatToParts(new Date());
  const get = (type) => parseInt(parts.find((part) => part.type === type)?.value, 10);
  return { year: get("year"), month: get("month"), day: get("day") };
}

function getEffectiveDates(today, leapPolicy) {
  const dates = [{ month: today.month, day: today.day }];
  if (!isLeapYear(today.year)) {
    if (leapPolicy === "mar1" && today.month === 3 && today.day === 1) {
      dates.push({ month: 2, day: 29 });
    }
    if (leapPolicy !== "mar1" && today.month === 2 && today.day === 28) {
      dates.push({ month: 2, day: 29 });
    }
  }
  return dates;
}

function isBirthdayToday(birthday, today, leapPolicy) {
  return getEffectiveDates(today, leapPolicy).some((date) => date.month === birthday.month && date.day === birthday.day);
}

function getUpcomingBirthday(birthday, today) {
  let year = today.year;
  let month = birthday.month;
  let day = birthday.day;
  if (month === 2 && day === 29 && !isLeapYear(year)) {
    day = 28;
  }
  if (month < today.month || (month === today.month && day < today.day)) {
    year += 1;
    if (birthday.month === 2 && birthday.day === 29 && !isLeapYear(year)) {
      month = 2;
      day = 28;
    }
  }
  return { year, month, day };
}

function daysBetween(from, to) {
  const start = Date.UTC(from.year, from.month - 1, from.day);
  const end = Date.UTC(to.year, to.month - 1, to.day);
  return Math.max(0, Math.round((end - start) / 86400000));
}

function formatBirthday(birthday) {
  const base = `${monthName(birthday.month)} ${birthday.day}`;
  return birthday.year ? `${base}, ${birthday.year}` : `${base} (year not set)`;
}

function formatBirthdayDateOnly(birthday) {
  return `${monthName(birthday.month)} ${birthday.day}`;
}

function monthName(month) {
  return new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" }).format(new Date(Date.UTC(2000, month - 1, 1)));
}

function weekdayName(year, month, day) {
  return new Intl.DateTimeFormat("en-US", { weekday: "long", timeZone: "UTC" }).format(new Date(Date.UTC(year, month - 1, day)));
}

function ordinal(day) {
  const mod10 = day % 10;
  const mod100 = day % 100;
  if (mod10 === 1 && mod100 !== 11) return `${day}st`;
  if (mod10 === 2 && mod100 !== 12) return `${day}nd`;
  if (mod10 === 3 && mod100 !== 13) return `${day}rd`;
  return `${day}th`;
}

function isLeapYear(year) {
  return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}

function isValidTimezone(timezone) {
  try {
    new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date());
    return true;
  } catch {
    return false;
  }
}

function normalizePool(pool) {
  return pool === "partialYear" ? "partialYear" : "fullYear";
}

function clampInt(value, min, max, fallback) {
  const parsed = parseInt(value, 10);
  if (!Number.isFinite(parsed)) return fallback;
  return Math.max(min, Math.min(max, parsed));
}

function pad2(value) {
  return String(value).padStart(2, "0");
}

function getCurrencyName(framework) {
  try {
    const config = typeof framework.getConfig === "function" ? framework.getConfig() : null;
    return config?.currency?.plural || config?.currency?.name || "coins";
  } catch {
    return "coins";
  }
}

function canModerate(user) {
  return Boolean(user?.isAdmin || user?.isMod);
}

function ensureSidebarNavItem(settings) {
  if (!settings?.getSetting || !settings?.setSetting) {
    return;
  }
  const navId = "plugins_birthday";
  const raw = settings.getSetting("nav_structure", null);
  if (!raw) {
    return;
  }
  let structure = raw;
  if (typeof structure === "string") {
    try {
      structure = JSON.parse(structure);
    } catch {
      return;
    }
  }
  if (!structure?.enabled || !Array.isArray(structure.sections)) {
    return;
  }
  for (const section of structure.sections) {
    if (Array.isArray(section.items)) {
      section.items = section.items.filter((item) => item !== navId);
    }
  }
  let pluginsSection = structure.sections.find((section) => section.id === "plugins");
  if (!pluginsSection) {
    pluginsSection = {
      id: "plugins",
      label: "Plugins",
      icon: "blocks",
      items: []
    };
    structure.sections.push(pluginsSection);
  }
  pluginsSection.items = Array.isArray(pluginsSection.items) ? pluginsSection.items : [];
  pluginsSection.items.push(navId);
  settings.setSetting("nav_structure", structure);
}

function renderDenied(res) {
  return res.status(403).render("error", {
    title: "Access denied",
    message: "You do not have access to that page."
  });
}

async function buildDiagnostics(discordClient, config) {
  const channel = config.announcement_channel_id
    ? await validateTextChannel(discordClient, config.announcement_channel_id)
    : { valid: false, message: "No announcement channel configured." };
  return {
    discordAvailable: Boolean(discordClient),
    discordReady: Boolean(discordClient?.readyAt),
    channel,
    economyAvailable: Boolean(global.lumiFrameworks?.economy?.addBalance),
    currentDate: getZonedDateParts(config.timezone)
  };
}

function getTextChannels(discordClient) {
  const channels = [];
  if (!discordClient?.guilds?.cache) {
    return channels;
  }
  for (const guild of discordClient.guilds.cache.values()) {
    guild.channels?.cache?.forEach((channel) => {
      if (isRegularTextChannel(channel)) {
        channels.push({ id: channel.id, label: `${guild.name} - ${channel.name}` });
      }
    });
  }
  return channels.sort((a, b) => a.label.localeCompare(b.label));
}

async function validateTextChannel(discordClient, channelId) {
  if (!discordClient) {
    return { valid: false, message: "Discord client is unavailable." };
  }
  const channel = discordClient.channels?.cache?.get(channelId) ||
    (typeof discordClient.channels?.fetch === "function" ? await discordClient.channels.fetch(channelId).catch(() => null) : null);
  if (!channel) {
    return { valid: false, message: "Configured channel was not found." };
  }
  if (!isRegularTextChannel(channel)) {
    return { valid: false, message: "Birthday channel must be a regular text channel." };
  }
  return { valid: true, message: "Channel is valid.", channel };
}

function isRegularTextChannel(channel) {
  if (!channel || channel.isThread?.()) return false;
  return channel.type === "GUILD_TEXT" || channel.type === 0;
}

function formatDateTime(value) {
  return value ? new Date(value).toLocaleString() : "";
}
