#!/usr/bin/env node

"use strict";

// Minimum required to get config
const path = require("path");
const envPaths = require("env-paths");
const cli = require("./cli");
const fs = require("fs");
const exif = require("piexifjs");

const defaultConfig = {};
const defaultConfigFile = path.join(
  envPaths("oasis", { suffix: "" }).config,
  "/default.json"
);
let haveConfig;

try {
  const defaultConfigOverride = fs.readFileSync(defaultConfigFile, "utf8");
  Object.entries(JSON.parse(defaultConfigOverride)).forEach(([key, value]) => {
    defaultConfig[key] = value;
  });
  haveConfig = true;
} catch (e) {
  if (e.code === "ENOENT") {
    haveConfig = false;
  } else {
    console.log(`There was a problem loading ${defaultConfigFile}`);
    throw e;
  }
}

const config = cli(defaultConfig, defaultConfigFile);
if (config.debug) {
  process.env.DEBUG = "oasis,oasis:*";
}

const customStyleFile = path.join(
  envPaths("oasis", { suffix: "" }).config,
  "/custom-style.css"
);
let haveCustomStyle;

try {
  fs.readFileSync(customStyleFile, "utf8");
  haveCustomStyle = true;
} catch (e) {
  if (e.code === "ENOENT") {
    haveCustomStyle = false;
  } else {
    console.log(`There was a problem loading ${customStyleFile}`);
    throw e;
  }
}

const nodeHttp = require("http");
const debug = require("debug")("oasis");

const log = (formatter, ...args) => {
  const isDebugEnabled = debug.enabled;
  debug.enabled = true;
  debug(formatter, ...args);
  debug.enabled = isDebugEnabled;
};

delete config._;
delete config.$0;

const { host } = config;
const { port } = config;
const url = `http://${host}:${port}`;

if (haveConfig) {
  log(`Configuration read defaults from ${defaultConfigFile}`);
} else {
  log(
    `No configuration file found at ${defaultConfigFile}, using built-in default values.`
  );
}

if (!haveCustomStyle) {
  log(
    `No custom style file found at ${customStyleFile}, ignoring this stylesheet.`
  );
}

debug("Current configuration: %O", config);
debug(`You can save the above to ${defaultConfigFile} to make \
these settings the default. See the readme for details.`);

const oasisCheckPath = "/.well-known/oasis";

process.on("uncaughtException", function (err) {
  // This isn't `err.code` because TypeScript doesn't like that.
  if (err["code"] === "EADDRINUSE") {
    nodeHttp.get(url + oasisCheckPath, (res) => {
      let rawData = "";
      res.on("data", (chunk) => {
        rawData += chunk;
      });
      res.on("end", () => {
        log(rawData);
        if (rawData === "oasis") {
          log(`Oasis is already running on host ${host} and port ${port}`);
          if (config.open === true) {
            log("Opening link to existing instance of Oasis");
            open(url);
          } else {
            log(
              "Not opening your browser because opening is disabled by your config"
            );
          }
          process.exit(0);
        } else {
          throw new Error(`Another server is already running at ${url}.
It might be another copy of Oasis or another program on your computer.
You can run Oasis on a different port number with this option:

    oasis --port ${config.port + 1}

Alternatively, you can set the default port in ${defaultConfigFile} with:

    {
      "port": ${config.port + 1}
    }
`);
        }
      });
    });
  } else {
    throw err;
  }
});

// HACK: We must get the CLI config and then delete environment variables.
// This hides arguments from other upstream modules who might parse them.
//
// Unfortunately some modules think that our CLI options are meant for them,
// and since there's no way to disable that behavior (!) we have to hide them
// manually by setting the args property to an empty array.
process.argv = [];

const http = require("./http");

const koaBody = require("koa-body");
const { nav, ul, li, a } = require("hyperaxe");
const open = require("open");
const pull = require("pull-stream");
const requireStyle = require("require-style");
const koaRouter = require("@koa/router");
const ssbMentions = require("ssb-mentions");
const ssbRef = require("ssb-ref");
const isSvg = require("is-svg");
const { themeNames } = require("@fraction/base16-css");
const { isFeed, isMsg, isBlob } = require("ssb-ref");

