import { Storage } from "@google-cloud/storage";
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { logError } from "../logger";
import { env } from "../env";

/**
 * Object storage for EDM templates.
 *
 * Two storage classes with deliberately opposite policies, because they are
 * read by completely different clients:
 *
 *   TEMPLATES (private)  — read only by our own render path (the SMTP worker
 *                          via the internal API, and console preview/test-send).
 *                          Never public, never cached by a CDN.
 *
 *   ASSETS (public)      — images referenced by <img src> in the sent email.
 *                          Fetched anonymously by Gmail/Outlook/Apple Mail from
 *                          the *recipient's* device, with no session, no auth,
 *                          and no ability to retry a 403. These MUST be public
 *                          and long-cached.
 *
 * Mixing the two up is the failure mode to avoid: a private image is a broken
 * image in every inbox, and a public template leaks unsent marketing copy.
 *
 * Path convention (layout v2):
 *   {TEMPLATE_ROOT}/templates/{templateId}/versions/{versionId}/template.json
 *   {TEMPLATE_ROOT}/templates/{templateId}/active/template.json   ← worker reads
 *   {ASSET_ROOT}/{folderPath}/images/{sha256:16}-{filename}       ← mirrors the tree
 *
 * Why the two halves are laid out differently, since it looks inconsistent:
 *
 *   Assets mirror the console's folder tree because their URLs are public,
 *   permanent, and read by humans — a broken one is a broken image in an inbox,
 *   and being able to say "this folder's images live here" is worth a lot.
 *
 *   Template objects are addressed by template id ALONE, and deliberately do not
 *   include the folder path. The SMTP worker's last-resort fallback is to read
 *   GCS directly when the internal API is unreachable, and all it has at that
 *   point is a template id — no database to look up which folder the template is
 *   in. Putting the folder in the key would make that fallback impossible, and
 *   would also mean renaming a folder had to rewrite live objects. The folder is
 *   recorded as object metadata instead, so browsing the bucket still tells you
 *   where a template belongs.
 */

export const EDM_TEMPLATE_ROOT = (
  process.env.GOOGLE_CLOUD_STORAGE_EDM_PREFIX || "console2-edm"
).replace(/^\/+|\/+$/g, "");

export const EDM_ASSET_ROOT = (
  process.env.GOOGLE_CLOUD_STORAGE_EDM_ASSET_PREFIX || "console2-edm-assets"
).replace(/^\/+|\/+$/g, "");

/**
 * Namespace holding template objects.
 *
 * Without it, a template id sits directly under the root and collides with any
 * other kind of object we might later keep there — the root would have no free
 * names left, because a template id can be anything.
 */
export const EDM_TEMPLATE_SUBROOT = "templates";

/** Everything belonging to one template. Used by delete, so it must cover both
 *  the version prefixes and the active pointer. */
export const buildTemplatePrefix = (templateId: string): string =>
  `${EDM_TEMPLATE_ROOT}/${EDM_TEMPLATE_SUBROOT}/${templateId}`;

/**
 * Where a template's objects were before layout v2.
 *
 * Kept because reads fall back to it: code deploys before the migration script
 * runs, and in that window every send would fail if the old key were forgotten.
 * Only the migration script and the read fallback should use this.
 */
export const buildLegacyTemplatePrefix = (templateId: string): string =>
  `${EDM_TEMPLATE_ROOT}/${templateId}`;

export const buildLegacyActiveObjectPath = (templateId: string): string =>
  `${buildLegacyTemplatePrefix(templateId)}/active/template.json`;

/**
 * Keyed by the version's UUID, not by a version_number, for the same reason the
 * dashboard bundles are: two concurrent saves would compute the same number
 * before either committed and would overwrite each other's bytes.
 */
export const buildVersionObjectPath = (
  templateId: string,
  versionId: string,
): string =>
  `${buildTemplatePrefix(templateId)}/versions/${versionId}/template.json`;

/**
 * The single object the SMTP worker fetches. Published by copying a version
 * object over it, so a send never has to resolve "which version is active"
 * against the database — one GET and it has everything it needs.
 */
