#!/usr/bin/env node

import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { createReadStream } from "node:fs";
import {
  constants,
  copyFile,
  lstat,
  mkdir,
  mkdtemp,
  readdir,
  rm,
  stat,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import process from "node:process";
import { promisify } from "node:util";

type Options = {
  source: string;
  output: string;
  replace: boolean;
  temporaryDirectory: string;
};

type TreeEntry = {
  type: "directory" | "file";
  mtime100ns: bigint;
  size?: bigint;
  sha256?: string;
};

const execFileAsync = promisify(execFile);
const supportedExtensions = new Set([".7z", ".exe", ".rar", ".zip"]);

function usage(): void {
  console.log(`Usage: pnpm repack-archive [options] <archive-exe-or-directory>

Creates a Windows XP-compatible ZIP next to the source.

  -o, --output <path>  write to a specific ZIP path
  --temp-dir <path>    use a specific directory for temporary files
  --replace            delete the source after successful verification
  --help               show this help message`);
}

function parseArguments(args: string[]): Options {
  let output: string | undefined;
  let temporaryDirectory = tmpdir();
  let replace = false;
  const positional: string[] = [];

  for (let index = 0; index < args.length; index += 1) {
    const argument = args[index];
    if (argument === "--help" || argument === "-h") {
      usage();
      process.exit(0);
    } else if (argument === "--replace") {
      replace = true;
    } else if (argument === "--output" || argument === "-o") {
      output = args[index + 1];
      if (output === undefined) {
        throw new Error(`${argument} requires a path`);
      }
      index += 1;
    } else if (argument === "--temp-dir") {
      const value = args[index + 1];
      if (value === undefined) {
        throw new Error(`${argument} requires a path`);
      }
      temporaryDirectory = path.resolve(value);
      index += 1;
    } else if (argument.startsWith("-")) {
      throw new Error(`Unknown option: ${argument}`);
    } else {
      positional.push(argument);
    }
  }

  if (positional.length !== 1) {
    throw new Error("Expected one archive, EXE, or directory path");
  }

  const source = path.resolve(positional[0]);
  const extension = path.extname(source).toLowerCase();
  const sourceBasename = supportedExtensions.has(extension)
    ? path.basename(source, extension)
    : path.basename(source);

  const destination = path.resolve(
    output ?? path.join(path.dirname(source), `${sourceBasename}.zip`),
  );
  if (path.extname(destination).toLowerCase() !== ".zip") {
    throw new Error("Output path must have the .zip extension");
  }
  if (source === destination) {
    throw new Error("Source and output paths are the same; use --output for ZIP input");
  }

  return { source, output: destination, replace, temporaryDirectory };
}

async function run(
  command: string,
  args: string[],
  cwd?: string,
): Promise<{ stdout: string; stderr: string }> {
  return execFileAsync(command, args, {
    cwd,
    encoding: "utf8",
    maxBuffer: 16 * 1024 * 1024,
  });
}

async function sha256(filePath: string): Promise<string> {
  const hash = createHash("sha256");
  for await (const chunk of createReadStream(filePath)) {
    hash.update(chunk);
  }
  return hash.digest("hex");
}

async function collectTree(root: string): Promise<Map<string, TreeEntry>> {
  const result = new Map<string, TreeEntry>();

  async function visit(directory: string): Promise<void> {
    const entries = await readdir(directory, { withFileTypes: true });
    entries.sort((left, right) => left.name.localeCompare(right.name, "en"));

    for (const entry of entries) {
      const absolutePath = path.join(directory, entry.name);
      const relativePath = path.relative(root, absolutePath).split(path.sep).join("/");
      const fileStat = await lstat(absolutePath, { bigint: true });
      const mtime100ns = fileStat.mtimeNs / 100n;

      if (fileStat.isDirectory()) {
        result.set(relativePath, { type: "directory", mtime100ns });
        await visit(absolutePath);
      } else if (fileStat.isFile()) {
        result.set(relativePath, {
          type: "file",
          mtime100ns,
          size: fileStat.size,
          sha256: await sha256(absolutePath),
        });
      } else {
        throw new Error(`Unsupported special file: ${relativePath}`);
      }
    }
  }

  await visit(root);
  return result;
}

function compareTrees(
  original: Map<string, TreeEntry>,
  verified: Map<string, TreeEntry>,
): void {
  if (original.size === 0) {
    throw new Error("Source contains no files or directories");
  }
  if (original.size !== verified.size) {
    throw new Error("ZIP contents differ from the source");
  }

  for (const [entryPath, expected] of original) {
    const actual = verified.get(entryPath);
    if (actual === undefined) {
      throw new Error(`Missing entry after repacking: ${entryPath}`);
    }
    if (
      actual.type !== expected.type ||
      actual.mtime100ns !== expected.mtime100ns ||
      actual.size !== expected.size ||
      actual.sha256 !== expected.sha256
    ) {
      throw new Error(`Entry differs after repacking: ${entryPath}`);
    }
  }
}

async function validateCompatibility(zipPath: string): Promise<void> {
  const { stdout } = await run("zipinfo", ["-v", zipPath]);
  if (/zip64/i.test(stdout)) {
    throw new Error("Zip64 is not compatible with Windows XP Explorer");
  }

  const versions = [...stdout.matchAll(
    /minimum software version required to extract:\s*([0-9.]+)/g,
  )];
  if (versions.some((match) => Number(match[1]) > 2)) {
    throw new Error("ZIP requires an extractor newer than version 2.0");
  }

  const methods = [...stdout.matchAll(/compression method:\s*([^\r\n]+)/g)];
  if (
    methods.length === 0 ||
    methods.some((match) => !/^(deflated|none \(stored\))$/.test(match[1].trim()))
  ) {
    throw new Error("ZIP uses a method other than Store or Deflate");
  }

  const security = [...stdout.matchAll(/file security status:\s*([^\r\n]+)/g)];
  if (
    security.length === 0 ||
    security.some((match) => match[1].trim() !== "not encrypted")
  ) {
    throw new Error("Encrypted ZIP entries are not allowed");
  }
}

async function ensureOutputDoesNotExist(output: string): Promise<void> {
  try {
    await lstat(output);
    throw new Error(`Output already exists: ${output}`);
  } catch (error) {
    const code = error instanceof Error && "code" in error ? error.code : undefined;
    if (code !== "ENOENT") {
      throw error;
    }
  }
}

async function main(): Promise<void> {
  const options = parseArguments(process.argv.slice(2));
  const sourceStat = await stat(options.source);
  if (!sourceStat.isFile() && !sourceStat.isDirectory()) {
    throw new Error(`Unsupported source type: ${options.source}`);
  }
  if (sourceStat.isFile()) {
    const extension = path.extname(options.source).toLowerCase();
    if (!supportedExtensions.has(extension)) {
      throw new Error(`Unsupported source type: ${extension || "no extension"}`);
    }
  } else if (options.replace) {
    throw new Error("--replace is not supported for directory sources");
  }
  await ensureOutputDoesNotExist(options.output);

  await mkdir(options.temporaryDirectory, { recursive: true });
  const temporaryRoot = await mkdtemp(
    path.join(options.temporaryDirectory, "repack-archive-"),
  );
  const originalDirectory = path.join(temporaryRoot, "original");
  const verifiedDirectory = path.join(temporaryRoot, "verified");
  const temporaryZip = path.join(temporaryRoot, "output.zip");
  await mkdir(originalDirectory);
  await mkdir(verifiedDirectory);

  try {
    if (sourceStat.isDirectory()) {
      await run("cp", ["-a", "--", path.join(options.source, "."), originalDirectory]);
    } else if (path.extname(options.source).toLowerCase() === ".exe") {
      await run("cp", ["-p", "--", options.source, originalDirectory]);
    } else {
      await run("7z", ["t", options.source, "-bso0", "-bsp0"]);
      await run("7z", [
        "x",
        options.source,
        "-y",
        `-o${originalDirectory}`,
        "-bso0",
        "-bsp0",
      ]);
    }

    const originalTree = await collectTree(originalDirectory);
    await run(
      "7z",
      [
        "a",
        "-tzip",
        "-mm=Deflate",
        "-mx=9",
        "-mtc=on",
        "-mtm=on",
        "-mta=on",
        temporaryZip,
        ".",
        "-bso0",
        "-bsp0",
      ],
      originalDirectory,
    );
    await run("touch", ["-r", options.source, temporaryZip]);
    await run("7z", ["t", temporaryZip, "-bso0", "-bsp0"]);
    await validateCompatibility(temporaryZip);

    await run("7z", [
      "x",
      temporaryZip,
      "-y",
      `-o${verifiedDirectory}`,
      "-bso0",
      "-bsp0",
    ]);
    compareTrees(originalTree, await collectTree(verifiedDirectory));

    await mkdir(path.dirname(options.output), { recursive: true });
    try {
      await copyFile(temporaryZip, options.output, constants.COPYFILE_EXCL);
      await run("touch", ["-r", options.source, options.output]);
    } catch (error) {
      await rm(options.output, { force: true });
      throw error;
    }

    if (options.replace) {
      await rm(options.source);
    }

    console.log(`Created ${options.output}`);
    console.log("Verified contents, SHA-256, timestamps, and Windows XP compatibility.");
    if (options.replace) {
      console.log(`Removed ${options.source}`);
    }
  } finally {
    await rm(temporaryRoot, { recursive: true, force: true });
  }
}

try {
  await main();
} catch (error) {
  const message = error instanceof Error ? error.message : String(error);
  console.error(`Error: ${message}`);
  process.exitCode = 1;
}
