import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import os from "node:os";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { parse } from "yaml";

const EXPECTED_PUBLISH = Object.freeze({
  provider: "github",
  owner: "Sucukdeluxe",
  repo: "multi-debrid-downloader"
});
const EXPECTED_PRODUCT_NAME = "Real-Debrid-Downloader";
const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}";
const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}";
const REQUIRED_BUILD_FILES = Object.freeze([
  "resources/extractor-jvm/**/*",
  "LICENSE"
]);
const EXPECTED_EXTRA_RESOURCE = Object.freeze({
  from: "LICENSE",
  to: "LICENSE"
});
const REDISTRIBUTION_FILES = Object.freeze([
  Object.freeze({
    sourcePath: "LICENSE",
    packagedPath: "resources/LICENSE",
    sha256: "f2c1bc02d9ba5235cc67dfea734e7dc90559b00d8cb2d142bad7a984ff96d3f6"
  }),
  Object.freeze({
    sourcePath: "resources/extractor-jvm/licenses/LGPL-2.1.txt",
    packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/LGPL-2.1.txt",
    sha256: "20e50fe7aae3e56378ebf0417d9de904f55a0e61e4df315333e632a4d3555d95"
  }),
  Object.freeze({
    sourcePath: "resources/extractor-jvm/licenses/7-Zip-license.txt",
    packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/7-Zip-license.txt",
    sha256: "477e15d4033026edb25d36c9f078bb0beafc9318f6505473648972a536ece263"
  }),
  Object.freeze({
    sourcePath: "resources/extractor-jvm/licenses/Apache-2.0.txt",
    packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/Apache-2.0.txt",
    sha256: "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30"
  }),
  Object.freeze({
    sourcePath: "resources/extractor-jvm/THIRD_PARTY_NOTICES.txt",
    packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/THIRD_PARTY_NOTICES.txt",
    sha256: "d4ab6ee9ba293f9d25d22e0506a465d79794259274a0ec944d3bb3d001b46ca9"
  })
]);

function readRequiredFile(filePath) {
  requireNonEmptyFile(filePath, "required file");
  return fs.readFileSync(filePath, "utf8");
}

function parseYamlDocument(content, sourceName) {
  let value;
  try {
    value = parse(content, { uniqueKeys: true });
  } catch (error) {
    throw new Error(`${sourceName} contains invalid YAML: ${String(error?.message || error)}`);
  }
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new Error(`${sourceName} must contain a YAML mapping`);
  }
  return value;
}

function assertEqual(actual, expected, label) {
  if (actual !== expected) {
    throw new Error(`${label} must be ${expected}, received ${String(actual)}`);
  }
}

function resolveReleaseDir(rootDir) {
  const directLatest = path.join(rootDir, "latest.yml");
  if (fs.existsSync(directLatest)) {
    return rootDir;
  }
  return path.join(rootDir, "release");
}

function requireNonEmptyFile(filePath, label) {
  let stat;
  try {
    stat = fs.lstatSync(filePath);
  } catch (error) {
    if (error?.code === "ENOENT") {
      throw new Error(`Missing ${label}: ${filePath}`);
    }
    throw error;
  }
  if (stat.isSymbolicLink()) {
    throw new Error(`${label} must be a regular file, not a symbolic link: ${filePath}`);
  }
  if (!stat.isFile() || stat.size === 0) {
    throw new Error(`${label} must be a non-empty regular file: ${filePath}`);
  }
  return stat;
}

