import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { defineConfig, type Plugin } from "vite";

/**
 * The office's copy of the page, emitted from the city's.
 *
 * `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind
 * two names, and the app works out which door it is at runtime by reading its
 * own hostname. A crawler cannot: it reads the HTML and nothing else. So a
 * single `index.html` means both doors unfurl with the same title, the same
 * sentence and the same picture — and the office door's whole reason to exist is
 * that it is a different place.
 *
 * The obvious fix, a second hand-written `office.html`, is the one the Caddy
 * config already argues against for the static root: "sharing the root rather
 * than copying it means a deploy cannot leave the two doors on different
 * builds". Two 900-line files each carrying the whole inline stylesheet would
 * drift on the first CSS change, and nothing would notice, because the drift is
 * invisible until somebody opens the other door.
 *
 * So there is one source and the build emits both shells. Everything outside the
 * `ogc:` markers in `index.html` is copied byte for byte — the stylesheet, the
 * DOM, the script tag Vite has already rewritten to the hashed bundle — and only
 * the block between them is replaced. A change to the interface reaches both
 * doors by construction, and the only thing that can differ is the thing that is
 * supposed to.
 *
 * It runs in `writeBundle` rather than `transformIndexHtml` because it needs the
 * *finished* document, after Vite has substituted the asset URLs. Transforming
 * earlier would emit a page pointing at unhashed source paths.
 */
function twoDoors(): Plugin {
  const MARKER_END = "<!-- ogc:end -->";

  const OFFICE = `
    <title>Spaces — a studio you can walk around</title>
    <meta
      name="description"
      content="Three buildings you can walk around: a live/work studio above San Francisco, a courtyard block in the LA Arts District, and a hangar at Alameda Point. Real sun, real hours, the lights on at night, the microphone on the desk and the speaker on the machine. Rendered in the browser from a data file anybody can copy. Apache-2.0, self-hostable."
    />
    <link rel="canonical" href="https://office.lumbridgecorp.com/" />

    <meta property="og:type" content="website" />
    <meta property="og:site_name" content="Lumbridge Spaces" />
    <meta property="og:title" content="Spaces — a studio you can walk around" />
    <meta
      property="og:description"
      content="The rooms, the desks and who is at them — floor plans rendered from a data file anybody can copy, under the sun of the day you are reading this."
    />
    <meta property="og:url" content="https://office.lumbridgecorp.com/" />
    <meta property="og:image" content="https://office.lumbridgecorp.com/og-office.png" />
    <meta property="og:image:type" content="image/png" />
    <meta property="og:image:width" content="1200" />
    <meta property="og:image:height" content="630" />
    <!--
      Describes the card as it is actually composed, which is not the same thing
      as describing the app. FURNITURE.BARE takes the plan view off the share
      card — the card supplies its own typography and the app's panels shrunk to
      1200x630 are unreadable furniture — so an alt that promises "the plan drawn
      beside it" is describing a screenshot nobody is looking at. Re-read it
      against public/og-office.png whenever capture.mjs changes the framing.
    -->
    <meta
      property="og:image:alt"
      content="A studio interior rendered as an open-topped model and seen from above: two desks of monitors, chairs, shelving, planting, a kitchen counter and floor-to-ceiling glazing, with a humanoid standing on the floor beside the bench."
    />

    <meta name="twitter:card" content="summary_large_image" />
    <meta name="twitter:title" content="Spaces — an office you can walk around" />
    <meta
      name="twitter:description"
      content="The rooms, the desks and who is at them — floor plans rendered from a data file anybody can copy, under the sun of the day you are reading this."
    />
    <meta name="twitter:image" content="https://office.lumbridgecorp.com/og-office.png" />`;

  return {
    name: "lumbridge:two-doors",
    apply: "build",
    async writeBundle(options) {
      const dir = options.dir ?? "dist";
      const html = await readFile(join(dir, "index.html"), "utf8");

      const open = html.indexOf("ogc:start");
      const end = html.indexOf(MARKER_END);
      // Loudly, rather than by silently shipping two identical shells. A card
      // that is quietly the wrong one is the failure this plugin exists to
      // prevent, so losing the markers must not be a no-op.
      if (open === -1 || end === -1) {
        this.error("index.html has no ogc:start/ogc:end markers — cannot emit office.html");
        return;
      }
      // The opening marker sits inside a larger comment, so cut from the start
      // of that comment: otherwise the explanation ships on both doors while
      // being true of only one.
      const from = html.lastIndexOf("<!--", open);
      const office =
        html.slice(0, from) +
        "<!-- The office door. Generated from index.html by `twoDoors` in vite.config.ts. -->" +
        OFFICE +
        html.slice(end + MARKER_END.length);

      await writeFile(join(dir, "office.html"), office, "utf8");
    },
  };
}

