/**
 * /api/assetImage renders a canvas movie to PNG! To do this, we use a headless
 * Chromium browser, which renders a special page in the webapp and screenshots
 * the displayed canvas.
 *
 * This is, of course, a relatively heavyweight operation: it's always gonna be
 * a bit slow, and consume significant RAM. So, caching is going to be
 * important, so that we're not calling this all the time and overloading the
 * endpoint!
 *
 * Parameters:
 *   - libraryUrl: A https://images.neopets.com/ URL to a JS movie library
 *   - size: 600, 300, or 150. Determines the output image size.
 */
const beeline = require("honeycomb-beeline")({
  writeKey: process.env["HONEYCOMB_WRITE_KEY"],
  dataset:
    process.env["NODE_ENV"] === "production"
      ? "Dress to Impress (2020)"
      : "Dress to Impress (2020, dev)",
  serviceName: "impress-2020-gql-server",
});

const puppeteer = require("puppeteer");
const genericPool = require("generic-pool");

console.info(`Creating new browser instance`);
const browserPromise = puppeteer.launch({ headless: true });

// We maintain a small pool of browser pages, to manage memory usage. If all
// the pages are already in use, a request will wait for one of them to become
// available.
//
// NOTE: 4 pages is about where our 1-cpu prod environment maxes out. We might
//       want to upgrade to the 2-cpu box as we add more pressure though, and
//       then maybe we can afford more pages in the pool?

const PAGE_POOL = genericPool.createPool(
  {
    create: async () => {
      console.debug(`Creating a browser page`);
      const browser = await browserPromise;
      return await browser.newPage();
    },
    destroy: (page) => {
      console.debug(`Closing a browser page`);
      page.close();
    },
    validate: (page) => page.browser().isConnected(),
  },
  { min: 4, max: 4, testOnBorrow: true, acquireTimeoutMillis: 15000 }
);
PAGE_POOL.on("factoryCreateError", (error) => console.error(error));
PAGE_POOL.on("factoryDestroyError", (error) => console.error(error));

async function handle(req, res) {
  const { libraryUrl, size } = req.query;
  if (!libraryUrl) {
    return reject(res, "libraryUrl is required");
  }

  if (!isNeopetsUrl(libraryUrl)) {
    return reject(
      res,
      `libraryUrl must be an HTTPS Neopets URL, but was: ${libraryUrl}`
    );
  }

  if (size !== "600" && size !== "300" && size !== "150") {
    return reject(res, `size must be 600, 300, or 150, but was: ${size}`);
  }

  let imageBuffer;
  try {
    imageBuffer = await loadAndScreenshotImage(libraryUrl, size);
  } catch (e) {
    console.error(e);
    if (e.name === "TimeoutError") {
      return reject(res, `Server under heavy load: ${e.message}`, 503);
    }
    return reject(res, `Could not load image: ${e.message}`, 500);
  }

  // TODO: Compress the image?

  // Send a long-term cache header, to avoid running this any more than we have
  // to! If we make a big change, we'll flush the cache or add a version param.
  res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
  res.setHeader("Content-Type", "image/png");
  return res.send(imageBuffer);
}

async function loadAndScreenshotImage(libraryUrl, size) {
  // To render the image, we load the /internal/assetImage page in the web app,
  // a simple page specifically designed for this API endpoint!
  //
  // NOTE: If we deploy to a host where localhost:3000 won't work, make this
  //       configurable with an env var, e.g. process.env.LOCAL_APP_HOST
  const assetImagePageUrl = new URL(
    "http://localhost:3000/internal/assetImage"
  );
  assetImagePageUrl.search = new URLSearchParams({
    libraryUrl,
    size,
  }).toString();

  console.debug("Getting browser page");
  const page = await PAGE_POOL.acquire();

  try {
    console.debug("Page ready, navigating to: " + assetImagePageUrl.toString());
    await page.goto(assetImagePageUrl.toString());
    console.debug("Page loaded, awaiting image");

    // Start looking for the loaded canvas, *and* for an error message.
    // When either one displays, we proceed, either by returning the image if
    // present, or raising the error if present.
    const imageBufferPromise = screenshotImageFromPage(page);
    const errorMessagePromise = readErrorMessageFromPage(page);
    let firstResultFromPage;
    try {
      firstResultFromPage = await Promise.any([
        imageBufferPromise.then((imageBuffer) => ({ imageBuffer })),
        errorMessagePromise.then((errorMessage) => ({ errorMessage })),
      ]);
    } catch (error) {
      if (error.errors) {
        // If both promises failed, show all error messages.
        throw new Error(error.errors.map((e) => e.message).join(", "));
      } else {
        throw error;
      }
    }

    if (firstResultFromPage.errorMessage) {
      throw new Error(firstResultFromPage.errorMessage);
    } else if (firstResultFromPage.imageBuffer) {
      return firstResultFromPage.imageBuffer;
    } else {
      throw new Error(
        `Assertion error: Promise.any did not return an errorMessage or an imageBuffer: ` +
          `${JSON.stringify(Object.keys(firstResultFromPage))}`
      );
    }
  } finally {
    // To avoid memory leaks, we destroy the page when we're done with it.
    // The pool will replace it with a fresh one!
    PAGE_POOL.destroy(page);
  }
}

async function screenshotImageFromPage(page) {
  await page.waitForSelector("#asset-image-canvas[data-is-loaded=true]", {
    timeout: 10000,
  });
  const canvas = await page.$("#asset-image-canvas[data-is-loaded=true]");
  console.debug("Image loaded, taking screenshot");

  const imageBuffer = await canvas.screenshot({
    omitBackground: true,
  });
  console.debug(`Screenshot captured, size: ${imageBuffer.length}`);

  return imageBuffer;
}

async function readErrorMessageFromPage(page) {
  await page.waitForSelector("#asset-image-error-message", {
    timeout: 10000,
  });
  const errorMessageContainer = await page.$("#asset-image-error-message");
  const errorMessage = await errorMessageContainer.innerText();
  return errorMessage;
}

function isNeopetsUrl(urlString) {
  let url;
  try {
    url = new URL(urlString);
  } catch (e) {
    return false;
  }

  return url.origin === "https://images.neopets.com";
}

function reject(res, message, status = 400) {
  res.setHeader("Content-Type", "text/plain; charset=utf8");
  return res.status(status).send(message);
}

// Polyfill Promise.any for older Node: https://github.com/ungap/promise-any
Promise.any =
  Promise.any ||
  function ($) {
    return new Promise(function (D, E, A, L) {
      A = [];
      L = $.map(function ($, i) {
        return Promise.resolve($).then(D, function (O) {
          return ((A[i] = O), --L) || E({ errors: A });
        });
      }).length;
    });
  };

async function handleWithBeeline(req, res) {
  beeline.withTrace(
    { name: "api/assetImage", operation_name: "api/assetImage" },
    () => handle(req, res)
  );
}

export default handleWithBeeline;