export const buildActiveObjectPath = (templateId: string): string =>
  `${buildTemplatePrefix(templateId)}/active/template.json`;

/**
 * What one stored template version contains. This is the exact payload the
 * worker renders from, so `subject` has to live here: nothing upstream of the
 * worker supplies one (all 46 publish() call sites in the main core pass no
 * subject), and it is itself a Handlebars string.
 */
export type EDMTemplateObject = {
  subject: string;
  html: string;
  plain: string;
  generatePlain: boolean;
};

// Lazily constructed so importing this module never forces the GCS env vars to
// exist — env.GetString logs fatal on a miss, and the pure validator/render
// paths have no business needing credentials.
let cached: { client: Storage; bucket: string } | null = null;

const CONFIG_PREFIX = "EDM storage is not configured";

const getClient = () => {
  if (cached) return cached;

  const rawCredentials = env.GetString("GOOGLE_SERVICE_ACCOUNT_CREDENTIALS");
  let credentials: Record<string, string>;
  try {
    credentials = JSON.parse(rawCredentials) as Record<string, string>;
  } catch {
    throw new Error(
      `${CONFIG_PREFIX}: GOOGLE_SERVICE_ACCOUNT_CREDENTIALS is not valid JSON. ` +
        `It must be the whole service-account key file on ONE line — a pretty-printed ` +
        `paste only captures the opening "{". This is a server configuration problem, ` +
        `not a problem with the uploaded template.`,
    );
  }
  if (!credentials.client_email || !credentials.private_key) {
    throw new Error(
      `${CONFIG_PREFIX}: GOOGLE_SERVICE_ACCOUNT_CREDENTIALS is missing client_email / private_key. ` +
        `This is a server configuration problem, not a problem with the uploaded template.`,
    );
  }

  const bucket = env.GetString("GOOGLE_CLOUD_STORAGE_BUCKET_NAME");
  if (!bucket || bucket === "your-bucket-name") {
    throw new Error(
      `${CONFIG_PREFIX}: GOOGLE_CLOUD_STORAGE_BUCKET_NAME is unset or still the placeholder. ` +
        `This is a server configuration problem, not a problem with the uploaded template.`,
    );
  }

  cached = {
    client: new Storage({
      credentials,
      projectId: env.GetString("GOOGLE_CLOUD_PROJECT_ID"),
    }),
    bucket,
  };
  return cached;
};

/**
 * Strip an uploaded filename down to something safe to concatenate into an
 * object path. The content hash carries uniqueness, so this only has to be
 * legible and free of traversal — collisions after sanitising are harmless.
 */
const safeAssetName = (filename: string): string => {
  const base = filename.split(/[\\/]/).pop() ?? "asset";
  const cleaned = base
    .toLowerCase()
    // Any run of unsafe characters collapses to a single separator, so
    // "hero  image!!.png" does not become "hero--image--.png".
    .replace(/[^a-z0-9._-]+/g, "-")
    // A separator sitting against the dot is just noise: "Hero Image!.png"
    // should read "hero-image.png", not "hero-image-.png".
    .replace(/-*\.-*/g, ".")
    .replace(/^[-.]+|[-.]+$/g, "")
    .slice(0, 80);
  return cleaned || "asset";
};

/**
 * Google Cloud Storage driver — the production one.
 */