const ssb = require("./ssb");

const router = new koaRouter();

// Create "cooler"-style interface from SSB connection.
// This handle is passed to the models for their convenience.
const cooler = ssb({ offline: config.offline });

const { about, blob, friend, meta, post, vote } = require("./models")({
  cooler,
  isPublic: config.public,
});

const nameWarmup = about._startNameWarmup();

// enhance the users' input text by expanding @name to [@name](@feedPub.key)
// and slurps up blob uploads and appends a markdown link for it to the text (see handleBlobUpload)
const preparePreview = async function (ctx) {
  let text = String(ctx.request.body.text);

  // find all the @mentions that are not inside a link already
  // stores name:[matches...]
  // TODO: sort by relationship
  const mentions = {};

  // This matches for @string followed by a space or other punctuations like ! , or .
  // The idea here is to match a plain @name but not [@name](...)
  // also: re.exec has state => regex is consumed and thus needs to be re-instantiated for each call
  //
  // Change this link when the regex changes: https://regex101.com/r/j5rzSv/2
  const rex = /(^|\s)(?!\[)@([a-zA-Z0-9-]+)([\s.,!?)~]{1}|$)/g;
  //                                        ^ sentence ^
  //                                         delimiters

  // find @mentions using rex and use about.named() to get the info for them
  let m;
  while ((m = rex.exec(text)) !== null) {
    const name = m[2];
    let matches = about.named(name);
    for (const feed of matches) {
      let found = mentions[name] || [];
      found.push(feed);
      mentions[name] = found;
    }
  }

  // filter the matches depending on the follow relation
  Object.keys(mentions).forEach((name) => {
    let matches = mentions[name];
    // if we find mention matches for a name, and we follow them / they follow us,
    // then use those matches as suggestions
    const meaningfulMatches = matches.filter((m) => {
      return (m.rel.followsMe || m.rel.following) && m.rel.blocking === false;
    });
    if (meaningfulMatches.length > 0) {
      matches = meaningfulMatches;
    }
    mentions[name] = matches;
  });

  // replace the text with a markdown link if we have unambiguous match
  const replacer = (match, name, sign) => {
    let matches = mentions[name];
    if (matches && matches.length === 1) {
      // we found an exact match, don't send it to frontend as a suggestion
      delete mentions[name];
      // format markdown link and put the correct sign back at the end
      return `[@${matches[0].name}](${matches[0].feed})${sign ? sign : ""}`;
    }
    return match;
  };
  text = text.replace(rex, replacer);

  // add blob new blob to the end of the document.
  text += await handleBlobUpload(ctx);

  // author metadata for the preview-post
  const ssb = await cooler.open();
  const authorMeta = {
    id: ssb.id,
    name: await about.name(ssb.id),
    image: await about.image(ssb.id),
  };

  return { authorMeta, text, mentions };
};

