import yauzl from "yauzl";
import type { Entry, ZipFile } from "yauzl";
import {
  BUNDLE_CONTRACT,
  contentTypeForPath,
  DEFAULT_ENTRY_POINT,
  extensionOf,
  isAllowedExtension,
  MANIFEST_FILENAME,
  type DashboardBundleFile,
  type DashboardBundleValidationResult,
  type DashboardManifest,
} from "./dashboard-bundle.types";

/**
 * Pure Bundle Contract validation. Takes a zip buffer, gives back either the
 * fully-inflated, contract-compliant file set or a list of everything wrong
 * with it. No network, no filesystem, no env — so it unit-tests without mocks.
 *
 * Two passes on purpose:
 *   pass 1 reads only the central directory (names, declared sizes, unix mode)
 *          and rejects on metadata alone — nothing is inflated, so a zip bomb
 *          that *declares* 4 GB never costs us 4 GB.
 *   pass 2 inflates, holding a running byte budget, so a zip bomb that *lies*
 *          about its declared sizes is cut off mid-stream.
 */

// yauzl reports the unix mode in the high 16 bits of externalFileAttributes.
const S_IFMT = 0o170000;
const S_IFREG = 0o100000;
const S_IFDIR = 0o040000;
const S_IFLNK = 0o120000;

const openZip = (buffer: Buffer): Promise<ZipFile> =>
  new Promise((resolve, reject) => {
    // autoClose:false — we keep reading entry streams after the walk ends.
    // validateEntrySizes:true (default) makes yauzl itself error when an entry
    // inflates to something other than its declared size.
    yauzl.fromBuffer(
      buffer,
      { lazyEntries: true, autoClose: false },
      (err, zipfile) => {
        if (err || !zipfile) {
          reject(err ?? new Error("zip could not be opened"));
          return;
        }
        resolve(zipfile);
      },
    );
  });

const walkEntries = (zipfile: ZipFile): Promise<Entry[]> =>
  new Promise((resolve, reject) => {
    const entries: Entry[] = [];
    zipfile.on("entry", (entry: Entry) => {
      entries.push(entry);
      zipfile.readEntry();
    });
    zipfile.on("end", () => resolve(entries));
    zipfile.on("error", reject);
    zipfile.readEntry();
  });

class ByteBudgetExceeded extends Error {
  constructor() {
    super("byte budget exceeded");
  }
}

/** Inflate one entry, aborting the stream the moment it outgrows the budget. */
const readEntry = (
  zipfile: ZipFile,
  entry: Entry,
  budgetRemaining: number,
): Promise<Buffer> =>
  new Promise((resolve, reject) => {
    zipfile.openReadStream(entry, (err, stream) => {
      if (err || !stream) {
        reject(err ?? new Error(`could not read "${entry.fileName}"`));
        return;
      }
      const chunks: Buffer[] = [];
      let seen = 0;
      stream.on("data", (chunk: Buffer) => {
        seen += chunk.length;
        if (seen > budgetRemaining) {
          stream.destroy();
          reject(new ByteBudgetExceeded());
          return;
        }
        chunks.push(chunk);
      });
      stream.on("end", () => resolve(Buffer.concat(chunks)));
      stream.on("error", reject);
    });
  });

/**
 * Zip-slip and friends. Returns the safe normalised path, or null if the entry
 * must be refused. Everything here is decided on the *name alone* — we never
 * resolve against a real directory, so there's no window where a bad path
 * touches the filesystem.
 */
export const safeRelativePath = (rawName: string): string | null => {
  if (!rawName) return null;
  // NUL and backslash are never legal in a spec-compliant zip entry name; both
  // are classic truncation / separator-confusion tricks.
  if (rawName.includes("\0") || rawName.includes("\\")) return null;
  // Absolute POSIX path, UNC-ish, or a Windows drive letter.
  if (rawName.startsWith("/")) return null;
  if (/^[a-z]:/i.test(rawName)) return null;

  const segments: string[] = [];
  for (const segment of rawName.split("/")) {
    if (segment === "" || segment === ".") continue; // collapse "a//b" and "./a"
    if (segment === "..") return null; // any escape attempt at all
    segments.push(segment);
  }
  if (segments.length === 0) return null;
  return segments.join("/");
};