const gcsDriver = () => {
  // ── templates (private) ───────────────────────────────────────────────────

  /**
   * Write one immutable version object. resumable:false — templates are small
   * (a few hundred KB at most) and a single-shot upload avoids the extra
   * session round-trip.
   */
  const putTemplateVersion = async (
    templateId: string,
    versionId: string,
    payload: EDMTemplateObject,
    opts: { folderPath?: string; name?: string } = {},
  ): Promise<string> => {
    const { client, bucket } = getClient();
    const fullPath = buildVersionObjectPath(templateId, versionId);
    try {
      await client
        .bucket(bucket)
        .file(fullPath)
        .save(JSON.stringify(payload), {
          resumable: false,
          metadata: {
            contentType: "application/json; charset=utf-8",
            // Immutable once written (a re-save is a new version), but private:
            // only our render path may read it.
            cacheControl: "private, max-age=0, no-transform",
            /**
             * Where this template sits in the console, for whoever is staring at
             * the bucket. It is a copy of what the database says, so it is a
             * hint and not a source of truth — a folder rename does not rewrite
             * it, by design.
             */
            metadata: {
              edmFolderPath: opts.folderPath ?? "",
              edmName: opts.name ?? "",
            },
          },
        });
      return fullPath;
    } catch (err) {
      logError(err, `Failed uploading EDM template version ${fullPath}`);
      throw new Error(
        `Failed uploading EDM template version "${fullPath}": ${(err as Error).message}`,
      );
    }
  };

  /**
   * Publish: make a version the one the worker will render.
   *
   * A server-side copy rather than a re-upload, so the bytes are provably
   * identical to the version object and publishing costs no egress.
   */
  const publishVersion = async (
    templateId: string,
    versionId: string,
  ): Promise<string> => {
    const { client, bucket } = getClient();
    const from = buildVersionObjectPath(templateId, versionId);
    const to = buildActiveObjectPath(templateId);
    try {
      await client.bucket(bucket).file(from).copy(client.bucket(bucket).file(to));
      return to;
    } catch (err) {
      logError(err, `Failed publishing EDM template ${templateId}`);
      throw new Error(
        `Failed publishing EDM template "${templateId}": ${(err as Error).message}`,
      );
    }
  };

  /** Read a stored template object. null when the object does not exist. */
  const getTemplateObject = async (
    fullPath: string,
  ): Promise<EDMTemplateObject | null> => {
    const { client, bucket } = getClient();
    try {
      const file = client.bucket(bucket).file(fullPath);
      const [exists] = await file.exists();
      if (!exists) return null;
      const [buffer] = await file.download();
      return JSON.parse(buffer.toString("utf-8")) as EDMTemplateObject;
    } catch (err) {
      logError(err, `Failed reading EDM template object ${fullPath}`);
      throw new Error(
        `Failed reading EDM template object "${fullPath}": ${(err as Error).message}`,
      );
    }
  };

  /**
   * The active object, checking the pre-v2 key if the new one is absent.
   *
   * The fallback is what makes the layout change safe to deploy on its own: for
   * templates not yet moved by the migration script, the old key is still the
   * only one that exists, and a send that cannot find its template is a send
   * that fails.
   */
  const getActiveTemplate = async (templateId: string) => {
    const current = await getTemplateObject(buildActiveObjectPath(templateId));
    if (current) return current;
    return getTemplateObject(buildLegacyActiveObjectPath(templateId));
  };

  const getVersionTemplate = (templateId: string, versionId: string) =>
    getTemplateObject(buildVersionObjectPath(templateId, versionId));

  /**
   * Remove every object for a template. Deletes template HTML only — assets are
   * never touched, see putAsset.
   */
  const deleteTemplate = async (templateId: string): Promise<void> => {
    const { client, bucket } = getClient();
    // Both layouts: a template deleted before the migration script has reached
    // it would otherwise leave its old objects behind forever, and nothing would
    // ever look at that prefix again to notice.
    const prefixes = [`${buildTemplatePrefix(templateId)}/`];
    // An id of "templates" would make the legacy prefix equal to the whole v2
    // namespace, and deleting one template would wipe every template.
    if (templateId !== EDM_TEMPLATE_SUBROOT) {
      prefixes.push(`${buildLegacyTemplatePrefix(templateId)}/`);
    }
    for (const prefix of prefixes) {
      try {
        await client.bucket(bucket).deleteFiles({ prefix, force: true });
      } catch (err) {
        logError(err, `Failed deleting EDM template objects under ${prefix}`);
        throw new Error(
          `Failed deleting EDM template objects under "${prefix}": ${(err as Error).message}`,
        );
      }
    }
  };

  // ── assets (public, immutable, never deleted) ─────────────────────────────

  /**
   * Store an image and return its public URL.
   *
   * Content-addressed, and that is load-bearing rather than a nicety:
   *
   *   1. A sent email is permanent. Someone opens a two-year-old EDM and the
   *      browser fetches this exact URL. If the bytes behind it ever changed or
   *      disappeared, we would retroactively break mail already delivered. A
   *      hash-named object can never be legitimately overwritten, so editing a
   *      template mints a NEW object and leaves the old one serving forever.
   *
   *   2. There is deliberately no delete for assets anywhere in this module —
   *      not on template edit, not on template delete. Orphaned images are the
   *      correct outcome; storage is far cheaper than a broken inbox. Do not
   *      add a cleanup job here by analogy with dashboard-storage's
   *      deleteByPrefix: the dashboard lifecycle is the opposite of this one.
   *
   *   3. Dedup falls out for free — one shared logo across 500 EDMs is one
   *      object.
   */
  const putAsset = async (
    buffer: Buffer,
    filename: string,
    contentType: string,
    opts: { folderPath?: string } = {},
  ): Promise<{ path: string; url: string; sha256: string }> => {
    const { client, bucket } = getClient();
    const sha256 = createHash("sha256").update(buffer).digest("hex");
    const fullPath = buildAssetPath(filename, sha256, opts.folderPath);

    try {
      const file = client.bucket(bucket).file(fullPath);

      // Content-addressed, so an existing object is byte-identical by
      // definition. Skipping the write keeps re-uploads of a shared logo cheap
      // and keeps the object's original creation time intact.
      const [exists] = await file.exists();
      if (!exists) {
        await file.save(buffer, {
          resumable: false,
          metadata: {
            contentType,
            // One year, immutable — the URL can never point at different bytes.
            cacheControl: "public, max-age=31536000, immutable",
          },
        });
      }

      return { path: fullPath, url: assetUrl(fullPath), sha256 };
    } catch (err) {
      logError(err, `Failed uploading EDM asset ${fullPath}`);
      throw new Error(
        `Failed uploading EDM asset "${fullPath}": ${(err as Error).message}`,
      );
    }
  };

  /**
   * Delete every asset stored under one folder's prefix.
   *
   * Irreversible, and it reaches beyond this console: a URL under this prefix
   * may already be embedded in mail that was delivered months ago, and that
   * mail cannot be recalled or repaired. Only ever called from an explicitly
   * confirmed cascade delete.
   */
  const deleteAssetsByFolder = async (
    folderPath: string,
    opts: { imagesOnly?: boolean } = {},
  ): Promise<number> => {
    const { client, bucket } = getClient();
    const prefix = opts.imagesOnly
      ? `${buildFolderImagePrefix(folderPath)}/`
      : `${EDM_ASSET_ROOT}/${normaliseFolderPath(folderPath)}/`;
    // Refuse a prefix that would sweep the whole asset root.
    if (prefix === `${EDM_ASSET_ROOT}/` || prefix.includes("//")) {
      throw new Error(`Refusing to delete assets with an unsafe prefix: "${prefix}"`);
    }
    try {
      const [files] = await client.bucket(bucket).getFiles({ prefix });
      await client.bucket(bucket).deleteFiles({ prefix, force: true });
      return files.length;
    } catch (err) {
      logError(err, `Failed deleting EDM assets under ${prefix}`);
      throw new Error(
        `Failed deleting EDM assets under "${prefix}": ${(err as Error).message}`,
      );
    }
  };

  /**
   * Every asset object stored under a folder's prefix.
   *
   * Needed because the console otherwise derives a folder's images from its
   * templates' asset manifests, which only ever knows about images a template
   * references. An image uploaded on its own — before the html that will use it
   * exists — is in the bucket and in no manifest, so it was invisible.
   */
  const listAssetsByFolder = async (
    folderPath: string,
  ): Promise<{ path: string; url: string; size: number; updated: string | null }[]> => {
    const { client, bucket } = getClient();
    const normalised = normaliseFolderPath(folderPath);
    const prefix = normalised ? `${EDM_ASSET_ROOT}/${normalised}/` : `${EDM_ASSET_ROOT}/`;
    try {
      const [files] = await client.bucket(bucket).getFiles({ prefix });
      return files
        // One level only. Under a folder that means its own images/ plus
        // anything uploaded before images/ became the convention; at the root
        // it must not sweep in every folder's images.
        .filter((f) => {
          const rest = f.name.slice(prefix.length);
          if (!normalised) return !rest.includes("/");
          const parts = rest.split("/");
          return parts.length === 1 || (parts.length === 2 && parts[0] === EDM_IMAGE_SUBFOLDER);
        })
        .map((f) => ({
          path: f.name,
          url: assetUrl(f.name),
          size: Number(f.metadata?.size ?? 0),
          updated: (f.metadata?.updated as string | undefined) ?? null,
        }));
    } catch (err) {
      logError(err, `Failed listing EDM assets under ${prefix}`);
      return [];
    }
  };

  /**
   * Delete one asset object.
   *
   * Irreversible and it reaches past this console: the URL may already be
   * embedded in mail delivered months ago, which cannot be recalled or
   * repaired. The prefix guard stops a malformed path from targeting anything
   * outside the asset root.
   */
  const deleteAsset = async (fullPath: string): Promise<void> => {
    const { client, bucket } = getClient();
    if (!fullPath.startsWith(`${EDM_ASSET_ROOT}/`) || fullPath.includes("..")) {
      throw new Error(`Refusing to delete outside the asset root: "${fullPath}"`);
    }
    try {
      await client.bucket(bucket).file(fullPath).delete({ ignoreNotFound: true });
    } catch (err) {
      logError(err, `Failed deleting EDM asset ${fullPath}`);
      throw new Error(`Failed deleting EDM asset "${fullPath}": ${(err as Error).message}`);
    }
  };

  const assetExists = async (fullPath: string): Promise<boolean> => {
    const { client, bucket } = getClient();
    try {
      const [exists] = await client.bucket(bucket).file(fullPath).exists();
      return exists;
    } catch (err) {
      logError(err, `Failed checking EDM asset ${fullPath}`);
      throw new Error(
        `Failed checking EDM asset "${fullPath}": ${(err as Error).message}`,
      );
    }
  };

  return {
    putTemplateVersion,
    publishVersion,
    getTemplateObject,
    getActiveTemplate,
    getVersionTemplate,
    deleteTemplate,
    putAsset,
    assetExists,
    listAssetsByFolder,
    deleteAsset,
    deleteAssetsByFolder,
  };
};

