/**
 * Object visibility understood by storage ports.
 */
export type StorageVisibility = "private" | "public";

/**
 * String metadata stored alongside an object.
 */
export type StorageMetadata = Record<string, string>;

/**
 * Body types accepted by `StoragePort.put(...)`.
 */
export type StorageBody =
  | string
  | ArrayBuffer
  | Uint8Array
  | Blob
  | ReadableStream<Uint8Array>;

/**
 * Options for writing an object to storage.
 */
export interface StoragePutOptions {
  /**
   * MIME content type stored with the object.
   */
  contentType?: string;
  /**
   * Cache-Control value stored with the object.
   */
  cacheControl?: string;
  /**
   * Provider metadata stored with the object.
   */
  metadata?: StorageMetadata;
  /**
   * Whether the object may receive a public URL.
   */
  visibility?: StorageVisibility;
}

/**
 * Metadata for an object in storage.
 */
export interface StorageObject {
  /**
   * Object key. Keys are relative object-store paths, not filesystem paths or
   * public URLs.
   */
  key: string;
  /**
   * Object size in bytes.
   */
  size: number;
  contentType?: string;
  cacheControl?: string;
  /**
   * Provider metadata stored with the object.
   */
  metadata: StorageMetadata;
  /**
   * Object visibility.
   */
  visibility: StorageVisibility;
  /**
   * Last modification timestamp.
   */
  lastModified: Date;
}

/**
 * Object metadata plus a one-shot readable body.
 *
 * Like Fetch response bodies, storage bodies can be consumed once. Call `get`
 * again if you need another reader.
 */
export interface StorageObjectBody extends StorageObject {
  /**
   * Whether this object body has already been consumed. Like Fetch response
   * bodies, storage bodies are one-shot so providers can stream objects without
   * buffering them.
   */
  readonly bodyUsed: boolean;
  /**
   * Consume the object as a readable byte stream.
   */
  stream(): ReadableStream<Uint8Array>;
  /**
   * Consume the object as bytes.
   */
  bytes(): Promise<Uint8Array>;
  /**
   * Consume the object as an ArrayBuffer.
   */
  arrayBuffer(): Promise<ArrayBuffer>;
  /**
   * Consume the object as UTF-8 text.
   */
  text(): Promise<string>;
}

/**
 * App-facing object storage port.
 *
 * Implement this with S3, R2, local disk, or a test adapter. Application code
 * should depend on this interface instead of provider-specific SDKs.
 */
export interface StoragePort {
  /**
   * Store an object and return its metadata.
   */
  put(
    key: string,
    body: StorageBody,
    options?: StoragePutOptions,
  ): Promise<StorageObject>;
  /**
   * Return object metadata and body, or `null` when missing.
   */
  get(key: string): Promise<StorageObjectBody | null>;
  /**
   * Return object metadata without its body, or `null` when missing.
   */
  stat(key: string): Promise<StorageObject | null>;
  /**
   * Delete an object.
   *
   * @returns `true` when the object existed.
   */
  delete(key: string): Promise<boolean>;
  /**
   * Return whether an object exists.
   */
  exists(key: string): Promise<boolean>;
  /**
   * Return a public URL when the object is public and the adapter can build one.
   */
  publicUrl(key: string): Promise<string | null>;
}

/**
 * Options for prefixing one validated storage key.
 */
export interface PrefixStorageKeyOptions {
  /**
   * Optional app or environment prefix. Leading and trailing slashes are
   * removed before it is applied.
   */
  keyPrefix?: string;
  /**
   * Relative object key to prefix.
   */
  key: string;
}

/**
 * Options for formatting a public storage URL.
 */
export interface CreateStoragePublicUrlOptions {
  /**
   * Absolute or app-relative public base URL.
   */
  publicBaseUrl: string;
  /**
   * Relative object key appended to the public base URL.
   */
  key: string;
}

function hasControlCharacter(value: string): boolean {
  for (const char of value) {
    const code = char.charCodeAt(0);
    if (code <= 31 || code === 127) return true;
  }

  return false;
}

/**
 * Assert that a storage key follows Beignet's provider-neutral key rules.
 *
 * Valid keys are non-empty relative object paths. They do not contain control
 * characters, backslashes, empty path segments, or `.` / `..` segments.
 * Providers may enforce additional adapter-specific restrictions after this
 * shared assertion.
 */
export function assertValidStorageKey(key: string): void {
  if (key.length === 0) {
    throw new Error("Storage key must not be empty.");
  }

  if (hasControlCharacter(key)) {
    throw new Error("Storage key must not include control characters.");
  }

  if (key.startsWith("/")) {
    throw new Error("Storage key must not start with '/'.");
  }

  if (key.endsWith("/")) {
    throw new Error("Storage key must not end with '/'.");
  }

  if (key.includes("\\")) {
    throw new Error("Storage key must use '/' separators, not '\\'.");
  }

  const segments = key.split("/");

  if (segments.some((segment) => segment === "")) {
    throw new Error("Storage key must not include empty path segments.");
  }

  if (segments.some((segment) => segment === "." || segment === "..")) {
    throw new Error("Storage key must not include '.' or '..' segments.");
  }
}

/**
 * Normalize and validate an optional storage key prefix.
 *
 * Empty and slash-only prefixes normalize to an empty string.
 */
export function normalizeStorageKeyPrefix(prefix: string | undefined): string {
  if (!prefix) return "";
  const normalized = prefix.replace(/^\/+|\/+$/g, "");
  if (!normalized) return "";
  assertValidStorageKey(normalized);
  return normalized;
}

/**
 * Prefix a storage key with an optional app or environment namespace.
 */
