import crypto from "crypto";
import { renderToStaticMarkup } from "react-dom/server";
import createMailgun from "mailgun-js";
import type { ActionFunction, LoaderFunction, Session } from "remix";
import { createCookieSessionStorage, json, redirect } from "remix";

/*******************************************************************************
 * Before we can do anything, we need to make sure the environment has
 * everything we need. If anything is missing, we just prevent the app from
 * starting up.
 */
if (typeof process.env.ORIGIN !== "string")
  throw new Error("Missing `process.env.ORIGIN`");

if (typeof process.env.SESSION_SECRET !== "string")
  throw new Error("Missing `process.env.SESSION_SECRET`");

if (typeof process.env.MAGIC_LINK_SALT !== "string")
  throw new Error("Missing `process.env.MAGIC_LINK_SALT`");

if (typeof process.env.MAILGUN_KEY !== "string")
  throw new Error("Missing process.env.MAILGUN_KEY");

if (typeof process.env.MAILGUN_DOMAIN !== "string")
  throw new Error("Missing `process.env.MAILGUN_DOMAIN`");

/*******************************************************************************
 * 1. It all starts with a "user session". A session is a fancy type of cookie
 * that references data either in the cookie directly or in some other storage
 * like a database (and the cookie holds value that can access the other
 * storage). In our case we're going to keep the data in the cookie itself since
 * we don't know what kind of database you've got.
 */
export let authSession = createCookieSessionStorage({
  cookie: {
    secrets: [process.env.SESSION_SECRET],
    path: "/",
    sameSite: "lax",
  },
});

// 30 days
let sessionMaxAge = /*seconds*/ 60 * /*hrs*/ 24 * /*days*/ 30;

/*******************************************************************************
 * 2. The whole point of authentication is to make sure we have a valid user
 * before showing them some pages. This function protects pages from
 * unauthenticated users. You call this from any loader/action that needs
 * authentication.
 *
 * This function will return the user session (with a way to refresh it, we'll
 * talk about that when you get to (7)). If there isn't a session, it redirects
 * to the "/auth" route by throwing a redirect response.
 *
 * Because you can `throw` a response in Remix, your loaders and actions don't
 * have to worry about doing the redirects themselves. Code in the loader will
 * stop executing and this function peforms a redirect right here.
 *
 * 6. All future requests to loaders/actions that require a user session will
 * call this function and they'll get the session instead of a login redirect.
 * Sessions are stored with cookies which have a "max age" value. This is how
 * long you want the browser to hang on to the cookie. The `refresh` function
 * allows loaders and actions to "refresh" the max age so it's always "since the
 * user last used it". If we didn't refresh, then sessions would always expire
 * even if the user is on your site every day.
 */
export async function getAuthSession(
  request: Request
): Promise<[Session, () => Promise<Headers>]> {
  let cookie = request.headers.get("cookie");
  let session = await authSession.getSession(cookie);

  if (!session.has("auth")) {
    throw redirect("/auth", {
      status: 303,
      headers: {
        "auth-redirect": getReferrer(request),
      },
    });
  }

  let refresh = async () =>
    new Headers({
      "Set-Cookie": await authSession.commitSession(session, {
        maxAge: sessionMaxAge,
      }),
    });

  return [session, refresh];
}

/*******************************************************************************
 * 3. The user is redirected to this loader from `getAuthSession` if they haven't
 * logged in yet. This loader is also used to validate tokens, but right now there
 * isn't a token so it just renders the route with a "referrer" so the token can
 * log them into the right page later. We'll be back here soon for that part.
 *
 * Now go to (4)
 *
 * 5. After the user clicks the link in their email we end up here again, but
 * this time we have a token in the URL. If it's valid, we set "auth" in the
 * session as we redirect to the landing page. We've got a user session!
 *
 * You might also do some work with your database here, like create a user
 * record.
 *
 * Now go up to (6)
 */
export let signInLoader: LoaderFunction = async ({ request }) => {
  let magicToken = getMagicToken(request);

  if (typeof magicToken !== "string") {
    return json({ landingPage: getReferrer(request) });
  }

  let magicLinkPayload = getMagicLink(magicToken);

  // might want to create user in the db
  // might want to create a db session instead of a cookie session
  // might set the user.id or session.id from a db instead of email
  let session = await authSession.getSession();
  session.set("auth", magicLinkPayload.email);

  return redirect(magicLinkPayload.landingPage, {
    headers: {
      "Set-Cookie": await authSession.commitSession(session, {
        maxAge: sessionMaxAge,
      }),
    },
  });
};