const parseManifest = (
  raw: Buffer,
): { manifest: DashboardManifest | null; warning: string | null } => {
  let parsed: unknown;
  try {
    parsed = JSON.parse(raw.toString("utf8"));
  } catch {
    return {
      manifest: null,
      warning: `${MANIFEST_FILENAME} is not valid JSON and was ignored`,
    };
  }
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
    return {
      manifest: null,
      warning: `${MANIFEST_FILENAME} is not a JSON object and was ignored`,
    };
  }
  const m = parsed as Record<string, unknown>;
  const bad: string[] = [];
  if (typeof m.name !== "string") bad.push("name");
  if (typeof m.version !== "string") bad.push("version");
  if (typeof m.entry !== "string") bad.push("entry");
  if (m.description !== undefined && typeof m.description !== "string") {
    bad.push("description");
  }
  if (bad.length > 0) {
    return {
      manifest: null,
      warning: `${MANIFEST_FILENAME} has invalid or missing field(s): ${bad.join(", ")} — ignored`,
    };
  }
  return {
    manifest: {
      name: m.name as string,
      version: m.version as string,
      entry: m.entry as string,
      ...(typeof m.description === "string"
        ? { description: m.description }
        : {}),
    },
    warning: null,
  };
};

export const validateDashboardBundle = async (
  zipBuffer: Buffer,
): Promise<DashboardBundleValidationResult> => {
  if (!Buffer.isBuffer(zipBuffer) || zipBuffer.length === 0) {
    return { ok: false, errors: ["Upload is empty or is not a file."] };
  }

  let zipfile: ZipFile;
  try {
    zipfile = await openZip(zipBuffer);
  } catch (err) {
    return {
      ok: false,
      errors: [
        `Upload is not a readable zip archive: ${(err as Error).message}`,
      ],
    };
  }

  try {
    if (zipfile.entryCount > BUNDLE_CONTRACT.MAX_ZIP_ENTRIES) {
      return {
        ok: false,
        errors: [
          `Archive declares ${zipfile.entryCount} entries, which exceeds the limit of ${BUNDLE_CONTRACT.MAX_ZIP_ENTRIES}.`,
        ],
      };
    }

    let entries: Entry[];
    try {
      entries = await walkEntries(zipfile);
    } catch (err) {
      return {
        ok: false,
        errors: [`Archive is corrupt: ${(err as Error).message}`],
      };
    }

    // -----------------------------------------------------------------
    // Pass 1 — metadata only. Collect every failure rather than bailing on
    // the first, so the uploader gets one complete list to fix.
    // -----------------------------------------------------------------
    const errors: string[] = [];
    const unsafePaths: string[] = [];
    const symlinkPaths: string[] = [];
    const irregularPaths: string[] = [];
    const disallowedPaths: string[] = [];
    const duplicatePaths: string[] = [];

    const fileEntries: { entry: Entry; path: string }[] = [];
    const seenPaths = new Set<string>();
    let declaredBytes = 0;

    for (const entry of entries) {
      const rawName = entry.fileName;
      const mode = (entry.externalFileAttributes >>> 16) & 0xffff;
      const fileType = mode & S_IFMT;

      if (fileType === S_IFLNK) {
        symlinkPaths.push(rawName);
        continue;
      }

      const safePath = safeRelativePath(
        rawName.endsWith("/") ? rawName.slice(0, -1) : rawName,
      );
      if (safePath === null) {
        unsafePaths.push(rawName);
        continue;
      }

      // Directories carry no content and aren't counted or stored; we recreate
      // structure implicitly from file paths.
      const isDirectory = rawName.endsWith("/") || fileType === S_IFDIR;
      if (isDirectory) continue;

      // mode 0 means the zip was written by a tool that records no unix mode
      // (most Windows zippers) — that's normal, so only reject a mode that is
      // present and says "not a regular file".
      if (mode !== 0 && fileType !== 0 && fileType !== S_IFREG) {
        irregularPaths.push(rawName);
        continue;
      }

      if (seenPaths.has(safePath)) {
        duplicatePaths.push(safePath);
        continue;
      }
      seenPaths.add(safePath);

      if (!isAllowedExtension(safePath)) {
        const ext = extensionOf(safePath);
        disallowedPaths.push(`${safePath} (${ext ? `.${ext}` : "no extension"})`);
        continue;
      }

      declaredBytes += entry.uncompressedSize;
      fileEntries.push({ entry, path: safePath });
    }

    if (unsafePaths.length > 0) {
      errors.push(
        `Archive contains ${unsafePaths.length} entr${unsafePaths.length === 1 ? "y" : "ies"} with unsafe paths (absolute, or escaping the bundle root): ${unsafePaths.join(", ")}`,
      );
    }
    if (symlinkPaths.length > 0) {
      errors.push(
        `Archive contains symlink entries, which are not allowed: ${symlinkPaths.join(", ")}`,
      );
    }
    if (irregularPaths.length > 0) {
      errors.push(
        `Archive contains entries that are neither regular files nor directories: ${irregularPaths.join(", ")}`,
      );
    }
    if (duplicatePaths.length > 0) {
      errors.push(
        `Archive contains duplicate paths: ${duplicatePaths.join(", ")}`,
      );
    }
    if (disallowedPaths.length > 0) {
      errors.push(
        `Archive contains ${disallowedPaths.length} file(s) with disallowed extensions: ${disallowedPaths.join(", ")}. Allowed: ${BUNDLE_CONTRACT.ALLOWED_EXTENSIONS.join(", ")}.`,
      );
    }
    if (fileEntries.length > BUNDLE_CONTRACT.MAX_FILE_COUNT) {
      errors.push(
        `Archive contains ${fileEntries.length} files, which exceeds the limit of ${BUNDLE_CONTRACT.MAX_FILE_COUNT}.`,
      );
    }
    if (declaredBytes > BUNDLE_CONTRACT.MAX_TOTAL_UNZIPPED_BYTES) {
      errors.push(
        `Archive declares ${declaredBytes} uncompressed bytes, which exceeds the limit of ${BUNDLE_CONTRACT.MAX_TOTAL_UNZIPPED_BYTES}.`,
      );
    }
    if (fileEntries.length === 0) {
      errors.push("Archive contains no usable files.");
    }

    if (errors.length > 0) return { ok: false, errors };

    // -----------------------------------------------------------------
    // Pass 2 — inflate under a running budget.
    // -----------------------------------------------------------------
    const files: DashboardBundleFile[] = [];
    let totalBytes = 0;

    for (const { entry, path } of fileEntries) {
      let buffer: Buffer;
      try {
        buffer = await readEntry(
          zipfile,
          entry,
          BUNDLE_CONTRACT.MAX_TOTAL_UNZIPPED_BYTES - totalBytes,
        );
      } catch (err) {
        if (err instanceof ByteBudgetExceeded) {
          return {
            ok: false,
            errors: [
              `Archive expands beyond the limit of ${BUNDLE_CONTRACT.MAX_TOTAL_UNZIPPED_BYTES} uncompressed bytes (detected while extracting "${path}").`,
            ],
          };
        }
        return {
          ok: false,
          errors: [
            `Archive is corrupt: could not extract "${path}": ${(err as Error).message}`,
          ],
        };
      }
      totalBytes += buffer.length;
      files.push({
        path,
        size: buffer.length,
        contentType: contentTypeForPath(path),
        buffer,
      });
    }

    // -----------------------------------------------------------------
    // Entry point resolution.
    // -----------------------------------------------------------------
    const warnings: string[] = [];
    const byPath = new Map(files.map((f) => [f.path, f]));
    const isHtmlFile = (p: string) =>
      byPath.has(p) && extensionOf(p) === "html";

    let manifest: DashboardManifest | null = null;
    let entryPoint: string | null = null;

    const manifestFile = byPath.get(MANIFEST_FILENAME);
    const manifestPresent = manifestFile !== undefined;

    if (manifestFile) {
      const { manifest: parsed, warning } = parseManifest(manifestFile.buffer);
      if (warning) warnings.push(warning);
      if (parsed) {
        const candidate = safeRelativePath(parsed.entry);
        if (candidate && isHtmlFile(candidate)) {
          manifest = parsed;
          entryPoint = candidate;
        } else {
          warnings.push(
            `${MANIFEST_FILENAME} entry "${parsed.entry}" is not an .html file present in the bundle — falling back to ${DEFAULT_ENTRY_POINT}.`,
          );
          // Keep the metadata even though its entry was unusable.
          manifest = parsed;
        }
      }
    }

    if (!entryPoint && isHtmlFile(DEFAULT_ENTRY_POINT)) {
      entryPoint = DEFAULT_ENTRY_POINT;
    }

    if (!entryPoint) {
      /*
       * We know exactly what IS in the bundle at this point, so say so. The
       * bare "no index.html at root" message told uploaders what was missing
       * but not why, and the overwhelmingly common cause — zipping the folder
       * instead of its contents — is diagnosable from the paths we already
       * hold. The first sentence is kept stable because the developer guide
       * quotes it; the diagnosis is appended.
       */
      const base = `Could not determine an entry point: no manifest.json entry and no ${DEFAULT_ENTRY_POINT} at root.`;
      const paths = files.map((f) => f.path);
      const rootPaths = paths.filter((p) => !p.includes("/"));

      // Every file sharing one leading segment means a wrapper folder.
      const firstSegments = new Set(
        paths.map((p) => (p.includes("/") ? p.slice(0, p.indexOf("/")) : "")),
      );
      const wrapper =
        firstSegments.size === 1 && !firstSegments.has("")
          ? [...firstSegments][0]
          : null;

      const nested = paths.find(
        (p) =>
          p.toLowerCase().endsWith(`/${DEFAULT_ENTRY_POINT}`) ||
          p.toLowerCase().endsWith(`/${MANIFEST_FILENAME}`),
      );
      const caseVariant = rootPaths.find(
        (p) =>
          p.toLowerCase() === DEFAULT_ENTRY_POINT ||
          p.toLowerCase() === MANIFEST_FILENAME,
      );
      const rootHtml = rootPaths.filter((p) => extensionOf(p) === "html");
      const anyHtml = paths.filter((p) => extensionOf(p) === "html");

      let diagnosis: string;
      if (wrapper) {
        diagnosis = `Every file in this archive sits inside "${wrapper}/" — it looks like the folder was zipped instead of its contents. Re-zip so ${MANIFEST_FILENAME} and ${DEFAULT_ENTRY_POINT} are at the top level.`;
      } else if (nested) {
        diagnosis = `Found "${nested}", but it must be at the archive root, not inside a folder. Re-zip the folder's contents rather than the folder itself.`;
      } else if (caseVariant) {
        diagnosis = `Found "${caseVariant}" at the root — the name must be exactly lowercase ("${DEFAULT_ENTRY_POINT}" / "${MANIFEST_FILENAME}"). Rename it, or add a ${MANIFEST_FILENAME} whose "entry" points at it.`;
      } else if (rootHtml.length > 0) {
        diagnosis = `The root does contain ${rootHtml.map((p) => `"${p}"`).join(", ")}. Rename one to ${DEFAULT_ENTRY_POINT}, or add a ${MANIFEST_FILENAME} with "entry" set to it.`;
      } else if (anyHtml.length > 0) {
        diagnosis = `The only HTML in this archive is ${anyHtml.slice(0, 5).map((p) => `"${p}"`).join(", ")}${anyHtml.length > 5 ? ` (+${anyHtml.length - 5} more)` : ""}, none of it at the root. Move a page to the root or point ${MANIFEST_FILENAME}'s "entry" at one.`;
      } else {
        diagnosis = `This archive contains no .html files at all — a dashboard bundle needs at least one page.`;
      }

      return {
        ok: false,
        // Surface why a present-but-unusable manifest was ignored; it is often
        // the actual reason the fallback was reached.
        errors: [`${base} ${diagnosis}`, ...warnings],
      };
    }

    return {
      ok: true,
      entryPoint,
      manifest,
      manifestPresent,
      fileCount: files.length,
      totalBytes,
      files,
      warnings,
    };
  } finally {
    // fromBuffer keeps no fd, but close() releases yauzl's internal reader.
    try {
      zipfile.close();
    } catch {
      /* already closed */
    }
  }
};
