import { Storage } from "@google-cloud/storage";
import type { Readable } from "node:stream";
import { logError } from "../logger";
import { env } from "../env";

/**
 * Private object storage for static-dashboard bundles.
 *
 * Deliberately parallel to lib/storage/gcs.ts rather than built on it: that
 * wrapper hands out `.publicUrl()` and caches objects as `public`, which is
 * exactly wrong here. Dashboard bundles are only ever reachable through our own
 * authenticated proxy route, so nothing in this module makes an object public —
 * no makePublic(), no predefinedAcl, no signed or public URLs. Access control is
 * the bucket's (uniform bucket-level access) plus our route's.
 *
 * Path convention (everything lives under one root folder — see below):
 *   {root}/{dashboardId}/versions/{versionId}/{relativePath...}
 *   {root}/{dashboardId}/cover/{filename}
 */

/**
 * Root folder for every object this module writes.
 *
 * The bucket is shared with other systems, so dashboard content is namespaced
 * under a single top-level folder rather than scattered at the bucket root.
 * That keeps `deleteByPrefix` from ever being able to reach another system's
 * objects, and makes the bucket browsable.
 *
 * Overridable per environment for the case where one bucket serves both dev
 * and prod; defaults to the agreed folder name.
 */
export const DASHBOARD_ROOT_PREFIX = (
  process.env.GOOGLE_CLOUD_STORAGE_DASHBOARD_PREFIX || "console2-dashboard"
).replace(/^\/+|\/+$/g, "");

/** Everything belonging to one dashboard. Used by delete, so it must cover both
 *  the version prefixes and the cover. */
export const buildDashboardPrefix = (dashboardId: string): string =>
  `${DASHBOARD_ROOT_PREFIX}/${dashboardId}`;

/**
 * Keyed by the version's UUID, deliberately NOT by its version_number.
 *
 * version_number is allocated at insert time, so two concurrent uploads for the
 * same dashboard would compute the same number — and therefore the same prefix
 * — before either had committed a row. They would overwrite each other's files,
 * and a cleanup by one would delete the other's bytes. A locally-minted UUID is
 * never shared, so each upload provably owns its prefix. version_number remains
 * on the row as the display/rollback integer; it appears in no storage path.
 */
export const buildVersionPrefix = (
  dashboardId: string,
  versionId: string,
): string => `${buildDashboardPrefix(dashboardId)}/versions/${versionId}`;

export const buildCoverPrefix = (dashboardId: string): string =>
  `${buildDashboardPrefix(dashboardId)}/cover`;

// 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/test path
// has no business needing credentials.
let cached: { client: Storage; bucket: string } | null = null;

/**
 * Misconfiguration must not masquerade as a bad upload.
 *
 * A raw `JSON.parse` SyntaxError here used to bubble all the way to the wizard
 * as "Failed storing the dashboard bundle: Expected property name…", which told
 * the uploader to fix a bundle that was perfectly fine. These messages name the
 * env var and say plainly that it's a server-side problem.
 */
const CONFIG_PREFIX = "Dashboard 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 bundle.`,
    );
  }
  // `{}` parses cleanly but authenticates nothing, which fails much later with
  // an opaque 401/403 from Google. Catch the placeholder here instead.
  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 bundle.`,
    );
  }

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

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

export type DashboardStorageObject = {
  stream: Readable;
  contentType: string;
  size: number;
};

export const DashboardStorage = () => {
  /**
   * Store one file under `${prefix}/${relativePath}`, private.
   * resumable:false — bundle files are small and capped; a single-shot upload
   * avoids the extra session round-trip per file.
   */
  const putObject = async (
    prefix: string,
    relativePath: string,
    buffer: Buffer,
    contentType: string,
  ): Promise<string> => {
    const { client, bucket } = getClient();
    const fullPath = `${prefix}/${relativePath}`;
    try {
      await client
        .bucket(bucket)
        .file(fullPath)
        .save(buffer, {
          resumable: false,
          metadata: {
            contentType,
            // Immutable once written (a re-upload is a new version prefix), but
            // private: only our proxy may hand these to a browser.
            cacheControl: "private, max-age=0, no-transform",
          },
        });
      return fullPath;
    } catch (err) {
      logError(err, `Failed uploading dashboard object ${fullPath}`);
      throw new Error(
        `Failed uploading dashboard object "${fullPath}": ${(err as Error).message}`,
      );
    }
  };

  /** Read stream + metadata for the proxy route to pipe through. */
  const getObject = async (
    fullPath: string,
  ): Promise<DashboardStorageObject | null> => {
    const { client, bucket } = getClient();
    try {
      const file = client.bucket(bucket).file(fullPath);
      const [exists] = await file.exists();
      if (!exists) return null;
      const [metadata] = await file.getMetadata();
      return {
        stream: file.createReadStream(),
        contentType: metadata.contentType ?? "application/octet-stream",
        size: Number(metadata.size ?? 0),
      };
    } catch (err) {
      logError(err, `Failed reading dashboard object ${fullPath}`);
      throw new Error(
        `Failed reading dashboard object "${fullPath}": ${(err as Error).message}`,
      );
    }
  };

  const listObjects = async (prefix: string): Promise<string[]> => {
    const { client, bucket } = getClient();
    try {
      const [files] = await client.bucket(bucket).getFiles({ prefix });
      return files.map((f) => f.name);
    } catch (err) {
      logError(err, `Failed listing dashboard objects under ${prefix}`);
      throw new Error(
        `Failed listing dashboard objects under "${prefix}": ${(err as Error).message}`,
      );
    }
  };

  /** Remove every object under a version prefix — cleanup for failed uploads. */
  const deleteByPrefix = async (prefix: string): Promise<void> => {
    const { client, bucket } = getClient();
    try {
      await client.bucket(bucket).deleteFiles({ prefix, force: true });
    } catch (err) {
      logError(err, `Failed deleting dashboard objects under ${prefix}`);
      throw new Error(
        `Failed deleting dashboard objects under "${prefix}": ${(err as Error).message}`,
      );
    }
  };

  const objectExists = 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 dashboard object ${fullPath}`);
      throw new Error(
        `Failed checking dashboard object "${fullPath}": ${(err as Error).message}`,
      );
    }
  };

  return {
    putObject,
    getObject,
    listObjects,
    deleteByPrefix,
    objectExists,
  };
};

export type TDashboardStorage = ReturnType<typeof DashboardStorage>;