function sha256NormalizedText(filePath) {
  const normalized = fs.readFileSync(filePath, "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
  return crypto.createHash("sha256").update(normalized, "utf8").digest("hex");
}

function verifyRedistributionFiles(baseDir, pathKey, label) {
  for (const file of REDISTRIBUTION_FILES) {
    const relativePath = file[pathKey];
    const filePath = path.join(baseDir, ...relativePath.split("/"));
    requireNonEmptyFile(filePath, `${label} redistribution file`);
    const actualDigest = sha256NormalizedText(filePath);
    if (actualDigest !== file.sha256) {
      throw new Error(`${label} redistribution content mismatch for ${relativePath}`);
    }
  }
}

function hasExpectedExtraResource(extraResources) {
  const entries = Array.isArray(extraResources) ? extraResources : [extraResources];
  return entries.some((entry) => (
    entry
    && typeof entry === "object"
    && !Array.isArray(entry)
    && entry.from === EXPECTED_EXTRA_RESOURCE.from
    && entry.to === EXPECTED_EXTRA_RESOURCE.to
  ));
}

function runCommand(command, args) {
  const result = spawnSync(command, args, {
    encoding: "utf8",
    windowsHide: true
  });
  return {
    status: result.status,
    stdout: String(result.stdout || ""),
    stderr: String(result.stderr || ""),
    error: result.error
  };
}

function listFiles(rootDir) {
  const files = [];
  const pending = [rootDir];
  while (pending.length > 0) {
    const current = pending.pop();
    for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
      const entryPath = path.join(current, entry.name);
      const stat = fs.lstatSync(entryPath);
      if (stat.isSymbolicLink()) {
        files.push(entryPath);
      } else if (stat.isDirectory()) {
        pending.push(entryPath);
      } else if (stat.isFile()) {
        files.push(entryPath);
      }
    }
  }
  return files;
}

function extractArchive(archivePath, outputDir, sevenZipPath, commandRunner) {
  fs.mkdirSync(outputDir, { recursive: true });
  const args = ["x", archivePath, `-o${outputDir}`, "-y", "-bb0", "-bd"];
  const result = commandRunner(sevenZipPath, args);
  if (result?.error || result?.status !== 0) {
    const detail = String(result?.error?.message || result?.stderr || result?.stdout || "unknown error").trim();
    throw new Error(`7-Zip command failed for ${archivePath}: ${detail}`);
  }
}

function extractArchiveTree(archivePath, outputDir, sevenZipPath, commandRunner, depth = 0) {
  if (depth > 5) {
    throw new Error(`Archive nesting limit exceeded for ${archivePath}`);
  }
  extractArchive(archivePath, outputDir, sevenZipPath, commandRunner);
  const nestedArchives = listFiles(outputDir).filter((filePath) => /\.7z$/i.test(filePath));
  for (let index = 0; index < nestedArchives.length; index += 1) {
    const nestedArchive = nestedArchives[index];
    const nestedOutput = path.join(outputDir, `.nested-${depth}-${index}`);
    extractArchiveTree(nestedArchive, nestedOutput, sevenZipPath, commandRunner, depth + 1);
  }
}

function normalizeRelativePath(rootDir, filePath) {
  return path.relative(rootDir, filePath).split(path.sep).join("/");
}

function verifyArchiveRedistributionFiles(extractionRoot, archiveName) {
  const extractedFiles = listFiles(extractionRoot);
  for (const file of REDISTRIBUTION_FILES) {
    const suffix = file.packagedPath.toLowerCase();
    const matches = extractedFiles.filter((filePath) => {
      const relativePath = normalizeRelativePath(extractionRoot, filePath).toLowerCase();
      return relativePath === suffix || relativePath.endsWith(`/${suffix}`);
    });
    if (matches.length === 0) {
      throw new Error(`Missing redistribution file ${file.packagedPath} in archive ${archiveName}`);
    }
    for (const filePath of matches) {
      requireNonEmptyFile(filePath, `${archiveName} redistribution file`);
      const actualDigest = sha256NormalizedText(filePath);
      if (actualDigest !== file.sha256) {
        throw new Error(`Archive redistribution content mismatch for ${file.packagedPath} in ${archiveName}`);
      }
    }
  }
}

function readPackageVersion(rootDir) {
  const packageJson = JSON.parse(readRequiredFile(path.join(rootDir, "package.json")));
  const version = String(packageJson.version || "").trim();
  if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
    throw new Error(`package.json contains invalid version ${version || "<empty>"}`);
  }
  return { packageJson, version };
}