/*******************************************************************************
 * 4. After the user submits the form with their email address, we read the POST
 * body from the request, validate it, send the email, and finally render the
 * same route again but this time with action data. The UI then tells them to
 * check their email.
 *
 * No go back up to (5)
 */
export let signInAction: ActionFunction = async ({ request }) => {
  let body = Object.fromEntries(new URLSearchParams(await request.text()));

  if (typeof body.email !== "string" || body.email.indexOf("@") === -1) {
    throw json("Missing email", { status: 400 });
  }

  if (typeof body.landingPage !== "string") {
    throw json("Missing landing page", { status: 400 });
  }

  await sendMagicLinkEmail(body.email, body.landingPage);
  return json("ok");
};

function getMagicToken(request: Request) {
  let { searchParams } = new URL(request.url);
  return searchParams.get("key");
}

function getMagicLink(magicToken: string) {
  try {
    return validateMagicLink(magicToken);
  } catch (e) {
    throw json("Invalid magic link", { status: 400 });
  }
}

function getReferrer(request: Request) {
  // This doesn't work with all remix adapters yet, so pick a good default
  let referrer = request.referrer;
  if (referrer) {
    let url = new URL(referrer);
    return url.pathname + url.search;
  }
  return "/dashboard";
}

let magicLinkSearchParam = "magic";
let linkExpirationTime = 1000 * 60 * 30;
let algorithm = "aes-256-ctr";
let ivLength = 16;

let encryptionKey = crypto.scryptSync(process.env.MAGIC_LINK_SALT, "salt", 32);

function encrypt(text: string) {
  let iv = crypto.randomBytes(ivLength);
  let cipher = crypto.createCipheriv(algorithm, encryptionKey, iv);
  let encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
  return `${iv.toString("hex")}:${encrypted.toString("hex")}`;
}

function decrypt(text: string) {
  let [ivPart, encryptedPart] = text.split(":");
  if (!ivPart || !encryptedPart) {
    throw new Error("Invalid text.");
  }

  let iv = Buffer.from(ivPart, "hex");
  let encryptedText = Buffer.from(encryptedPart, "hex");
  let decipher = crypto.createDecipheriv(algorithm, encryptionKey, iv);
  let decrypted = Buffer.concat([
    decipher.update(encryptedText),
    decipher.final(),
  ]);
  return decrypted.toString();
}

type MagicLinkPayload = {
  email: string;
  landingPage: string;
  creationDate: string;
};

export function generateMagicLink(email: string, landingPage: string) {
  let payload: MagicLinkPayload = {
    email,
    landingPage,
    creationDate: new Date().toISOString(),
  };
  let stringToEncrypt = JSON.stringify(payload);
  let encryptedString = encrypt(stringToEncrypt);
  let url = new URL(process.env.ORIGIN as string);
  url.pathname = "/auth";
  url.searchParams.set(magicLinkSearchParam, encryptedString);
  return url.toString();
}

function isMagicLinkPayload(obj: any): obj is MagicLinkPayload {
  return (
    typeof obj === "object" &&
    typeof obj.email === "string" &&
    typeof obj.landingPage === "string" &&
    typeof obj.creationDate === "string"
  );
}

export function validateMagicLink(link: string) {
  let decryptedString = decrypt(link);
  let payload = JSON.parse(decryptedString);

  if (!isMagicLinkPayload(payload)) {
    throw new Error("Invalid magic link");
  }

  let linkCreationDate = new Date(payload.creationDate);
  let expirationTime = linkCreationDate.getTime() + linkExpirationTime;

  if (Date.now() > expirationTime) {
    throw new Error("Invalid magic link");
  }

  return payload;
}

/*******************************************************************************
 * Email handled by mailgun
 */
let mailgun = createMailgun({
  apiKey: process.env.MAILGUN_KEY,
  domain: process.env.MAILGUN_DOMAIN,
});

export async function sendMagicLinkEmail(email: string, landingPage: string) {
  let link = generateMagicLink(email, landingPage);

  let html = renderToStaticMarkup(
    <>
      <p style={{ fontWeight: "bold" }}>Magic link demo email.</p>
      <p>(kinda cool we can use JSX to write html email yeah?!)</p>
      <p>
        Just click this <a href={link}>link</a> and you're logged in!
      </p>
    </>
  );

  return mailgun.messages().send({
    from: "Remix Magic Link Demo <you@example.com>",
    to: email,
    subject: "Login to Local Host!",
    html,
  });
}