/**
 * Filesystem driver — for local development and the smoke test.
 *
 * Exists because every write path in this feature (create, publish, asset
 * upload) hard-depends on a bucket, so without one you cannot exercise a single
 * end-to-end flow locally: no create, no preview, no test send. That made the
 * whole surface untestable on a laptop, which is the wrong trade for a
 * migration this size.
 *
 * Object paths are identical to the GCS driver's, just rooted at a directory,
 * so what you exercise locally is the same path logic that runs in production.
 *
 * NOT for production use — there is no durability, no sharing between
 * instances, and assets are not reachable by a mail client. Selected only by an
 * explicit opt-in env var.
 */
const localDriver = () => {
  const root = path.resolve(
    process.env.EDM_LOCAL_STORAGE_DIR || ".edm-storage",
  );

  const abs = (objectPath: string) => path.join(root, objectPath);

  const write = async (objectPath: string, data: Buffer) => {
    const target = abs(objectPath);
    await fs.mkdir(path.dirname(target), { recursive: true });
    await fs.writeFile(target, data);
    return objectPath;
  };

  const read = async (objectPath: string): Promise<Buffer | null> => {
    try {
      return await fs.readFile(abs(objectPath));
    } catch (err) {
      if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
      throw err;
    }
  };

  const putTemplateVersion = async (
    templateId: string,
    versionId: string,
    payload: EDMTemplateObject,
  ): Promise<string> =>
    write(
      buildVersionObjectPath(templateId, versionId),
      Buffer.from(JSON.stringify(payload), "utf-8"),
    );

  const publishVersion = async (
    templateId: string,
    versionId: string,
  ): Promise<string> => {
    const source = await read(buildVersionObjectPath(templateId, versionId));
    if (!source) {
      throw new Error(
        `Cannot publish ${templateId}: version object ${versionId} does not exist`,
      );
    }
    return write(buildActiveObjectPath(templateId), source);
  };

  const getTemplateObject = async (
    fullPath: string,
  ): Promise<EDMTemplateObject | null> => {
    const buffer = await read(fullPath);
    return buffer ? (JSON.parse(buffer.toString("utf-8")) as EDMTemplateObject) : null;
  };

  const putAsset = async (
    buffer: Buffer,
    filename: string,
    _contentType: string,
    opts: { folderPath?: string } = {},
  ): Promise<{ path: string; url: string; sha256: string }> => {
    const sha256 = createHash("sha256").update(buffer).digest("hex");
    const fullPath = buildAssetPath(filename, sha256, opts.folderPath);
    // Content-addressed, so an existing file is byte-identical by definition.
    if (!(await read(fullPath))) await write(fullPath, buffer);
    return { path: fullPath, url: assetUrl(fullPath), sha256 };
  };

  return {
    putTemplateVersion,
    publishVersion,
    getTemplateObject,
    getActiveTemplate: (templateId: string) =>
      getTemplateObject(buildActiveObjectPath(templateId)),
    getVersionTemplate: (templateId: string, versionId: string) =>
      getTemplateObject(buildVersionObjectPath(templateId, versionId)),
    deleteTemplate: async (templateId: string): Promise<void> => {
      await fs.rm(abs(buildTemplatePrefix(templateId)), {
        recursive: true,
        force: true,
      });
    },
    putAsset,
    assetExists: async (fullPath: string): Promise<boolean> =>
      (await read(fullPath)) !== null,
    deleteAsset: async (fullPath: string): Promise<void> => {
      if (!fullPath.startsWith(`${EDM_ASSET_ROOT}/`) || fullPath.includes("..")) {
        throw new Error(`Refusing to delete outside the asset root: "${fullPath}"`);
      }
      await fs.rm(abs(fullPath), { force: true });
    },
    listAssetsByFolder: async (folderPath: string) => {
      const normalised = normaliseFolderPath(folderPath);
      const base = `${EDM_ASSET_ROOT}${normalised ? `/${normalised}` : ""}`;
      // The folder itself and its images/ subfolder — the latter is where new
      // uploads go, the former holds anything from before that convention.
      const dirs = [base, `${base}/${EDM_IMAGE_SUBFOLDER}`];
      const out: { path: string; url: string; size: number; updated: string | null }[] = [];

      for (const dir of dirs) {
        try {
          for (const entry of await fs.readdir(abs(dir), { withFileTypes: true })) {
            if (!entry.isFile()) continue;
            const rel = `${dir}/${entry.name}`;
            const stat = await fs.stat(abs(rel));
            out.push({
              path: rel,
              url: assetUrl(rel),
              size: stat.size,
              updated: stat.mtime.toISOString(),
            });
          }
        } catch {
          // Missing directory just means nothing uploaded there yet.
        }
      }
      return out;
    },
    deleteAssetsByFolder: async (
      folderPath: string,
      opts: { imagesOnly?: boolean } = {},
    ): Promise<number> => {
      const prefix = normaliseFolderPath(folderPath);
      if (!prefix) throw new Error("Refusing to delete assets with an empty prefix");
      const target = opts.imagesOnly
        ? buildFolderImagePrefix(folderPath)
        : `${EDM_ASSET_ROOT}/${prefix}`;
      await fs.rm(abs(target), { recursive: true, force: true });
      return 0;
    },
  };
};