function sha512Base64(filePath) {
  const hash = crypto.createHash("sha512");
  const handle = fs.openSync(filePath, "r");
  const buffer = Buffer.allocUnsafe(1024 * 1024);
  try {
    let bytesRead;
    do {
      bytesRead = fs.readSync(handle, buffer, 0, buffer.length, null);
      if (bytesRead > 0) {
        hash.update(buffer.subarray(0, bytesRead));
      }
    } while (bytesRead > 0);
  } finally {
    fs.closeSync(handle);
  }
  return hash.digest("base64");
}

export function verifyPublicRelease(rootDir = process.cwd()) {
  const absoluteRoot = path.resolve(rootDir);
  const { packageJson, version } = readPackageVersion(absoluteRoot);

  const build = packageJson.build || {};
  const publish = build.publish || {};
  assertEqual(build.productName, EXPECTED_PRODUCT_NAME, "package.json build.productName");
  assertEqual(publish.provider, EXPECTED_PUBLISH.provider, "package.json publish provider");
  assertEqual(publish.owner, EXPECTED_PUBLISH.owner, "package.json publish owner");
  assertEqual(publish.repo, EXPECTED_PUBLISH.repo, "package.json publish repo");
  assertEqual(build.nsis?.artifactName, EXPECTED_NSIS_ARTIFACT_NAME, "package.json NSIS artifactName");
  assertEqual(build.portable?.artifactName, EXPECTED_PORTABLE_ARTIFACT_NAME, "package.json portable artifactName");
  const buildFiles = Array.isArray(build.files) ? build.files : [];
  const missingBuildFiles = REQUIRED_BUILD_FILES.filter((entry) => !buildFiles.includes(entry));
  if (missingBuildFiles.length > 0) {
    throw new Error(`package.json build.files omits redistribution content: ${missingBuildFiles.join(", ")}`);
  }
  if (!hasExpectedExtraResource(build.extraResources)) {
    throw new Error("package.json build.extraResources must copy LICENSE to LICENSE");
  }

  const releaseDir = resolveReleaseDir(absoluteRoot);
  const latestFields = parseYamlDocument(
    readRequiredFile(path.join(releaseDir, "latest.yml")),
    "latest.yml"
  );
  const appUpdateFields = parseYamlDocument(
    readRequiredFile(path.join(releaseDir, "win-unpacked", "resources", "app-update.yml")),
    "app-update.yml"
  );

  assertEqual(latestFields.version, version, "latest.yml version");
  assertEqual(appUpdateFields.provider, EXPECTED_PUBLISH.provider, "app-update.yml provider");
  assertEqual(appUpdateFields.owner, EXPECTED_PUBLISH.owner, "app-update.yml owner");
  assertEqual(appUpdateFields.repo, EXPECTED_PUBLISH.repo, "app-update.yml repo");

  const expectedSetup = `${EXPECTED_PRODUCT_NAME}-Setup-${version}.exe`;
  const expectedPortable = `${EXPECTED_PRODUCT_NAME}-${version}-portable.exe`;
  const expectedBlockmap = `${expectedSetup}.blockmap`;
  assertEqual(latestFields.path, expectedSetup, "latest.yml path");

  const requiredArtifacts = [expectedSetup, expectedPortable, expectedBlockmap];
  const missingArtifacts = requiredArtifacts.filter((fileName) => !fs.existsSync(path.join(releaseDir, fileName)));
  if (missingArtifacts.length > 0) {
    throw new Error(`Missing release artifacts: ${missingArtifacts.join(", ")}`);
  }
  const artifactStats = new Map();
  for (const fileName of requiredArtifacts) {
    artifactStats.set(fileName, requireNonEmptyFile(path.join(releaseDir, fileName), "release artifact"));
  }

  if (!Array.isArray(latestFields.files) || latestFields.files.length !== 1) {
    throw new Error("latest.yml files must contain exactly one canonical installer entry");
  }
  const installerEntry = latestFields.files[0];
  if (!installerEntry || typeof installerEntry !== "object" || Array.isArray(installerEntry)) {
    throw new Error("latest.yml files entry must be a mapping");
  }
  assertEqual(installerEntry.url, expectedSetup, "latest.yml files url");
  const expectedSetupSize = artifactStats.get(expectedSetup).size;
  assertEqual(installerEntry.size, expectedSetupSize, "latest.yml files size");
  const expectedSetupDigest = sha512Base64(path.join(releaseDir, expectedSetup));
  assertEqual(installerEntry.sha512, expectedSetupDigest, "latest.yml files SHA512 digest");
  assertEqual(latestFields.sha512, expectedSetupDigest, "latest.yml SHA512 digest");

  verifyRedistributionFiles(absoluteRoot, "sourcePath", "source");
  verifyRedistributionFiles(path.join(releaseDir, "win-unpacked"), "packagedPath", "win-unpacked");

  return {
    publish: {
      provider: publish.provider,
      owner: publish.owner,
      repo: publish.repo
    },
    latestArtifact: latestFields.path,
    missingArtifacts
  };
}