// handleBlobUpload ingests an uploaded form file.
// it takes care of maximum blob size (5meg), exif stripping and mime detection.
// finally it returns the correct markdown link for the blob depending on the mime-type.
// it supports plain, image and also audio: and video: as understood by ssbMarkdown.
const handleBlobUpload = async function (ctx) {
  if (!ctx.request.files) return "";

  const ssb = await cooler.open();
  const blobUpload = ctx.request.files.blob;
  if (typeof blobUpload === "undefined") {
    return "";
  }

  let data = await fs.promises.readFile(blobUpload.path);
  if (data.length == 0) {
    return "";
  }

  // 5 MiB check
  const mebibyte = Math.pow(2, 20);
  const maxSize = 5 * mebibyte;
  if (data.length > maxSize) {
    throw new Error("Blob file is too big, maximum size is 5 mebibytes");
  }

  try {
    const removeExif = (fileData) => {
      const exifOrientation = exif.load(fileData);
      const orientation = exifOrientation["0th"][exif.ImageIFD.Orientation];
      const clean = exif.remove(fileData);
      if (orientation !== undefined) {
        // preserve img orientation
        const exifData = { "0th": {} };
        exifData["0th"][exif.ImageIFD.Orientation] = orientation;
        const exifStr = exif.dump(exifData);
        return exif.insert(exifStr, clean);
      } else {
        return clean;
      }
    };

    const dataString = data.toString("binary");
    // implementation borrowed from ssb-blob-files
    // (which operates on a slightly different data structure, sadly)
    // https://github.com/ssbc/ssb-blob-files/blob/master/async/image-process.js
    data = Buffer.from(removeExif(dataString), "binary");
  } catch (e) {
    // blob was likely not a jpeg -- no exif data to remove. proceeding with blob upload
  }

  const addBlob = new Promise((resolve, reject) => {
    pull(
      pull.values([data]),
      ssb.blobs.add((err, hashedBlobRef) => {
        if (err) return reject(err);
        resolve(hashedBlobRef);
      })
    );
  });
  let blob = {
    id: await addBlob,
    name: blobUpload.name,
  };

  // determine encoding to add the correct markdown link
  const FileType = require("file-type");
  try {
    let fileType = await FileType.fromBuffer(data);
    blob.mime = fileType.mime;
  } catch (error) {
    console.warn(error);
    blob.mime = "application/octet-stream";
  }

  // append uploaded blob as markdown to the end of the input text
  if (blob.mime.startsWith("image/")) {
    return `\n![${blob.name}](${blob.id})`;
  } else if (blob.mime.startsWith("audio/")) {
    return `\n![audio:${blob.name}](${blob.id})`;
  } else if (blob.mime.startsWith("video/")) {
    return `\n![video:${blob.name}](${blob.id})`;
  } else {
    return `\n[${blob.name}](${blob.id})`;
  }
};

const resolveCommentComponents = async function (ctx) {
  const { message } = ctx.params;
  const parentId = message;
  const parentMessage = await post.get(parentId);
  const myFeedId = await meta.myFeedId();

  const hasRoot =
    typeof parentMessage.value.content.root === "string" &&
    ssbRef.isMsg(parentMessage.value.content.root);
  const hasFork =
    typeof parentMessage.value.content.fork === "string" &&
    ssbRef.isMsg(parentMessage.value.content.fork);

  const rootMessage = hasRoot
    ? hasFork
      ? parentMessage
      : await post.get(parentMessage.value.content.root)
    : parentMessage;

  const messages = await post.topicComments(rootMessage.key);

  messages.push(rootMessage);
  let contentWarning;
  if (ctx.request.body) {
    const rawContentWarning = String(ctx.request.body.contentWarning).trim();
    contentWarning =
      rawContentWarning.length > 0 ? rawContentWarning : undefined;
  }
  return { messages, myFeedId, parentMessage, contentWarning };
};

const {
  authorView,
  previewCommentView,
  commentView,
  editProfileView,
  indexingView,
  extendedView,
  latestView,
  likesView,
  threadView,
  hashtagView,
  markdownView,
  mentionsView,
  popularView,
  previewView,
  privateView,
  publishCustomView,
  publishView,
  previewSubtopicView,
  subtopicView,
  searchView,
  imageSearchView,
  setLanguage,
  settingsView,
  topicsView,
  summaryView,
  threadsView,
} = require("./views");

let sharp;

try {
  sharp = require("sharp");
} catch (e) {
  // Optional dependency
}

const readmePath = path.join(__dirname, "..", "README.md");
const packagePath = path.join(__dirname, "..", "package.json");

const readme = fs.readFileSync(readmePath, "utf8");
const version = JSON.parse(fs.readFileSync(packagePath, "utf8")).version;

