import { Storage } from "@google-cloud/storage";
import { logError } from "../logger";
import { env } from "../env";

/**
 * Private object storage for approval-gated export workbooks.
 *
 * Modelled on lib/storage/dashboard-storage.ts and deliberately NOT built on
 * lib/storage/gcs.ts: that module writes `cacheControl: "public, max-age=33650"`
 * and hands out `.publicUrl()`. These workbooks are member data behind an approval
 * gate — a publicly cacheable URL would defeat the entire feature. Nothing here
 * makes an object public: no makePublic(), no predefinedAcl, no signed or public
 * URLs. Access control is the bucket's (uniform bucket-level access) plus the
 * console session on our own download route.
 *
 * Path convention:
 *   {root}/{YYYY}/{MM}/{objectId}/{filename}.xlsx
 *
 * Dated folders because the interesting question about an old export is "what went
 * out that month", and because it keeps any one folder from growing unboundedly.
 */

/**
 * Root folder for every object this module writes.
 *
 * The bucket is shared with other systems, so exports are namespaced under a single
 * top-level folder rather than scattered at the bucket root — the same reasoning as
 * the dashboard prefix. Overridable per environment for the case where one bucket
 * serves both dev and prod.
 */
export const EXPORT_ROOT_PREFIX = (
  process.env.GOOGLE_CLOUD_STORAGE_EXPORTS_PREFIX || "console2-exports"
).replace(/^\/+|\/+$/g, "");

const XLSX_MIME =
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";

/**
 * Where one workbook lives.
 *
 * `objectId` is minted by the caller before the row exists, so the upload can happen
 * before the insert — see the create path. It is not the request's primary key: that
 * is allocated by the database at insert time, which is too late to name an object
 * we want written first.
 */
export const buildExportObjectPath = (
  objectId: string,
  filename: string,
  now: Date,
): string => {
  const year = now.getUTCFullYear();
  const month = String(now.getUTCMonth() + 1).padStart(2, "0");
  // Defence in depth: the filename is already sanitised where the request is
  // created, but a path separator arriving here would silently reshape the tree.
  const safe = filename.replace(/[^\w.\-]+/g, "_");
  return `${EXPORT_ROOT_PREFIX}/${year}/${month}/${objectId}/${safe}`;
};

// Lazily constructed so importing this module never forces the GCS env vars to
// exist — env.GetString logs fatal on a miss.
let cached: { client: Storage; bucket: string } | null = null;

const CONFIG_PREFIX = "Export 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. This is a server ` +
        `configuration problem, not a problem with the export.`,
    );
  }
  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 export.`,
    );
  }

  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 export.`,
    );
  }

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

export const ExportStorage = () => {
  /**
   * Writes one workbook, private.
   *
   * resumable:false — exports are capped at MAX_FILE_BYTES, so a single-shot upload
   * avoids a session round-trip for no benefit.
   */
  const putWorkbook = async (
    objectPath: string,
    buffer: Buffer,
  ): Promise<string> => {
    const { client, bucket } = getClient();
    try {
      await client
        .bucket(bucket)
        .file(objectPath)
        .save(buffer, {
          resumable: false,
          metadata: {
            contentType: XLSX_MIME,
            // Written once and never rewritten, but private: only our own
            // authenticated route may hand these to a browser.
            cacheControl: "private, max-age=0, no-transform",
          },
        });
      return objectPath;
    } catch (err) {
      logError(err, `Failed uploading export workbook ${objectPath}`);
      throw new Error(
        `Failed storing the export file: ${(err as Error).message}`,
      );
    }
  };

  /**
   * Reads a workbook back into memory.
   *
   * Buffered rather than streamed: these are bounded by MAX_FILE_BYTES (12 MB), both
   * consumers need the whole thing anyway — one attaches it to an email, the other
   * sends it as a single response — and a buffer avoids having to bridge a Node
   * stream into a web stream on the way out.
   *
   * Returns null when the object is gone, which the callers report as a missing file
   * rather than treating as a server fault: the row can outlive the object if a
   * lifecycle rule or a manual cleanup removes it.
   */
  const getWorkbook = async (objectPath: string): Promise<Buffer | null> => {
    const { client, bucket } = getClient();
    try {
      const file = client.bucket(bucket).file(objectPath);
      const [exists] = await file.exists();
      if (!exists) return null;
      const [contents] = await file.download();
      return contents;
    } catch (err) {
      logError(err, `Failed reading export workbook ${objectPath}`);
      throw new Error(
        `Failed reading the export file: ${(err as Error).message}`,
      );
    }
  };

  /**
   * Best-effort cleanup for an upload whose database row never landed.
   *
   * Never throws: it is called on a path where something has already failed, and the
   * caller's error is the one worth reporting. A leaked object is inert.
   */
  const deleteWorkbook = async (objectPath: string): Promise<void> => {
    try {
      const { client, bucket } = getClient();
      await client
        .bucket(bucket)
        .file(objectPath)
        .delete({ ignoreNotFound: true });
    } catch (err) {
      logError(err, `Failed deleting export workbook ${objectPath}`);
    }
  };

  return { putWorkbook, getWorkbook, deleteWorkbook };
};

export type TExportStorage = ReturnType<typeof ExportStorage>;
