import { createHash } from "node:crypto";
import { posix, win32 } from "node:path";

export const ASSET_ACCESS_CLASSES = [
  "public_release_asset",
  "internal_preview_asset",
  "private_user_asset",
] as const;

export type AssetAccessClass = typeof ASSET_ACCESS_CLASSES[number];
export type PublicAssetCacheKind = "font" | "template_conversion" | "thumbnail";

export interface AssetReleaseItemInput {
  access_class: AssetAccessClass;
  cache_kind?: PublicAssetCacheKind;
  content: Uint8Array;
  mime_type: string;
  owner_id?: string;
  relative_path: string;
  resource_id: string;
  root_ref: string;
  sha256?: string;
}

export interface AssetReleaseManifestInput {
  items: readonly AssetReleaseItemInput[];
  release_version: string;
}

export interface AssetReleaseManifestItem {
  access_class: AssetAccessClass;
  byte_size: number;
  cache_kind?: PublicAssetCacheKind;
  mime_type: string;
  release_version: string;
  resource_id: string;
  sha256: string;
  url: string;
}

export interface AssetReleaseManifestProjection {
  items: readonly AssetReleaseManifestItem[];
  manifest_sha256: string;
  release_version: string;
  schema_version: "AssetReleaseManifest/v1";
}

export interface AssetReleasePayload {
  accessClass: AssetAccessClass;
  bytes: Buffer;
  mimeType: string;
  ownerId?: string;
  releaseVersion: string;
  resourceId: string;
  sha256: string;
}

export interface AssetReleaseReader {
  project(
    accessClass: AssetAccessClass,
    releaseVersion: string,
    options?: { ownerId?: string; resourceIds?: readonly string[] },
  ): AssetReleaseManifestProjection | undefined;
  read(accessClass: AssetAccessClass, releaseVersion: string, resourceId: string): AssetReleasePayload | undefined;
}

interface StoredItem {
  accessClass: AssetAccessClass;
  bytes: Buffer;
  cacheKind?: PublicAssetCacheKind;
  mimeType: string;
  ownerId?: string;
  projection: AssetReleaseManifestItem;
  relativePath: string;
  rootRef: string;
  sha256: string;
}

const releaseVersionPattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
const resourceIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const sha256Pattern = /^[0-9a-f]{64}$/;
const rootRefPattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;

function assetUrl(accessClass: AssetAccessClass, releaseVersion: string, resourceId: string) {
  if (accessClass === "public_release_asset") return `/api/v1/assets/public/${releaseVersion}/${resourceId}`;
  if (accessClass === "internal_preview_asset") return `/api/v1/assets/preview/${releaseVersion}/${resourceId}`;
  return `/api/v1/private-assets/${releaseVersion}/${resourceId}`;
}

function isSafeRelativePath(value: string) {
  if (!value || value.includes("\\") || posix.isAbsolute(value) || win32.isAbsolute(value)) return false;
  const segments = value.split("/");
  return segments.every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
}

function sha256(value: string | Uint8Array) {
  return createHash("sha256").update(value).digest("hex");
}

function immutableProjection(input: Omit<AssetReleaseManifestProjection, "manifest_sha256">): AssetReleaseManifestProjection {
  const items = input.items.map((item) => Object.freeze({ ...item }));
  const manifestBody = JSON.stringify({ ...input, items });
  return Object.freeze({ ...input, items: Object.freeze(items), manifest_sha256: sha256(manifestBody) });
}

function validateItem(item: AssetReleaseItemInput, releaseVersion: string, seenIds: Set<string>): StoredItem {
  if (!ASSET_ACCESS_CLASSES.includes(item.access_class)) throw new Error("asset access class is unsupported");
  if (!resourceIdPattern.test(item.resource_id) || seenIds.has(item.resource_id)) throw new Error("resource_id must be a unique opaque UUID");
  seenIds.add(item.resource_id);
  if (!rootRefPattern.test(item.root_ref)) throw new Error("root_ref is invalid");
  if (!isSafeRelativePath(item.relative_path)) throw new Error("relative path must stay within its declared root");
  if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/i.test(item.mime_type)) throw new Error("mime_type is invalid");
  if (item.access_class === "public_release_asset" && !item.cache_kind) throw new Error("public release asset requires an allowlisted cache kind");
  if (item.access_class !== "public_release_asset" && item.cache_kind) throw new Error("non-public assets cannot declare a public cache kind");
  if (item.access_class === "private_user_asset" && !item.owner_id) throw new Error("private user asset requires owner_id");
  if (item.access_class !== "private_user_asset" && item.owner_id) throw new Error("only private user assets can declare owner_id");

  const bytes = Buffer.from(item.content);
  const digest = sha256(bytes);
  if (item.sha256 !== undefined && (!sha256Pattern.test(item.sha256) || item.sha256 !== digest)) {
    throw new Error("file SHA-256 does not match content");
  }
  const projection: AssetReleaseManifestItem = {
    access_class: item.access_class,
    byte_size: bytes.byteLength,
    ...(item.cache_kind ? { cache_kind: item.cache_kind } : {}),
    mime_type: item.mime_type,
    release_version: releaseVersion,
    resource_id: item.resource_id,
    sha256: digest,
    url: assetUrl(item.access_class, releaseVersion, item.resource_id),
  };
  return {
    accessClass: item.access_class,
    bytes,
    ...(item.cache_kind ? { cacheKind: item.cache_kind } : {}),
    mimeType: item.mime_type,
    ...(item.owner_id ? { ownerId: item.owner_id } : {}),
    projection: Object.freeze(projection),
    relativePath: item.relative_path,
    rootRef: item.root_ref,
    sha256: digest,
  };
}

export function createAssetReleaseManifest(input: AssetReleaseManifestInput): AssetReleaseReader {
  if (!releaseVersionPattern.test(input.release_version)) throw new Error("release_version is invalid");
  const seenIds = new Set<string>();
  const items = input.items
    .map((item) => validateItem(item, input.release_version, seenIds))
    .sort((left, right) => left.projection.resource_id.localeCompare(right.projection.resource_id));

  return Object.freeze({
    project(accessClass: AssetAccessClass, releaseVersion: string, options: { ownerId?: string; resourceIds?: readonly string[] } = {}) {
      if (releaseVersion !== input.release_version) return undefined;
      const selected = items.filter((item) => item.accessClass === accessClass
        && (accessClass !== "private_user_asset" || Boolean(options.ownerId) && item.ownerId === options.ownerId)
        && (!options.resourceIds || options.resourceIds.includes(item.projection.resource_id)));
      return immutableProjection({
        items: selected.map((item) => item.projection),
        release_version: input.release_version,
        schema_version: "AssetReleaseManifest/v1",
      });
    },
    read(accessClass: AssetAccessClass, releaseVersion: string, resourceId: string) {
      if (releaseVersion !== input.release_version) return undefined;
      const item = items.find((candidate) => candidate.accessClass === accessClass && candidate.projection.resource_id === resourceId);
      if (!item) return undefined;
      return {
        accessClass: item.accessClass,
        bytes: Buffer.from(item.bytes),
        mimeType: item.mimeType,
        ...(item.ownerId ? { ownerId: item.ownerId } : {}),
        releaseVersion,
        resourceId,
        sha256: item.sha256,
      };
    },
  });
}