/**
 * EDM_STORAGE_DRIVER=local swaps GCS for the filesystem. Defaults to gcs, so
 * production is unaffected by the existence of the local driver.
 */
/**
 * Folder segment of an asset path.
 *
 * Each segment is sanitised the same way a filename is, so a folder called
 * "Campaigns / 2026" cannot inject extra path levels or escape the asset root.
 */
export const normaliseFolderPath = (folderPath: string): string =>
  folderPath
    .split("/")
    .map((seg) => safeAssetName(seg))
    .filter((seg) => seg && seg !== "asset")
    .slice(0, 6) // depth cap; a pathological tree must not produce absurd keys
    .join("/");

/**
 * Where an uploaded image is stored.
 *
 * Grouped under the owning folder so the bucket mirrors the console's tree and
 * a folder's images can be deleted as a unit.
 *
 * The short content hash stays in the filename on purpose. Without it, two
 * different files both called hero.png in the same folder would silently
 * overwrite each other — and the loser is a live image in an email. With it,
 * re-uploading identical bytes still lands on the same key, so a genuine
 * re-upload is free rather than a duplicate.
 *
 * NOTE: this path records where an image was uploaded, not where its folder is
 * now. Renaming or moving a folder deliberately does NOT move objects — the
 * URLs are already embedded in delivered mail and moving them would break it.
 */
