import {
  Client,
  Events,
  GatewayIntentBits,
  GuildBasedChannel,
  Message,
} from "discord.js";
import { DB } from "sqlite";

import config from "./config.json" with { type: "json" };

const db = new DB("db.sqlite");
db.execute(`
  CREATE TABLE IF NOT EXISTS bag(
    userid INTEGER NOT NULL PRIMARY KEY,
    idx INTEGER
  );
  CREATE TABLE IF NOT EXISTS time(
    id INTEGER NOT NULL PRIMARY KEY,
    time INTEGER NOT NULL
  )
`);

const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    // allow enumeration of all members with the opt-in role
    GatewayIntentBits.GuildMembers,
    GatewayIntentBits.GuildPresences,
  ],
});

client.once(Events.ClientReady, (readyClient) => {
  console.log(`Ready! Logged in as ${readyClient.user.tag}`);
  const [timestamp] = db.query("SELECT time FROM time");
  if (!timestamp) {
    const date = getNextDate();
    console.log(`Scheduled next blogger for ${date}`);
    setTimeout(nextBloggerAndRepeat, date.getTime() - Date.now());
  } else {
    const date = new Date((timestamp[0] as number) * 1000);
    console.log(`Scheduled next blogger for ${date}`);
    setTimeout(nextBloggerAndRepeat, date.getTime() - Date.now());
  }
});

client.login(config.token);

function getNextDate(): Date {
  const date = new Date();
  // date.setUTCSeconds(date.getUTCSeconds() + 10);
  date.setUTCSeconds(0);
  date.setUTCMinutes(0);
  date.setUTCHours(date.getUTCHours() + 1);
  // date.setUTCHours(0);
  // date.setUTCDate(date.getUTCDate() + 1);
  return date;
}

async function nextBloggerAndRepeat() {
  await nextBlogger();
  const date = getNextDate();
  setTimeout(nextBloggerAndRepeat, date.getTime() - Date.now());
  console.log(`Scheduled next blogger for ${date}`);
  db.query(
    "INSERT INTO time(id,time) VALUES(0,?) ON CONFLICT(id) DO UPDATE SET time = ?",
    [
      Math.floor(date.getTime() / 1000),
      Math.floor(date.getTime() / 1000),
    ],
  );
}

// this uses a bag randomizer algorithm to keep things fair.

// it uses sqlite to provide robustness against crashes and bot restarts.
// it associates a userid with an idx, which is either an integer or null.
// when it's time to select the next blogger,
// it selects all users with non-null idxs, and sorts by idx,
// then it pops the top one off that array, and marks that idx as null.

// this system allows it to handle someone opting in or out between bag shuffles.
// see below comments for the reasoning behind this.
async function nextBlogger() {
  const guild = await client.guilds.fetch(config.guildID);
  await guild.members.fetch(); // refresh the cache
  const optinRole = await guild.roles.fetch(config.optinRoleID);
  const selectedRole = await guild.roles.fetch(config.selectedRoleID);
  const blogChannel = await guild.channels.fetch(config.blogChannelID);

  const bagOfUsers: Array<string> = [];
  optinRole?.members.each((member) => bagOfUsers.push(member.id));

  for (const user of bagOfUsers) {
    const dbUser = db.query("SELECT userid FROM bag WHERE userid = ?", [user]);
    // check if this user opted-in before bag was empty.
    // if so we add them to the bag at a random idx.
    if (dbUser.length == 0) {
      const [[numUsersInDB]] = db.query<[number]>("SELECT COUNT(*) FROM bag");
      const idx = Math.floor(Math.random() * numUsersInDB);
      db.query(
        "INSERT INTO bag(userid,idx) VALUES(?,?) ON CONFLICT(userid) DO UPDATE SET idx = ?",
        [user, idx, idx],
      );
    }
  }

  // if bag is empty, refill and shuffle it
  const [[numUsersInDB]] = db.query(
    "SELECT COUNT(*) FROM bag WHERE idx = NULL",
  );
  if (numUsersInDB == 0) {
    shuffleUserBag(bagOfUsers);

    db.execute("DELETE FROM bag");
    for (const idx in bagOfUsers) {
      db.query("INSERT INTO bag(userid,idx) VALUES(?,?)", [
        bagOfUsers[idx],
        idx,
      ]);
    }
  }

  const dbBagOfUsers = db.query<[bigint]>(
    "SELECT userid FROM bag WHERE idx IS NOT NULL ORDER BY idx ASC",
  );

  const selectedUserRow = dbBagOfUsers.pop();
  console.log("selected user row " + selectedUserRow);
  if (!selectedUserRow) return; // nobody has opted-in
  const selectedUserID = (selectedUserRow[0] as bigint).toString();

  // check if this user opted-out before bag was empty.
  // if so we delete their db row and rerun this function.
  // this prevents someone from accidentally getting picked after they opted-out.
  if (!bagOfUsers.includes(selectedUserID)) {
    db.query("DELETE FROM bag WHERE userid = ?", [selectedUserID]);
    return nextBlogger();
  }

  db.query("UPDATE bag SET idx = NULL WHERE userid = ?", [selectedUserID]);
  selectedRole?.members.each(async (member) => {
    if (member.id != selectedUserID) {
      await member.roles.remove(config.selectedRoleID);
    }
  });
  const selectedUser = await guild.members.fetch(selectedUserID);
  selectedUser.roles.add(config.selectedRoleID);
  if (blogChannel) await sillyPing(blogChannel, selectedUserID);

  console.log(db.query("SELECT userid,idx FROM bag"));
}

// a lazy way to do this.
// i can use an array of template strings so long as whatever variable we use inside it is in scope.
// this probably evaluates ALL the strings, even though we discard all but one.
// but it's good enough.
function sillyPing(
  channel: GuildBasedChannel,
  userID: string,
): Promise<Message<true>> {
  if (!channel.isTextBased()) throw new Error("Channel is not text-based");
  const messages = [
    `<@${userID}>, it's your turn!`,
    `Go go go <@${userID}>!`,
    `<@${userID}>: ready, set, RAMBLE!!`,
    `Here's your 15 minutes of fame, <@${userID}>! No take-backsies!`,
  ];
  const message = messages[Math.floor(Math.random() * messages.length)];
  return channel.send(message);
}

// in-place shuffle
// https://stackoverflow.com/a/12646864
function shuffleUserBag(array: Array<unknown>) {
  for (let i = array.length - 1; i >= 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
}