import yauzl from "yauzl";
import type { Entry, ZipFile } from "yauzl";
import { safeRelativePath } from "../dashboard/dashboard-bundle.validator";
import {
  extensionOf,
  IMAGE_CONTENT_TYPES,
  IMAGE_EXTENSIONS,
} from "./edm-assets";

/**
 * Reading a designer-delivered EDM zip.
 *
 * The shape designers actually ship is `index.html` + `images/` — which is the
 * folder structure the wizard asks about, already sitting on their disk. Taking
 * the zip whole means every relative path resolves in one action instead of the
 * author re-uploading each image by hand.
 *
 * Contract deliberately narrower than the dashboard bundle's: html and images
 * only. An EDM has no js, no css files (email css is inline or in a <style>
 * block), and no fonts, so accepting them would only widen what we store and
 * serve for no benefit.
 *
 * The two-pass zip-bomb defence mirrors dashboard-bundle.validator.ts:
 *   pass 1 reads only the central directory and rejects on declared metadata,
 *          so an archive that *claims* 4 GB never costs 4 GB;
 *   pass 2 inflates against a running byte budget, so one that *lies* about its
 *          declared sizes is cut off mid-stream.
 */

export const EDM_BUNDLE_CONTRACT = {
  MAX_TOTAL_UNZIPPED_BYTES: 25 * 1024 * 1024, // 25 MB
  MAX_FILE_COUNT: 150,
  MAX_ZIP_ENTRIES: 600,
  MAX_HTML_BYTES: 2 * 1024 * 1024,
} as const;

const S_IFMT = 0o170000;
const S_IFREG = 0o100000;
const S_IFDIR = 0o040000;
const S_IFLNK = 0o120000;

const ACCEPTED = new Set<string>(["html", "htm", ...IMAGE_EXTENSIONS]);

export type EDMBundleImage = {
  /** Path inside the zip, lower-cased — the key html references resolve against. */
  path: string;
  buffer: Buffer;
  contentType: string;
};

export type EDMBundle = {
  htmlPath: string;
  html: string;
  images: EDMBundleImage[];
};

export type EDMBundleResult =
  | { ok: true; bundle: EDMBundle; warnings: string[] }
  | { ok: false; errors: string[] };

/**
 * One EDM inside a multi-template archive.
 *
 * `dir` is the html's own directory, used both to scope its images and to
 * mirror the archive's folder layout into edm_folders. `folderPath` is that
 * directory split into segments, which is what the folder tree is built from.
 */
export type EDMBundleEntry = {
  htmlPath: string;
  /** "" for an html at the archive root. */
  dir: string;
  folderPath: string[];
  /** Suggested template name — the html's own name, or its folder's. */
  suggestedName: string;
  html: string;
  /** Images under this entry's own directory, plus any shared root images. */
  images: EDMBundleImage[];
};

export type EDMBundleEntriesResult =
  | { ok: true; entries: EDMBundleEntry[]; warnings: string[] }
  | { ok: false; errors: string[] };

const openZip = (buffer: Buffer): Promise<ZipFile> =>
  new Promise((resolve, reject) => {
    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);
      if (entries.length > EDM_BUNDLE_CONTRACT.MAX_ZIP_ENTRIES) {
        reject(
          new Error(
            `Archive declares more than ${EDM_BUNDLE_CONTRACT.MAX_ZIP_ENTRIES} entries`,
          ),
        );
        return;
      }
      zipfile.readEntry();
    });
    zipfile.on("end", () => resolve(entries));
    zipfile.on("error", reject);
    zipfile.readEntry();
  });

const readEntry = (zipfile: ZipFile, entry: Entry): Promise<Buffer> =>
  new Promise((resolve, reject) => {
    zipfile.openReadStream(entry, (err, stream) => {
      if (err || !stream) {
        reject(err ?? new Error("could not open entry stream"));
        return;
      }
      const chunks: Buffer[] = [];
      stream.on("data", (c: Buffer) => chunks.push(c));
      stream.on("end", () => resolve(Buffer.concat(chunks)));
      stream.on("error", reject);
    });
  });

type ArchiveFile = {
  /** Lower-cased, traversal-safe path within the archive. */
  path: string;
  buffer: Buffer;
};

type ArchiveResult =
  | { ok: true; files: ArchiveFile[]; warnings: string[] }
  | { ok: false; errors: string[] };