/**
 * Subfolder every image lands in, inside its owning folder's prefix.
 *
 * Object storage has no directories — a "folder" is just a shared key prefix,
 * so this is created implicitly by the first upload and needs no setup step.
 * Having it be a real segment rather than an implied one means a folder's
 * images can be listed and removed as a unit without touching anything else
 * stored alongside them.
 */
export const EDM_IMAGE_SUBFOLDER = "images";

/** The prefix holding one folder's images. */
export const buildFolderImagePrefix = (folderPath: string): string => {
  const folder = normaliseFolderPath(folderPath);
  return folder
    ? `${EDM_ASSET_ROOT}/${folder}/${EDM_IMAGE_SUBFOLDER}`
    : `${EDM_ASSET_ROOT}/${EDM_IMAGE_SUBFOLDER}`;
};

export const buildAssetPath = (
  filename: string,
  sha256: string,
  folderPath?: string,
): string => {
  const name = `${sha256.slice(0, 16)}-${safeAssetName(filename)}`;
  return `${buildFolderImagePrefix(folderPath ?? "")}/${name}`;
};

export const EDMStorage = (): TEDMStorage =>
  (process.env.EDM_STORAGE_DRIVER ?? "").trim().toLowerCase() === "local"
    ? localDriver()
    : gcsDriver();

