// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.

import { createWriteStream } from "node:fs";
import { type FileHandle, open } from "node:fs/promises";
import path, { resolve } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import zlib from "node:zlib";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import browserslistToEsbuild from "browserslist-to-esbuild";
import { globSync } from "tinyglobby";
import type { Manifest, PluginOption } from "vite";
import codegen from "vite-plugin-graphql-codegen";
import { defineConfig } from "vitest/config";

function i18nHotReload(): PluginOption {
  return {
    name: "i18n-hot-reload",
    handleHotUpdate({ file, server }): void {
      if (file.includes("locales") && file.endsWith(".json")) {
        console.log("Locale file updated");
        server.hot.send({
          type: "custom",
          event: "locales-update",
        });
      }
    },
  };
}

// Pre-compress the assets, so that the server can serve them directly
function compression(): PluginOption {
  return {
    name: "asset-compression",
    apply: "build",
    enforce: "post",

    writeBundle: {
      // We need to run after Vite's plugins, as it will do some final touches
      // to the files in this phase
      order: "post",
      async handler({ dir }, bundle) {
        const promises = Object.entries(bundle).flatMap(
          ([fileName, assetOrChunk]) => {
            const source =
              assetOrChunk.type === "asset"
                ? assetOrChunk.source
                : assetOrChunk.code;

            // Don't compress empty files, only compress CSS, JS and JSON files
            if (
              !source ||
              !(
                fileName.endsWith(".js") ||
                fileName.endsWith(".css") ||
                fileName.endsWith(".json")
              )
            ) {
              return [];
            }

            const uncompressed = Buffer.from(source);

            // We pre-compress assets with brotli as it offers the best
            // compression ratios compared to even zstd, and gzip as a fallback
            return [
              { compressor: zlib.createGzip(), ext: "gz" },
              {
                compressor: zlib.createBrotliCompress({
                  params: {
                    [zlib.constants.BROTLI_PARAM_MODE]:
                      zlib.constants.BROTLI_MODE_TEXT,
                    // 10 yields better results and is quicker than 11
                    [zlib.constants.BROTLI_PARAM_QUALITY]: 10,
                    [zlib.constants.BROTLI_PARAM_SIZE_HINT]:
                      uncompressed.length,
                  },
                }),
                ext: "br",
              },
            ].map(async ({ compressor, ext }) => {
              const output = path.join(dir, `${fileName}.${ext}`);
              const readStream = Readable.from(uncompressed);
              const writeStream = createWriteStream(output);

              await pipeline(readStream, compressor, writeStream);
            });
          },
        );

        await Promise.all(promises);
      },
    },
  };
}

declare module "vite" {
  interface ManifestChunk {
    integrity: string;
  }
}

// Custom plugin to make sure that each asset has an entry in the manifest
// This is needed so that the preloading & asset integrity generation works
// It also calculates integrity hashes for the assets
function augmentManifest(): PluginOption {
  return {
    name: "augment-manifest",
    apply: "build",
    enforce: "post",

    async writeBundle({ dir }, bundle): Promise<void> {
      const hashes: Record<string, Promise<string>> = {};
      for (const [fileName, assetOrChunk] of Object.entries(bundle)) {
        // Start calculating hash of the asset. We can let that run in the
        // background
        const source =
          assetOrChunk.type === "asset"
            ? assetOrChunk.source
            : assetOrChunk.code;

        hashes[fileName] = (async (): Promise<string> => {
          const digest = await crypto.subtle.digest(
            "SHA-384",
            Buffer.from(source),
          );
          return `sha384-${Buffer.from(digest).toString("base64")}`;
        })();
      }

      const manifestPath = resolve(dir, "manifest.json");

      let manifestHandle: FileHandle;
      try {
        manifestHandle = await open(manifestPath, "r+");
      } catch (error) {
        // Manifest does not exist, nothing to do but still warn about
        this.warn(`Failed to open manifest at ${manifestPath}: ${error}`);
        return;
      }
      const rawManifest = await manifestHandle.readFile("utf-8");
      const manifest = JSON.parse(rawManifest) as Manifest;

      const existing: Set<string> = new Set();
      const needs: Set<string> = new Set();

      for (const chunk of Object.values(manifest)) {
        existing.add(chunk.file);
        chunk.integrity = await hashes[chunk.file];
        for (const css of chunk.css ?? []) needs.add(css);
        for (const sub of chunk.assets ?? []) needs.add(sub);
      }

      const missing = Array.from(needs).filter((a) => !existing.has(a));

      for (const asset of missing) {
        manifest[asset] = {
          file: asset,
          integrity: await hashes[asset],
        };
      }

      // Overwrite the manifest with the augmented entries
      // XXX: you'd think that doing `manifestHandle.writeFile` would work, as
      // the docs says that it 'overwrites the file if it exists'. Turns out, it
      // reuses the previous position from `readFile`, so that would append on
      // the existing, so we have to use `write` with an explicit position.
      // Truncating the file just in case the output is smaller than before.
      await manifestHandle.truncate(0);
      await manifestHandle.write(JSON.stringify(manifest, null, 2), 0, "utf-8");
      await manifestHandle.close();
    },
  };
}

export default defineConfig((env) => ({
  base: "./",

  css: {
    modules: {
      localsConvention: "camelCaseOnly",
    },
  },

  define: {
    "import.meta.vitest": "undefined",
    "process.env.NODE_ENV": JSON.stringify(env.mode),
  },

  build: {
    manifest: "manifest.json",
    assetsDir: "",
    sourcemap: true,
    target: browserslistToEsbuild(),
    cssCodeSplit: true,
    reportCompressedSize: false,

    rollupOptions: {
      // This uses all the files in the src/entrypoints directory as inputs
      input: globSync(resolve(__dirname, "src/entrypoints/**/*.{css,ts,tsx}")),
    },
  },

  plugins: [
    codegen(),

    tanstackRouter({
      target: "react",
      autoCodeSplitting: true,
      verboseFileRoutes: false,
    }),

    react(),

    augmentManifest(),

    compression(),

    i18nHotReload(),
  ],

  server: {
    base: "/account/",
    proxy: {
      // Routes mostly extracted from crates/router/src/endpoints.rs
      "^/(|graphql.*|assets.*|\\.well-known.*|oauth2.*|login.*|logout.*|register.*|reauth.*|add-email.*|verify-email.*|change-password.*|consent.*|_matrix.*|complete-compat-sso.*|link.*|device.*|upstream.*|recover.*)$":
        "http://127.0.0.1:8080",
    },
  },

  test: {
    globalSetup: "./vitest.global-setup.ts",
    setupFiles: "./vitest.setup.ts",
    coverage: {
      provider: "v8",
      src: ["./src/"],
      exclude: ["**/gql/**", "**/*.d.ts", "**/*.stories.*"],
      all: true,
      reporter: ["text", "html", "lcov"],
    },
  },
}));