export function prefixStorageKey({
  keyPrefix,
  key,
}: PrefixStorageKeyOptions): string {
  const normalizedPrefix = normalizeStorageKeyPrefix(keyPrefix);
  assertValidStorageKey(key);
  return normalizedPrefix ? `${normalizedPrefix}/${key}` : key;
}

/**
 * Format an encoded public URL for a validated storage key.
 */
export function createStoragePublicUrl({
  publicBaseUrl,
  key,
}: CreateStoragePublicUrlOptions): string {
  assertValidStorageKey(key);
  const base = publicBaseUrl.replace(/\/+$/, "");
  const encodedKey = key
    .split("/")
    .map((part) => encodeURIComponent(part))
    .join("/");

  return `${base}/${encodedKey}`;
}

/**
 * Options for `createMemoryStorage(...)`.
 */
export interface MemoryStorageOptions {
  /**
   * Base URL used by `publicUrl(...)` for objects written with
   * `visibility: "public"`.
   */
  publicBaseUrl?: string;
}

type MemoryStorageEntry = StorageObject & {
  bytes: Uint8Array;
};

function copyBytes(bytes: Uint8Array): Uint8Array {
  return new Uint8Array(bytes);
}

function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
  const buffer = new ArrayBuffer(bytes.byteLength);
  new Uint8Array(buffer).set(bytes);
  return buffer;
}

function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
  const copy = copyBytes(bytes);
  return new ReadableStream<Uint8Array>({
    start(controller) {
      controller.enqueue(copy);
      controller.close();
    },
  });
}

async function streamToBytes(
  stream: ReadableStream<Uint8Array>,
): Promise<Uint8Array> {
  const reader = stream.getReader();
  const chunks: Uint8Array[] = [];
  let size = 0;

  try {
    while (true) {
      const result = await reader.read();
      if (result.done) break;
      chunks.push(result.value);
      size += result.value.byteLength;
    }
  } finally {
    reader.releaseLock();
  }

  const bytes = new Uint8Array(size);
  let offset = 0;
  for (const chunk of chunks) {
    bytes.set(chunk, offset);
    offset += chunk.byteLength;
  }

  return bytes;
}

async function storageBodyToBytes(body: StorageBody): Promise<Uint8Array> {
  if (typeof body === "string") {
    return new TextEncoder().encode(body);
  }

  if (body instanceof Uint8Array) {
    return copyBytes(body);
  }

  if (body instanceof ArrayBuffer) {
    return new Uint8Array(body.slice(0));
  }

  if (body instanceof Blob) {
    return new Uint8Array(await body.arrayBuffer());
  }

  return streamToBytes(body);
}

function cloneObject(entry: MemoryStorageEntry): StorageObject {
  return {
    key: entry.key,
    size: entry.size,
    ...(entry.contentType !== undefined
      ? { contentType: entry.contentType }
      : {}),
    ...(entry.cacheControl !== undefined
      ? { cacheControl: entry.cacheControl }
      : {}),
    metadata: { ...entry.metadata },
    visibility: entry.visibility,
    lastModified: new Date(entry.lastModified),
  };
}

function createObjectBody(entry: MemoryStorageEntry): StorageObjectBody {
  const object = cloneObject(entry);
  let bodyUsed = false;

  function consumeBytes(): Uint8Array {
    if (bodyUsed) {
      throw new Error("Storage object body has already been consumed.");
    }

    bodyUsed = true;
    return copyBytes(entry.bytes);
  }

  return {
    ...object,
    get bodyUsed() {
      return bodyUsed;
    },
    stream() {
      return bytesToStream(consumeBytes());
    },
    async bytes() {
      return consumeBytes();
    },
    async arrayBuffer() {
      return bytesToArrayBuffer(consumeBytes());
    },
    async text() {
      return new TextDecoder().decode(consumeBytes());
    },
  };
}

/**
 * Create an in-memory object storage adapter for tests, examples, and
 * single-process development.
 *
 * This adapter validates object keys using Beignet's storage key rules. It is
 * not durable and does not share objects across processes.
 *
 * @param options - Optional public URL base for public objects.
 * @returns A storage port backed by a local `Map`.
 */
export function createMemoryStorage(
  options: MemoryStorageOptions = {},
): StoragePort {
  const objects = new Map<string, MemoryStorageEntry>();

  return {
    async put(key, body, putOptions) {
      assertValidStorageKey(key);
      const bytes = await storageBodyToBytes(body);
      const entry: MemoryStorageEntry = {
        key,
        size: bytes.byteLength,
        ...(putOptions?.contentType !== undefined
          ? { contentType: putOptions.contentType }
          : {}),
        ...(putOptions?.cacheControl !== undefined
          ? { cacheControl: putOptions.cacheControl }
          : {}),
        metadata: { ...(putOptions?.metadata ?? {}) },
        visibility: putOptions?.visibility ?? "private",
        lastModified: new Date(),
        bytes,
      };

      objects.set(key, entry);

      return cloneObject(entry);
    },
    async get(key) {
      assertValidStorageKey(key);
      const entry = objects.get(key);
      if (!entry) return null;

      return createObjectBody(entry);
    },
    async stat(key) {
      assertValidStorageKey(key);
      const entry = objects.get(key);
      if (!entry) return null;

      return cloneObject(entry);
    },
    async delete(key) {
      assertValidStorageKey(key);
      return objects.delete(key);
    },
    async exists(key) {
      assertValidStorageKey(key);
      return objects.has(key);
    },
    async publicUrl(key) {
      assertValidStorageKey(key);
      const entry = objects.get(key);
      if (entry?.visibility !== "public" || !options.publicBaseUrl) {
        return null;
      }

      return createStoragePublicUrl({
        publicBaseUrl: options.publicBaseUrl,
        key,
      });
    },
  };
}