/**
 * Public URL for a stored asset.
 *
 * Prefers an explicit CDN base so the emitted <img src> is not tied to
 * storage.googleapis.com — once a URL ships in an email it is permanent, so the
 * indirection is worth having from day one even if it starts out unset.
 */
export const assetUrl = (fullPath: string): string => {
  const configured = process.env.EDM_ASSET_BASE_URL?.trim().replace(/\/+$/, "");

  if (configured) {
    /**
     * A base without a scheme is silently fatal.
     *
     * "storage.googleapis.com/x.png" in an <img src> is a RELATIVE path — the
     * mail client resolves it against its own origin and the image is broken in
     * every inbox, while the console preview may still render it because the
     * page has an origin to resolve against. It looks fine locally and fails
     * everywhere that matters, so it is repaired here rather than trusted.
     */
    const base = /^https?:\/\//i.test(configured) ? configured : `https://${configured}`;
    if (base !== configured) {
      logError(
        new Error("EDM_ASSET_BASE_URL has no scheme"),
        `EDM_ASSET_BASE_URL="${configured}" is missing http(s):// — assuming https. ` +
          `A scheme-less value produces relative image URLs that break in every mail client.`,
      );
    }
    return `${base}/${fullPath}`;
  }

  // Under the local driver there is no bucket to name, and asking env for one
  // would log fatal. A file:// URL is honest about what it is: readable by the
  // developer, and deliberately NOT something that would render in an inbox.
  if ((process.env.EDM_STORAGE_DRIVER ?? "").trim().toLowerCase() === "local") {
    const root = path.resolve(process.env.EDM_LOCAL_STORAGE_DIR || ".edm-storage");
    return `file://${path.join(root, fullPath)}`;
  }

  const bucket = env.GetString("GOOGLE_CLOUD_STORAGE_BUCKET_NAME");
  return `https://storage.googleapis.com/${bucket}/${fullPath}`;
};

export type TEDMStorage = ReturnType<typeof gcsDriver>;