/**
 * Inflate every html/image entry in the archive.
 *
 * Shared by the single- and multi-template readers so the zip-bomb defence and
 * path-safety rules only exist in one place.
 */
const readArchive = async (buffer: Buffer): Promise<ArchiveResult> => {
  let zipfile: ZipFile;
  try {
    zipfile = await openZip(buffer);
  } catch (err) {
    return { ok: false, errors: [`Not a readable zip: ${(err as Error).message}`] };
  }

  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 ────────────────────────────────────────────────
  const errors: string[] = [];
  const warnings: string[] = [];
  const skipped: string[] = [];
  const accepted: { entry: Entry; path: string }[] = [];
  const seen = 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) {
      errors.push(`Archive contains a symlink, which is not allowed: ${rawName}`);
      continue;
    }

    const safePath = safeRelativePath(
      rawName.endsWith("/") ? rawName.slice(0, -1) : rawName,
    );
    if (safePath === null) {
      errors.push(`Unsafe path in archive: ${rawName}`);
      continue;
    }

    if (rawName.endsWith("/") || fileType === S_IFDIR) continue;

    // mode 0 = zipped by a tool that records no unix mode (most Windows
    // zippers); only reject a mode that is present and says "not a regular file".
    if (mode !== 0 && fileType !== 0 && fileType !== S_IFREG) {
      errors.push(`Archive entry is neither a file nor a directory: ${rawName}`);
      continue;
    }

    const lower = safePath.toLowerCase();

    // macOS zips carry these; they are noise, not an error.
    if (lower.startsWith("__macosx/") || lower.endsWith(".ds_store")) continue;

    if (seen.has(lower)) {
      errors.push(`Duplicate path in archive: ${safePath}`);
      continue;
    }
    seen.add(lower);

    if (!ACCEPTED.has(extensionOf(lower))) {
      skipped.push(safePath);
      continue;
    }

    declaredBytes += entry.uncompressedSize;
    accepted.push({ entry, path: lower });
  }

  if (accepted.length > EDM_BUNDLE_CONTRACT.MAX_FILE_COUNT) {
    errors.push(
      `Archive contains ${accepted.length} usable files, over the limit of ${EDM_BUNDLE_CONTRACT.MAX_FILE_COUNT}.`,
    );
  }
  if (declaredBytes > EDM_BUNDLE_CONTRACT.MAX_TOTAL_UNZIPPED_BYTES) {
    errors.push(
      `Archive declares ${declaredBytes} bytes unzipped, over the limit of ${EDM_BUNDLE_CONTRACT.MAX_TOTAL_UNZIPPED_BYTES}.`,
    );
  }
  if (!accepted.some((f) => isHtmlPath(f.path))) {
    errors.push("Archive contains no .html file.");
  }

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

  if (skipped.length > 0) {
    warnings.push(
      `Ignored ${skipped.length} file(s) that are not html or images: ${skipped.slice(0, 10).join(", ")}${skipped.length > 10 ? "…" : ""}`,
    );
  }

  // ── pass 2: inflate against a running budget ─────────────────────────────
  const files: ArchiveFile[] = [];
  let actualBytes = 0;

  try {
    for (const file of accepted) {
      const data = await readEntry(zipfile, file.entry);
      actualBytes += data.length;

      if (actualBytes > EDM_BUNDLE_CONTRACT.MAX_TOTAL_UNZIPPED_BYTES) {
        zipfile.close();
        return {
          ok: false,
          errors: [
            `Archive inflates past the ${EDM_BUNDLE_CONTRACT.MAX_TOTAL_UNZIPPED_BYTES} byte limit — its declared sizes were understated.`,
          ],
        };
      }

      if (isHtmlPath(file.path) && data.length > EDM_BUNDLE_CONTRACT.MAX_HTML_BYTES) {
        zipfile.close();
        return {
          ok: false,
          errors: [
            `${file.path} is larger than the ${EDM_BUNDLE_CONTRACT.MAX_HTML_BYTES} byte html limit.`,
          ],
        };
      }

      files.push({ path: file.path, buffer: data });
    }
  } catch (err) {
    zipfile.close();
    return { ok: false, errors: [`Failed reading archive: ${(err as Error).message}`] };
  }

  zipfile.close();
  return { ok: true, files, warnings };
};

const isHtmlPath = (path: string): boolean =>
  ["html", "htm"].includes(extensionOf(path));