/**
 * Cut `satellite.js`'s WASM runtime out of the bundle.
 *
 * `satellite.js`'s entry point ends with `export * from './wasm/index.js'`, and
 * that subtree is an Emscripten build: a megabyte of generated glue that reaches
 * for `node:module` and `node:worker_threads`, wraps itself in a top-level
 * `await`, and cannot be tree-shaken away because a star re-export of a module
 * with side effects is not something a bundler may drop on its own.
 *
 * Measured, before this existed: **308 kB** of minified WASM loader shipped to
 * every visitor, in a chunk nothing ever called. `engine/satellites.ts` uses the
 * pure-JS `propagate` and says in its own header why it does not want the
 * `BulkPropagator` — a binary loaded at runtime plus a fallback path for when
 * that fails, to buy back two milliseconds a frame that are already budgeted.
 * Paying 308 kB to *not* use it was the worst of both.
 *
 * The stub is empty because nothing imports a name from it. If a future version
 * of this repo does want the bulk propagator, the fix is to delete this plugin
 * and pay the kilobytes deliberately — not to widen the stub.
 *
 * Matching is on the **importer** as well as the specifier, so this cannot
 * silently swallow some other package's `./wasm/index.js`.
 */
function noWasmPropagator(): Plugin {
  const STUB = "\0lumbridge:satellite-wasm-stub";
  return {
    name: "lumbridge:no-wasm-propagator",
    enforce: "pre",
    resolveId(source, importer) {
      if (source !== "./wasm/index.js") return null;
      if (importer === undefined || !importer.includes("satellite.js")) return null;
      return STUB;
    },
    load(id) {
      return id === STUB ? "export {};" : null;
    },
  };
}

export default defineConfig({
  // Mounted under tera.lumbridgecorp.com in production; the trailing
  // slash matters, since every asset URL is resolved against it.
  base: process.env.TERA_BASE ?? "/",
  plugins: [noWasmPropagator(), twoDoors()],
  /**
   * The heightfield worker is constructed as `{ type: "module" }` in
   * `world.ts`, and this is the build setting that agrees with it.
   *
   * Vite's default worker format is `iife`, which was survivable while nothing
   * in the graph needed anything an IIFE cannot express — and stopped being
   * survivable the moment it did. `satellite.js` reaches a WASM runtime with a
   * top-level `await` in it, and rollup's answer to a top-level await in an
   * `iife` chunk is to fail the whole build, with an error naming a file no
   * worker imports.
   *
   * So this is agreement rather than a workaround: the module the browser is
   * told to load as an ES module is now built as one.
   */
  worker: { format: "es" },
  build: {
    outDir: "dist",
    target: "es2022",
    // Two entries. `login.html` used to sit in `public/`, which Vite copies
    // verbatim — so `import.meta.env` was never substituted there and the page
    // could not be told which identity provider to sign in against. It is a real
    // entry now; the built URL (`/login.html`) is unchanged.
    //
    // `office.html` is deliberately not a third entry: it is the same document
    // as `index.html` with a different head, so making it one would give it its
    // own copy of the bundle graph. It is emitted after the build instead.
    rollupOptions: {
      input: { index: "index.html", login: "login.html" },
      output: {
        /**
         * three.js and the SGP4 propagator get a chunk of their own.
         *
         * Not to make the download smaller — it is the same bytes either way —
         * but to stop them being *re-downloaded*. They were inside the entry
         * chunk, so every deploy that changed a line of app code invalidated
         * three quarters of a megabyte of dependency that had not changed since
         * the last release. Split out, a returning visitor pays for the app and
         * keeps the vendor chunk it already has.
         *
         * They are one chunk rather than two because they are always wanted
         * together: `engine/satellites.ts` imports both, and it is reached from
         * the entry on every board.
         *
         * Measured: the entry chunk goes from 758 kB to 208 kB and the vendor
         * chunk is 550 kB. Rollup's 500 kB warning therefore still fires — and
         * it should. It now points at three.js, where it is a true statement
         * about a dependency nobody here can shrink, instead of at our own code,
         * where it was pointing at three.js all along and reading as if it were
         * about us. Raising `chunkSizeWarningLimit` would have hidden both.
         */
        manualChunks: {
          vendor: ["three", "satellite.js"],
        },
      },
    },
  },
});