router
  .param("imageSize", (imageSize, ctx, next) => {
    const size = Number(imageSize);
    const isInteger = size % 1 === 0;
    const overMinSize = size > 2;
    const underMaxSize = size <= 256;
    ctx.assert(
      isInteger && overMinSize && underMaxSize,
      400,
      "Invalid image size"
    );
    return next();
  })
  .param("blobId", (blobId, ctx, next) => {
    ctx.assert(ssbRef.isBlob(blobId), 400, "Invalid blob link");
    return next();
  })
  .param("message", (message, ctx, next) => {
    ctx.assert(ssbRef.isMsg(message), 400, "Invalid message link");
    return next();
  })
  .param("feed", (message, ctx, next) => {
    ctx.assert(ssbRef.isFeedId(message), 400, "Invalid feed link");
    return next();
  })
  .get("/", async (ctx) => {
    ctx.redirect("/mentions");
  })
  .get("/robots.txt", (ctx) => {
    ctx.body = "User-agent: *\nDisallow: /";
  })
  .get(oasisCheckPath, (ctx) => {
    ctx.body = "oasis";
  })
  .get("/public/popular/:period", async (ctx) => {
    const { period } = ctx.params;
    const publicPopular = async ({ period }) => {
      const messages = await post.popular({ period });

      const option = (somePeriod) => {
        const lowerPeriod = somePeriod.toLowerCase();
        return li(
          period === lowerPeriod
            ? a({ class: "current", href: `./${lowerPeriod}` }, somePeriod)
            : a({ href: `./${lowerPeriod}` }, somePeriod)
        );
      };

      const prefix = nav(
        ul(option("Day"), option("Week"), option("Month"), option("Year"))
      );

      return popularView({
        messages,
        prefix,
      });
    };
    ctx.body = await publicPopular({ period });
  })
  .get("/public/latest", async (ctx) => {
    const messages = await post.latest();
    ctx.body = await latestView({ messages });
  })
  .get("/public/latest/extended", async (ctx) => {
    const messages = await post.latestExtended();
    ctx.body = await extendedView({ messages });
  })
  .get("/public/latest/topics", async (ctx) => {
    const messages = await post.latestTopics();
    const channels = await post.channels();
    const list = channels.map((c) => {
      return li(a({ href: `/hashtag/${c}` }, `#${c}`));
    });
    const prefix = nav(ul(list));
    ctx.body = await topicsView({ messages, prefix });
  })
  .get("/public/latest/summaries", async (ctx) => {
    const messages = await post.latestSummaries();
    ctx.body = await summaryView({ messages });
  })
  .get("/public/latest/threads", async (ctx) => {
    const messages = await post.latestThreads();
    ctx.body = await threadsView({ messages });
  })
  .get("/author/:feed", async (ctx) => {
    const { feed } = ctx.params;

    const gt = Number(ctx.request.query["gt"] || -1);
    const lt = Number(ctx.request.query["lt"] || -1);

    if (lt > 0 && gt > 0 && gt >= lt)
      throw new Error("Given search range is empty");

    const author = async (feedId) => {
      const description = await about.description(feedId);
      const name = await about.name(feedId);
      const image = await about.image(feedId);
      const messages = await post.fromPublicFeed(feedId, gt, lt);
      const firstPost = await post.firstBy(feedId);
      const lastPost = await post.latestBy(feedId);
      const relationship = await friend.getRelationship(feedId);

      const avatarUrl = `/image/256/${encodeURIComponent(image)}`;

      return authorView({
        feedId,
        messages,
        firstPost,
        lastPost,
        name,
        description,
        avatarUrl,
        relationship,
      });
    };
    ctx.body = await author(feed);
  })
  .get("/search", async (ctx) => {
    let { query } = ctx.query;

    if (isMsg(query)) {
      return ctx.redirect(`/thread/${encodeURIComponent(query)}`);
    }
    if (isFeed(query)) {
      return ctx.redirect(`/author/${encodeURIComponent(query)}`);
    }
    if (isBlob(query)) {
      return ctx.redirect(`/blob/${encodeURIComponent(query)}`);
    }

    if (typeof query === "string") {
      // https://github.com/ssbc/ssb-search/issues/7
      query = query.toLowerCase();
      if (query.length > 1 && query.startsWith("#")) {
        const hashtag = query.slice(1);
        return ctx.redirect(`/hashtag/${encodeURIComponent(hashtag)}`);
      }
    }

    const messages = await post.search({ query });

    ctx.body = await searchView({ messages, query });
  })
  .get("/imageSearch", async (ctx) => {
    const { query } = ctx.query;

    const blobs = query ? await blob.search({ query }) : {};

    ctx.body = await imageSearchView({ blobs, query });
  })
  .get("/inbox", async (ctx) => {
    const inbox = async () => {
      const messages = await post.inbox();

      return privateView({ messages });
    };
    ctx.body = await inbox();
  })
  .get("/hashtag/:hashtag", async (ctx) => {
    const { hashtag } = ctx.params;
    const messages = await post.fromHashtag(hashtag);

    ctx.body = await hashtagView({ hashtag, messages });
  })
  .get("/theme.css", (ctx) => {
    const theme = ctx.cookies.get("theme") || config.theme;

    const packageName = "@fraction/base16-css";
    const filePath = `${packageName}/src/base16-${theme}.css`;
    ctx.type = "text/css";
    ctx.body = requireStyle(filePath);
  })
  .get("/custom-style.css", (ctx) => {
    ctx.type = "text/css";
    ctx.body = requireStyle(customStyleFile);
  })
  .get("/profile", async (ctx) => {
    const myFeedId = await meta.myFeedId();

    const gt = Number(ctx.request.query["gt"] || -1);
    const lt = Number(ctx.request.query["lt"] || -1);

    if (lt > 0 && gt > 0 && gt >= lt)
      throw new Error("Given search range is empty");

    const description = await about.description(myFeedId);
    const name = await about.name(myFeedId);
    const image = await about.image(myFeedId);

    const messages = await post.fromPublicFeed(myFeedId, gt, lt);
    const firstPost = await post.firstBy(myFeedId);
    const lastPost = await post.latestBy(myFeedId);

    const avatarUrl = `/image/256/${encodeURIComponent(image)}`;

    ctx.body = await authorView({
      feedId: myFeedId,
      messages,
      firstPost,
      lastPost,
      name,
      description,
      avatarUrl,
      relationship: { me: true },
    });
  })
  .get("/profile/edit", async (ctx) => {
    const myFeedId = await meta.myFeedId();
    const description = await about.description(myFeedId);
    const name = await about.name(myFeedId);

    ctx.body = await editProfileView({
      name,
      description,
    });
  })
  .post("/profile/edit", koaBody({ multipart: true }), async (ctx) => {
    const name = String(ctx.request.body.name);
    const description = String(ctx.request.body.description);

    const image = await fs.promises.readFile(ctx.request.files.image.path);
    ctx.body = await post.publishProfileEdit({
      name,
      description,
      image,
    });
    ctx.redirect("/profile");
  })
  .get("/publish/custom", async (ctx) => {
    ctx.body = await publishCustomView();
  })
  .get("/json/:message", async (ctx) => {
    if (config.public) {
      throw new Error(
        "Sorry, many actions are unavailable when Oasis is running in public mode. Please run Oasis in the default mode and try again."
      );
    }
    const { message } = ctx.params;
    ctx.type = "application/json";
    const json = async (message) => {
      const json = await meta.get(message);
      return JSON.stringify(json, null, 2);
    };
    ctx.body = await json(message);
  })
  .get("/blob/:blobId", async (ctx) => {
    const { blobId } = ctx.params;
    const buffer = await blob.getResolved({ blobId });
    ctx.body = buffer;

    if (ctx.body.length === 0) {
      ctx.response.status = 404;
    } else {
      ctx.set("Cache-Control", "public,max-age=31536000,immutable");
    }

    // This prevents an auto-download when visiting the URL.
    ctx.attachment(blobId, { type: "inline" });

    // If we don't do this explicitly the browser downloads the SVG and thinks
    // that it's plain XML, so it doesn't render SVG files correctly. Note that
    // this library is **not a full SVG parser**, and may cause false positives
    // in the case of malformed XML like `<svg><div></svg>`.
    if (isSvg(buffer)) {
      ctx.type = "image/svg+xml";
    }
  })
  .get("/image/:imageSize/:blobId", async (ctx) => {
    const { blobId, imageSize } = ctx.params;
    if (sharp) {
      ctx.type = "image/png";
    }

    const fakePixel = Buffer.from(
      "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
      "base64"
    );

    const fakeImage = (imageSize) =>
      sharp
        ? sharp({
            create: {
              width: imageSize,
              height: imageSize,
              channels: 4,
              background: {
                r: 0,
                g: 0,
                b: 0,
                alpha: 0.5,
              },
            },
          })
            .png()
            .toBuffer()
        : new Promise((resolve) => resolve(fakePixel));

    const image = async ({ blobId, imageSize }) => {
      const bufferSource = await blob.get({ blobId });
      const fakeId = "&0000000000000000000000000000000000000000000=.sha256";

      debug("got buffer source");
      return new Promise((resolve) => {
        if (blobId === fakeId) {
          debug("fake image");
          fakeImage(imageSize).then((result) => resolve(result));
        } else {
          debug("not fake image");
          pull(
            bufferSource,
            pull.collect(async (err, bufferArray) => {
              if (err) {
                await blob.want({ blobId });
                const result = fakeImage(imageSize);
                debug({ result });
                resolve(result);
              } else {
                const buffer = Buffer.concat(bufferArray);

                if (sharp) {
                  sharp(buffer)
                    .resize(imageSize, imageSize)
                    .png()
                    .toBuffer()
                    .then((data) => {
                      resolve(data);
                    });
                } else {
                  resolve(buffer);
                }
              }
            })
          );
        }
      });
    };
    ctx.body = await image({ blobId, imageSize: Number(imageSize) });
  })
  .get("/settings", async (ctx) => {
    const theme = ctx.cookies.get("theme") || config.theme;
    const getMeta = async ({ theme }) => {
      const peers = await meta.connectedPeers();
      const peersWithNames = await Promise.all(
        peers.map(async ([key, value]) => {
          value.name = await about.name(value.key);
          return [key, value];
        })
      );

      return settingsView({
        peers: peersWithNames,
        theme,
        themeNames,
        version: version.toString(),
      });
    };
    ctx.body = await getMeta({ theme });
  })
  .get("/likes/:feed", async (ctx) => {
    const { feed } = ctx.params;
    const likes = async ({ feed }) => {
      const pendingMessages = post.likes({ feed });
      const pendingName = about.name(feed);
      return likesView({
        messages: await pendingMessages,
        feed,
        name: await pendingName,
      });
    };
    ctx.body = await likes({ feed });
  })
  .get("/settings/readme", async (ctx) => {
    const status = async (text) => {
      return markdownView({ text });
    };
    ctx.body = await status(readme);
  })
  .get("/mentions", async (ctx) => {
    const mentions = async () => {
      const messages = await post.mentionsMe();

      return mentionsView({ messages });
    };
    ctx.body = await mentions();
  })
  .get("/thread/:message", async (ctx) => {
    const { message } = ctx.params;
    const thread = async (message) => {
      const messages = await post.fromThread(message);
      debug("got %i messages", messages.length);

      return threadView({ messages });
    };

    ctx.body = await thread(message);
  })
  .get("/subtopic/:message", async (ctx) => {
    const { message } = ctx.params;
    const rootMessage = await post.get(message);
    const myFeedId = await meta.myFeedId();

    debug("%O", rootMessage);
    const messages = [rootMessage];

    ctx.body = await subtopicView({ messages, myFeedId });
  })
  .get("/publish", async (ctx) => {
    ctx.body = await publishView();
  })
  .get("/comment/:message", async (ctx) => {
    const {
      messages,
      myFeedId,
      parentMessage,
    } = await resolveCommentComponents(ctx);
    ctx.body = await commentView({ messages, myFeedId, parentMessage });
  })
  .post(
    "/subtopic/preview/:message",
    koaBody({ multipart: true }),
    async (ctx) => {
      const { message } = ctx.params;
      const rootMessage = await post.get(message);
      const myFeedId = await meta.myFeedId();

      const rawContentWarning = String(ctx.request.body.contentWarning).trim();
      const contentWarning =
        rawContentWarning.length > 0 ? rawContentWarning : undefined;

      const messages = [rootMessage];

      const previewData = await preparePreview(ctx);

      ctx.body = await previewSubtopicView({
        messages,
        myFeedId,
        previewData,
        contentWarning,
      });
    }
  )
  .post("/subtopic/:message", koaBody(), async (ctx) => {
    const { message } = ctx.params;
    const text = String(ctx.request.body.text);

    const rawContentWarning = String(ctx.request.body.contentWarning).trim();
    const contentWarning =
      rawContentWarning.length > 0 ? rawContentWarning : undefined;

    const publishSubtopic = async ({ message, text }) => {
      // TODO: rename `message` to `parent` or `ancestor` or similar
      const mentions = ssbMentions(text) || undefined;

      const parent = await post.get(message);
      return post.subtopic({
        parent,
        message: { text, mentions, contentWarning },
      });
    };
    ctx.body = await publishSubtopic({ message, text });
    ctx.redirect(`/thread/${encodeURIComponent(message)}`);
  })
  .post(
    "/comment/preview/:message",
    koaBody({ multipart: true }),
    async (ctx) => {
      const {
        messages,
        contentWarning,
        myFeedId,
        parentMessage,
      } = await resolveCommentComponents(ctx);

      const previewData = await preparePreview(ctx);

      ctx.body = await previewCommentView({
        messages,
        myFeedId,
        contentWarning,
        parentMessage,
        previewData,
      });
    }
  )
  .post("/comment/:message", koaBody(), async (ctx) => {
    const { message } = ctx.params;
    const text = String(ctx.request.body.text);

    const rawContentWarning = String(ctx.request.body.contentWarning);
    const contentWarning =
      rawContentWarning.length > 0 ? rawContentWarning : undefined;

    const publishComment = async ({ message, text }) => {
      // TODO: rename `message` to `parent` or `ancestor` or similar
      const mentions = ssbMentions(text) || undefined;
      const parent = await meta.get(message);

      return post.comment({
        parent,
        message: { text, mentions, contentWarning },
      });
    };
    ctx.body = await publishComment({ message, text });
    ctx.redirect(`/thread/${encodeURIComponent(message)}`);
  })
  .post("/publish/preview", koaBody({ multipart: true }), async (ctx) => {
    const rawContentWarning = String(ctx.request.body.contentWarning).trim();

    // Only submit content warning if it's a string with non-zero length.
    const contentWarning =
      rawContentWarning.length > 0 ? rawContentWarning : undefined;

    const previewData = await preparePreview(ctx);
    ctx.body = await previewView({ previewData, contentWarning });
  })
  .post("/publish", koaBody(), async (ctx) => {
    const text = String(ctx.request.body.text);
    const rawContentWarning = String(ctx.request.body.contentWarning);

    // Only submit content warning if it's a string with non-zero length.
    const contentWarning =
      rawContentWarning.length > 0 ? rawContentWarning : undefined;

    const publish = async ({ text, contentWarning }) => {
      const mentions = ssbMentions(text) || undefined;

      return post.root({
        text,
        mentions,
        contentWarning,
      });
    };
    ctx.body = await publish({ text, contentWarning });
    ctx.redirect("/public/latest");
  })
  .post("/publish/custom", koaBody(), async (ctx) => {
    const text = String(ctx.request.body.text);
    const obj = JSON.parse(text);
    ctx.body = await post.publishCustom(obj);
    ctx.redirect(`/public/latest`);
  })
  .post("/follow/:feed", koaBody(), async (ctx) => {
    const { feed } = ctx.params;
    const referer = new URL(ctx.request.header.referer);
    ctx.body = await friend.follow(feed);
    ctx.redirect(referer.href);
  })
  .post("/unfollow/:feed", koaBody(), async (ctx) => {
    const { feed } = ctx.params;
    const referer = new URL(ctx.request.header.referer);
    ctx.body = await friend.unfollow(feed);
    ctx.redirect(referer.href);
  })
  .post("/block/:feed", koaBody(), async (ctx) => {
    const { feed } = ctx.params;
    const referer = new URL(ctx.request.header.referer);
    ctx.body = await friend.block(feed);
    ctx.redirect(referer.href);
  })
  .post("/unblock/:feed", koaBody(), async (ctx) => {
    const { feed } = ctx.params;
    const referer = new URL(ctx.request.header.referer);
    ctx.body = await friend.unblock(feed);
    ctx.redirect(referer.href);
  })
  .post("/like/:message", koaBody(), async (ctx) => {
    const { message } = ctx.params;
    // TODO: convert all so `message` is full message and `messageKey` is key
    const messageKey = message;

    const voteValue = Number(ctx.request.body.voteValue);

    const encoded = {
      message: encodeURIComponent(message),
    };

    const referer = new URL(ctx.request.header.referer);
    referer.hash = `centered-footer-${encoded.message}`;

    const like = async ({ messageKey, voteValue }) => {
      const value = Number(voteValue);
      const message = await post.get(messageKey);

      const isPrivate = message.value.meta.private === true;
      const messageRecipients = isPrivate ? message.value.content.recps : [];

      const normalized = messageRecipients.map((recipient) => {
        if (typeof recipient === "string") {
          return recipient;
        }

        if (typeof recipient.link === "string") {
          return recipient.link;
        }

        return null;
      });

      const recipients = normalized.length > 0 ? normalized : undefined;

      return vote.publish({ messageKey, value, recps: recipients });
    };
    ctx.body = await like({ messageKey, voteValue });
    ctx.redirect(referer.href);
  })
  .post("/theme.css", koaBody(), async (ctx) => {
    const theme = String(ctx.request.body.theme);
    ctx.cookies.set("theme", theme);
    const referer = new URL(ctx.request.header.referer);
    ctx.redirect(referer.href);
  })
  .post("/language", koaBody(), async (ctx) => {
    const language = String(ctx.request.body.language);
    ctx.cookies.set("language", language);
    const referer = new URL(ctx.request.header.referer);
    ctx.redirect(referer.href);
  })
  .post("/settings/conn/start", koaBody(), async (ctx) => {
    await meta.connStart();
    ctx.redirect("/settings");
  })
  .post("/settings/conn/stop", koaBody(), async (ctx) => {
    await meta.connStop();
    ctx.redirect("/settings");
  })
  .post("/settings/conn/sync", koaBody(), async (ctx) => {
    await meta.sync();
    ctx.redirect("/settings");
  })
  .post("/settings/conn/restart", koaBody(), async (ctx) => {
    await meta.connRestart();
    ctx.redirect("/settings");
  })
  .post("/settings/invite/accept", koaBody(), async (ctx) => {
    const invite = String(ctx.request.body.invite);
    await meta.acceptInvite(invite);
    ctx.redirect("/settings");
  })
  .post("/settings/rebuild", async (ctx) => {
    // Do not wait for rebuild to finish.
    meta.rebuild();
    ctx.redirect("/settings");
  });