const dirOf = (path: string): string => {
  const slash = path.lastIndexOf("/");
  return slash === -1 ? "" : path.slice(0, slash);
};

const toImage = (file: ArchiveFile): EDMBundleImage => {
  const ext = extensionOf(file.path);
  return {
    path: file.path,
    buffer: file.buffer,
    contentType: IMAGE_CONTENT_TYPES[ext] ?? "application/octet-stream",
  };
};

/** Strip separators and extension into something usable as a template name. */
const nameFromPath = (path: string): string => {
  const base = path.split("/").pop() ?? path;
  const stem = base.replace(/\.(html?|HTML?)$/i, "");
  // A folder full of "index.html" would otherwise produce N templates all
  // called "index", so fall back to the containing directory's name.
  const chosen = /^index$/i.test(stem) ? (dirOf(path).split("/").pop() ?? stem) : stem;
  return chosen.replace(/[-_]+/g, " ").trim() || "Untitled";
};

/**
 * Read an archive that may hold many EDMs, one per directory:
 *
 *   campaign/welcome/index.html   + campaign/welcome/images/…
 *   campaign/reminder/index.html  + campaign/reminder/images/…
 *   images/                       ← shared, offered to every entry
 *
 * Each html gets the images under its OWN directory, plus anything in a
 * top-level shared images folder. Scoping this way is what lets two EDMs both
 * reference "images/hero.png" and get different files — a flat bundle-wide
 * index would silently give them the same one.
 */
export const readEDMBundleEntries = async (
  buffer: Buffer,
): Promise<EDMBundleEntriesResult> => {
  const archive = await readArchive(buffer);
  if (!archive.ok) return archive;

  const htmlFiles = archive.files.filter((f) => isHtmlPath(f.path));
  const imageFiles = archive.files.filter((f) => !isHtmlPath(f.path));
  const warnings = [...archive.warnings];

  // Images that sit at the archive root (or under a root-level images/ folder)
  // and are not inside any html's directory — the common "one shared logo".
  const htmlDirs = new Set(htmlFiles.map((f) => dirOf(f.path)));
  const sharedImages = imageFiles.filter((img) => {
    const d = dirOf(img.path);
    // Shared when no html directory is an ancestor of this image.
    return ![...htmlDirs].some((hd) => hd !== "" && (d === hd || d.startsWith(`${hd}/`)));
  });

  const entries: EDMBundleEntry[] = htmlFiles.map((htmlFile) => {
    const dir = dirOf(htmlFile.path);

    const own = imageFiles.filter((img) => {
      const d = dirOf(img.path);
      return dir === "" ? true : d === dir || d.startsWith(`${dir}/`);
    });

    // Own images win over shared on a path collision.
    const ownPaths = new Set(own.map((i) => i.path));
    const scoped = [...own, ...sharedImages.filter((s) => !ownPaths.has(s.path))];

    return {
      htmlPath: htmlFile.path,
      dir,
      folderPath: dir === "" ? [] : dir.split("/"),
      suggestedName: nameFromPath(htmlFile.path),
      html: htmlFile.buffer.toString("utf-8"),
      images: scoped.map(toImage),
    };
  });

  if (entries.length === 0) {
    return { ok: false, errors: ["Archive contains no .html file."] };
  }

  return { ok: true, entries, warnings };
};

/**
 * Single-template read — the preview path.
 *
 * Prefers a root-level index.html, then the shallowest html, because a zip
 * often also carries a preview or a fragment and picking the deepest one
 * silently would build the template from the wrong document.
 */
export const readEDMBundle = async (buffer: Buffer): Promise<EDMBundleResult> => {
  const result = await readEDMBundleEntries(buffer);
  if (!result.ok) return result;

  const chosen =
    result.entries.find((e) => e.htmlPath === "index.html") ??
    [...result.entries].sort(
      (a, b) =>
        a.htmlPath.split("/").length - b.htmlPath.split("/").length ||
        a.htmlPath.localeCompare(b.htmlPath),
    )[0]!;

  const warnings = [...result.warnings];
  if (result.entries.length > 1) {
    warnings.push(
      `Archive has ${result.entries.length} html files; used "${chosen.htmlPath}". Use batch import to bring in all of them.`,
    );
  }

  return {
    ok: true,
    bundle: {
      htmlPath: chosen.htmlPath,
      html: chosen.html,
      images: chosen.images,
    },
    warnings,
  };
};
