/** A stored object's metadata (no body). */
export interface StorageObjectHead {
  key: string
  size: number
  etag: string
  /** Content-Type as stored (set at put; a HOST that validated bytes stores the DETECTED type). */
  contentType?: string
  customMetadata?: Record<string, string>
  /** Last-modified epoch ms, when the backend reports it. */
  uploadedAt?: number
}

/** A stored object WITH its body. */
export interface StorageObject extends StorageObjectHead {
  body: ReadableStream<Uint8Array>
}

export interface PutOptions {
  contentType?: string
  cacheControl?: string
  customMetadata?: Record<string, string>
}

export interface PutResult {
  key: string
  size: number
  etag: string
}

export interface ListOptions {
  prefix?: string
  cursor?: string
  limit?: number
}

export interface ListResult {
  objects: StorageObjectHead[]
  /** Present iff more pages remain; pass back as ListOptions.cursor. */
  cursor?: string
}

export interface PresignOptions {
  /** REQUIRED, bounded — a presigned URL is a time-boxed capability, never standing. */
  expiresIn: number
  contentType?: string
}

export interface PresignedUrl {
  url: string
  method: 'PUT' | 'GET'
  headers: Record<string, string>
  expiresIn: number
}

/** Body shapes a put accepts — matches the r2-binding leaf so the R2 adapter wraps it directly. */
export type StorageBody = ReadableStream | ArrayBuffer | ArrayBufferView | Blob | Uint8Array

/**
 * Host-agnostic object store. R2 is the FIRST reference adapter, not the lock
 * (CLAUDE.md §5). A new host's adapter slots in here without touching any consumer.
 */
export interface StorageAdapter {
  put(key: string, body: StorageBody, opts?: PutOptions): Promise<PutResult>
  /** Body + metadata. null when the key is absent (NEVER throw on absence — no enumeration oracle). */
  get(key: string): Promise<StorageObject | null>
  /** Metadata only. null when absent. */
  head(key: string): Promise<StorageObjectHead | null>
  /** Idempotent — deleting an absent key resolves, never throws. */
  delete(key: string): Promise<void>
  list(opts?: ListOptions): Promise<ListResult>
  /** Mint a browser-direct PUT capability for ONE exact key (never prefix/wildcard). */
  presignPut(key: string, opts: PresignOptions): Promise<PresignedUrl>
  /** OPTIONAL — signed read for private buckets. Public-bucket hosts omit it. */
  presignGet?(key: string, opts: PresignOptions): Promise<PresignedUrl>
}

export type StorageErrorCode = 'invalid_key' | 'not_found' | 'backend'

export interface StorageErrorShape {
  readonly isStorageError: true
  readonly code: StorageErrorCode
  readonly message: string
}

export class StorageError extends Error implements StorageErrorShape {
  readonly isStorageError = true as const
  readonly code: StorageErrorCode
  constructor(code: StorageErrorCode, message: string) {
    super(message)
    this.code = code
    this.name = 'StorageError'
  }
}

/** Cross-package-safe guard — use this, NOT instanceof. */
export function isStorageError(e: unknown): e is StorageErrorShape {
  return typeof e === 'object' && e !== null && (e as { isStorageError?: unknown }).isStorageError === true
}

/**
 * Shared exact-key guard. Throws StorageError('invalid_key') on prefix/wildcard/empty/trailing-slash.
 * Both adapters call this so presignPut enforces the single-exact-key floor identically.
 */
export function assertExactStorageKey(key: string): void {
  if (!key || key.includes('*') || key.includes('?') || key.endsWith('/')) {
    throw new StorageError('invalid_key', `storage key must be one exact object key, not a prefix or wildcard: ${key}`)
  }
}

/** Shared expiry guard. Throws RangeError when expiresIn is not a positive finite number of seconds. */
export function assertPresignExpiry(expiresIn: number): void {
  if (!Number.isFinite(expiresIn) || expiresIn <= 0) {
    throw new RangeError('expiresIn must be a positive finite number of seconds')
  }
}