export function verifyReleaseArchives(rootDir = process.cwd(), options = {}) {
  const absoluteRoot = path.resolve(rootDir);
  const { version } = readPackageVersion(absoluteRoot);
  const releaseDir = resolveReleaseDir(absoluteRoot);
  const sevenZipPath = String(options.sevenZipPath || process.env.SEVEN_ZIP_PATH || "7z").trim();
  const commandRunner = options.runCommand || runCommand;
  const archiveNames = [
    `${EXPECTED_PRODUCT_NAME}-Setup-${version}.exe`,
    `${EXPECTED_PRODUCT_NAME}-${version}-portable.exe`
  ];
  const verifiedArchives = [];

  for (const archiveName of archiveNames) {
    const archivePath = path.join(releaseDir, archiveName);
    requireNonEmptyFile(archivePath, "release archive");
    const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-archive-"));
    try {
      extractArchiveTree(archivePath, extractionRoot, sevenZipPath, commandRunner);
      verifyArchiveRedistributionFiles(extractionRoot, archiveName);
      verifiedArchives.push(archiveName);
    } finally {
      fs.rmSync(extractionRoot, { recursive: true, force: true });
    }
  }

  return { verifiedArchives };
}

function parseCliArgs(argv) {
  let rootDir = "";
  let verifyArchives = false;
  let sevenZipPath = "";
  for (let index = 0; index < argv.length; index += 1) {
    const arg = argv[index];
    if (arg === "--verify-archives") {
      verifyArchives = true;
    } else if (arg === "--seven-zip") {
      index += 1;
      sevenZipPath = String(argv[index] || "").trim();
      if (!sevenZipPath) {
        throw new Error("--seven-zip requires an executable path");
      }
    } else if (arg.startsWith("--")) {
      throw new Error(`Unknown argument: ${arg}`);
    } else if (!rootDir) {
      rootDir = arg;
    } else {
      throw new Error(`Unexpected argument: ${arg}`);
    }
  }
  if (sevenZipPath && !verifyArchives) {
    throw new Error("--seven-zip requires --verify-archives");
  }
  return {
    rootDir: rootDir || process.cwd(),
    verifyArchives,
    sevenZipPath: sevenZipPath || process.env.SEVEN_ZIP_PATH || "7z"
  };
}

const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isCli) {
  try {
    const args = parseCliArgs(process.argv.slice(2));
    const result = verifyPublicRelease(args.rootDir);
    const archiveResult = args.verifyArchives
      ? verifyReleaseArchives(args.rootDir, { sevenZipPath: args.sevenZipPath })
      : { verifiedArchives: [] };
    process.stdout.write(`${JSON.stringify({ ...result, ...archiveResult })}\n`);
  } catch (error) {
    process.stderr.write(`${String(error?.message || error)}\n`);
    process.exitCode = 1;
  }
}