const routes = router.routes();

const middleware = [
  async (ctx, next) => {
    if (config.public && ctx.method !== "GET") {
      throw new Error(
        "Sorry, many actions are unavailable when Oasis is running in public mode. Please run Oasis in the default mode and try again."
      );
    }
    await next();
  },
  async (ctx, next) => {
    const selectedLanguage = ctx.cookies.get("language") || "en";
    setLanguage(selectedLanguage);
    await next();
  },
  async (ctx, next) => {
    const ssb = await cooler.open();

    const status = await ssb.status();
    const values = Object.values(status.sync.plugins);
    const totalCurrent = Object.values(status.sync.plugins).reduce(
      (acc, cur) => acc + cur,
      0
    );
    const totalTarget = status.sync.since * values.length;

    const left = totalTarget - totalCurrent;

    // Weird trick to get percentage with 1 decimal place (e.g. 78.9)
    const percent = Math.floor((totalCurrent / totalTarget) * 1000) / 10;
    const mebibyte = 1024 * 1024;

    if (left > mebibyte) {
      ctx.response.body = indexingView({ percent });
    } else {
      await next();
    }
  },
  routes,
];

const { allowHost } = config;
const app = http({ host, port, middleware, allowHost });

// HACK: This lets us close the database once tests finish.
// If we close the database after each test it throws lots of really fun "parent
// stream closing" errors everywhere and breaks the tests. :/
app._close = () => {
  nameWarmup.close();
  cooler.close();
};

module.exports = app;

log(`Listening on ${url}`);

if (config.open === true) {
  open(url);
}
