/**
 * Build-time precompression for hashed static assets.
 *
 * Generates .br (brotli q5), .gz (gzip l8), and .zst (zstd l8) files
 * alongside compressible assets in dist/client/_next/static/. Served
 * directly by the production server — no per-request compression needed
 * for immutable build output.
 *
 * Only targets the hashed-asset directory (immutable) — public directory
 * files use on-the-fly compression since they may change between deploys.
 */
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import zlib from "node:zlib";
import { promisify } from "node:util";
import { ASSET_PREFIX_URL_DIR } from "../utils/asset-prefix.js";

const brotliCompress = promisify(zlib.brotliCompress);
const gzip = promisify(zlib.gzip);
const zstdCompress = typeof zlib.zstdCompress === "function" ? promisify(zlib.zstdCompress) : null;

/** File extensions worth compressing (text-based, not already compressed). */
const COMPRESSIBLE_EXTENSIONS = new Set([
  ".js",
  ".mjs",
  ".css",
  ".html",
  ".json",
  ".xml",
  ".svg",
  ".txt",
  ".map",
  ".wasm",
]);

/** Below this size, compression overhead exceeds savings. */
const MIN_SIZE = 1024;

/**
 * Past ~8 parallel files, mixed-size asset sets spend more time queueing zlib
 * work than making forward progress. Keep the batch size bounded even on
 * higher-core machines.
 */
const CONCURRENCY = Math.min(os.availableParallelism(), 8);

type PrecompressResult = {
  filesCompressed: number;
  totalOriginalBytes: number;
  /** Sum of brotli-compressed sizes (used for compression ratio reporting). */
  totalBrotliBytes: number;
};

/**
 * Walk a directory recursively, yielding relative paths for regular files.
 */
async function* walkFiles(dir: string, base: string = dir): AsyncGenerator<string> {
  let entries;
  try {
    entries = await fsp.readdir(dir, { withFileTypes: true });
  } catch {
    return; // directory doesn't exist
  }
  for (const entry of entries) {
    const fullPath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      yield* walkFiles(fullPath, base);
    } else if (entry.isFile()) {
      yield path.relative(base, fullPath);
    }
  }
}

/**
 * Precompress all compressible hashed assets under `clientDir/<assetsDir>/`.
 *
 * Writes `.br`, `.gz`, and `.zst` files alongside each original.
 * Safe to re-run — overwrites existing compressed variants with identical
 * output, and never compresses `.br`, `.gz`, or `.zst` files themselves.
 *
 * `assetsDir` defaults to `"_next/static"` (Next.js's canonical convention,
 * matching `resolveAssetsDir("")`). When `assetPrefix` is configured as a
 * path prefix the build writes assets to a different directory (e.g.
 * `"cdn/_next/static"`); callers should resolve that with
 * `resolveAssetsDir(assetPrefix)` and thread it through. Without this,
 * `assetPrefix` builds would walk an empty `_next/static/` directory and
 * emit zero compressed variants.
 */
export async function precompressAssets(
  clientDir: string,
  options: {
    /** Subdirectory under `clientDir` containing hashed assets. Defaults to `"_next/static"`. */
    assetsDir?: string;
    onProgress?: (completed: number, total: number, file: string) => void;
  } = {},
): Promise<PrecompressResult> {
  const { assetsDir: assetsSubdir = ASSET_PREFIX_URL_DIR, onProgress } = options;
  const assetsDir = path.join(clientDir, assetsSubdir);
  const result: PrecompressResult = {
    filesCompressed: 0,
    totalOriginalBytes: 0,
    totalBrotliBytes: 0,
  };

  // Collect compressible file paths, then read + compress in bounded chunks
  // to keep peak memory at O(CONCURRENCY * max_file_size) instead of
  // O(total_assets).
  const filePaths: string[] = [];

  for await (const relativePath of walkFiles(assetsDir)) {
    const ext = path.extname(relativePath).toLowerCase();

    if (!COMPRESSIBLE_EXTENSIONS.has(ext)) continue;
    // .br/.gz/.zst are intentionally absent from COMPRESSIBLE_EXTENSIONS, so
    // precompressed variants generated by a previous run are never re-compressed.

    filePaths.push(path.join(assetsDir, relativePath));
  }

  let processed = 0;
  for (let i = 0; i < filePaths.length; i += CONCURRENCY) {
    const chunk = filePaths.slice(i, i + CONCURRENCY);
    await Promise.all(
      chunk.map(async (fullPath) => {
        const content = await fsp.readFile(fullPath);
        // readFile already done before this check — stat()-first would save
        // the read for tiny files but costs an extra syscall per file;
        // sub-1KB hashed assets are rare enough that read-first is cheaper.
        if (content.length < MIN_SIZE) return;

        // Compress all variants concurrently within each file
        const compressions: Promise<Buffer>[] = [
          brotliCompress(content, {
            params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 },
          }),
          gzip(content, { level: 8 }),
        ];
        if (zstdCompress) {
          compressions.push(
            zstdCompress(content, {
              params: { [zlib.constants.ZSTD_c_compressionLevel]: 8 },
            }),
          );
        }

        const results = await Promise.all(compressions);
        const [brContent, gzContent, zstdContent] = results;

        const writes = [
          fsp.writeFile(fullPath + ".br", brContent),
          fsp.writeFile(fullPath + ".gz", gzContent),
        ];
        if (zstdContent) {
          writes.push(fsp.writeFile(fullPath + ".zst", zstdContent));
        }
        await Promise.all(writes);

        // Increment counters only after all writes succeed, so partial
        // failures (e.g. ENOSPC mid-write) don't inflate the reported totals.
        result.filesCompressed++;
        result.totalOriginalBytes += content.length;
        result.totalBrotliBytes += brContent.length;
      }),
    );
    // Report progress once per chunk to avoid non-deterministic ordering
    // within Promise.all (smaller files complete before larger ones).
    // Progress tracks all files (including skipped ones below MIN_SIZE),
    // which differs from filesCompressed (only files actually compressed).
    processed += chunk.length;
    onProgress?.(processed, filePaths.length, path.basename(chunk[chunk.length - 1]));
  }

  return result;
}
