'use strict';

import * as C from './consts.js';
import {
  hexFromBuffer,
  sha256,
  hmac,
  uriResourceEscape,
  getByteSize,
  sanitizeETag,
  uriEscape,
  parseXml,
  escapeXml,
  base64FromBuffer,
  extractErrCode,
  S3NetworkError,
  S3ServiceError,
  generateParts,
  toUint8Array,
  isBun,
  byCodePoint,
} from './utils.js';
import type * as IT from './types.js';

/**
 * S3 class for interacting with S3-compatible object storage services.
 * This class provides methods for common S3 operations such as uploading, downloading,
 * and deleting objects, as well as multipart uploads.
 *
 * @class
 * @example
 * const s3 = new S3mini({
 *   accessKeyId: 'your-access-key',
 *   secretAccessKey: 'your-secret-key',
 *   endpoint: 'https://your-s3-endpoint.com/bucket-name',
 *   region: 'auto' // by default is auto
 * });
 *
 * // Upload a file
 * await s3.putObject('example.txt', 'Hello, World!');
 *
 * // Download a file
 * const content = await s3.getObject('example.txt');
 *
 * // Delete a file
 * await s3.deleteObject('example.txt');
 */
class S3mini {
  /**
   * Creates an instance of the S3 class.
   *
   * @constructor
   * @param {Object} config - Configuration options for the S3 instance.
   * @param {string} config.accessKeyId - The access key ID for authentication.
   * @param {string} config.secretAccessKey - The secret access key for authentication.
   * @param {string} config.endpoint - The endpoint URL of the S3-compatible service.
   * @param {string} [config.region='auto'] - The region of the S3 service.
   * @param {number} [config.requestSizeInBytes=8388608] - The request size of a single request in bytes (AWS S3 is 8MB).
   * @param {number} [config.requestAbortTimeout=undefined] - The timeout in milliseconds after which a request should be aborted (careful on streamed requests).
   * @param {Object} [config.logger=null] - A logger object with methods like info, warn, error.
   * @param {typeof fetch} [config.fetch=globalThis.fetch] - Custom fetch implementation to use for HTTP requests.
   * @param {number} [config.minPartSize=8388608] - The minimum part size for multipart uploads in bytes (default is 8MB).
   * @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type.
   */
  readonly #accessKeyId: string;
  readonly #secretAccessKey: string;
  readonly endpoint: URL;
  readonly region: string;
  readonly bucketName: string;
  readonly requestSizeInBytes: number;
  readonly requestAbortTimeout?: number;
  readonly logger?: IT.Logger;
  readonly _fetch: typeof fetch;
  readonly minPartSize: number;
  private readonly _bun?: IT.NativeS3Client;
  private signingKeyDate?: string;
  private signingKey?: ArrayBuffer;

  constructor({
    accessKeyId,
    secretAccessKey,
    endpoint,
    region = 'auto',
    requestSizeInBytes = C.DEFAULT_REQUEST_SIZE_IN_BYTES,
    requestAbortTimeout = undefined,
    logger = undefined,
    fetch = globalThis.fetch,
    minPartSize = C.MIN_PART_SIZE,
  }: IT.S3Config) {
    this._validateConstructorParams(accessKeyId, secretAccessKey, endpoint);
    this.#accessKeyId = accessKeyId;
    this.#secretAccessKey = secretAccessKey;
    this.endpoint = new URL(this._ensureValidUrl(endpoint));
    this.region = region;
    this.bucketName = this._extractBucketName();
    this.requestSizeInBytes = requestSizeInBytes;
    this.requestAbortTimeout = requestAbortTimeout;
    this.logger = logger;
    this._fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => fetch(input, init);
    this.minPartSize = minPartSize;

    // Bun's native client has its own transport, so a caller-supplied fetch would be silently
    // bypassed: only take the native path when the default fetch is in use. It also refuses empty
    // credentials, which anonymous access to public buckets relies on.
    if (isBun && fetch === globalThis.fetch && this._hasCredentials()) {
      // Bun's client is an origin plus a bucket name, so an endpoint carrying anything past the
      // bucket (host/bucket/prefix) cannot be expressed: the extra segments would be dropped and
      // every native request would silently land in the parent bucket, while the signed path stays
      // inside the prefix. Decline the native path rather than read and write the wrong location.
      const segments = this.endpoint.pathname.split('/').filter(Boolean);
      if (segments.length < 2) {
        const { S3Client } = (
          globalThis as unknown as { Bun: { S3Client: new (o: Record<string, unknown>) => IT.NativeS3Client } }
        ).Bun;
        this._bun = new S3Client({
          accessKeyId,
          secretAccessKey,
          endpoint: this.endpoint.origin,
          region: this.region,
          bucket: this.bucketName,
          // Bucket in the path means path-style; otherwise it is in the host and Bun has to be
          // told, or it would repeat the bucket in the path (bucket.host/bucket/key).
          virtualHostedStyle: segments.length === 0,
        });
      }
    }
  }

  private _sanitize(obj: unknown): unknown {
    if (typeof obj !== 'object' || obj === null) {
      return obj;
    }
    return Object.keys(obj).reduce(
      (acc: Record<string, unknown>, key) => {
        if (C.SENSITIVE_KEYS_REDACTED.has(key.toLowerCase())) {
          acc[key] = '[REDACTED]';
        } else if (
          typeof (obj as Record<string, unknown>)[key] === 'object' &&
          (obj as Record<string, unknown>)[key] !== null
        ) {
          acc[key] = this._sanitize((obj as Record<string, unknown>)[key]);
        } else {
          acc[key] = (obj as Record<string, unknown>)[key];
        }
        return acc;
      },
      Array.isArray(obj) ? [] : {},
    );
  }

  private _log(
    level: 'info' | 'warn' | 'error',
    message: string,
    additionalData: Record<string, unknown> | string = {},
  ): void {
    if (this.logger && typeof this.logger[level] === 'function') {
      // Function to recursively sanitize an object

      // Sanitize the additional data
      const sanitizedData = this._sanitize(additionalData);
      // Prepare the log entry
      const logEntry = {
        timestamp: new Date().toISOString(),
        level,
        message,
        details: sanitizedData,
        // Include some general context, but sanitize sensitive parts
        context: this._sanitize({
          region: this.region,
          endpoint: this.endpoint.toString(),
          // Only include the first few characters of the access key, if it exists
          accessKeyId: this.#accessKeyId ? `${this.#accessKeyId.substring(0, 4)}...` : undefined,
        }),
      };

      // Log the sanitized entry
      this.logger[level](JSON.stringify(logEntry));
    }
  }

  // S3 returns repeated elements as either an array or a single scalar object
  // (e.g. a lone <Contents> is not wrapped in a 1-element array). Normalize to array.
  private _asArray(value: unknown): unknown[] {
    return Array.isArray(value) ? value : [value];
  }

  private _validateConstructorParams(accessKeyId: string, secretAccessKey: string, endpoint: string): void {
    if (typeof accessKeyId !== 'string') {
      throw new TypeError(C.ERROR_ACCESS_KEY_REQUIRED);
    }
    if (typeof secretAccessKey !== 'string') {
      throw new TypeError(C.ERROR_SECRET_KEY_REQUIRED);
    }
    if (typeof endpoint !== 'string' || endpoint.trim().length === 0) {
      throw new TypeError(C.ERROR_ENDPOINT_REQUIRED);
    }
  }

  /**
   * Check if credentials are configured (non-empty).
   * @returns true if both accessKeyId and secretAccessKey are non-empty.
   */
  private _hasCredentials(): boolean {
    return this.#accessKeyId.trim().length > 0 && this.#secretAccessKey.trim().length > 0;
  }

  /**
   * Re-shape a Bun S3Error as the S3ServiceError the signed path throws, so callers see one error
   * type on every runtime. Bun does not expose the HTTP status, so it is recovered from the error
   * code where the S3 API pins it and left as 0 (unknown) otherwise.
   */
  private _bunError(e: unknown): unknown {
    const err = e as { name?: string; code?: string; message?: string };
    if (err?.name !== 'S3Error') {
      return e;
    }
    const status = err.code ? (C.S3_CODE_STATUS[err.code] ?? 0) : 0;
    const message = status ? `S3 returned ${status} – ${err.code}` : (err.message ?? String(e));
    // The provider's wording goes where the signed path puts the error body.
    return new S3ServiceError(message, status, err.code, err.message);
  }

  /** True for a Bun S3Error the signed path would have absorbed as a tolerated 404. */
  private _isBunNotFound(e: unknown): boolean {
    const code = (e as { code?: string })?.code;
    return !!code && C.S3_CODE_STATUS[code] === 404;
  }

  /** Run a read op via Bun-native S3, returning null when the object or its bucket is absent. */
  private async _bunRead<T>(key: string, op: (f: IT.NativeS3File) => Promise<T>): Promise<T | null> {
    try {
      return await op(this._bun!.file(key));
    } catch (e) {
      // Every signed reader tolerates 404 and answers null, so match on the status the code maps
      // to rather than on NoSuchKey alone: a missing *bucket* is equally a 404, and singling out
      // the key left it throwing here while returning null on the signed path.
      if (this._isBunNotFound(e)) {
        return null;
      }
      throw this._bunError(e);
    }
  }

  private _ensureValidUrl(raw: string): string {
    const candidate = /^(https?:)?\/\//i.test(raw) ? raw : `https://${raw}`;
    try {
      new URL(candidate);

      // Find the last non-slash character
      let endIndex = candidate.length;
      while (endIndex > 0 && candidate[endIndex - 1] === '/') {
        endIndex--;
      }
      return endIndex === candidate.length ? candidate : candidate.substring(0, endIndex);
    } catch {
      const msg = `${C.ERROR_ENDPOINT_FORMAT} But provided: "${raw}"`;
      this._log('error', msg);
      throw new TypeError(msg);
    }
  }

  private _validateMethodIsGetOrHead(method: string): void {
    if (method !== 'GET' && method !== 'HEAD') {
      this._log('error', `${C.ERROR_PREFIX}method must be either GET or HEAD`);
      throw new Error(`${C.ERROR_PREFIX}method must be either GET or HEAD`);
    }
  }

  private _checkKey(key: string): void {
    if (typeof key !== 'string' || key.trim().length === 0) {
      this._log('error', C.ERROR_KEY_REQUIRED);
      throw new TypeError(C.ERROR_KEY_REQUIRED);
    }
  }

  private _checkDelimiter(delimiter: string): void {
    if (typeof delimiter !== 'string' || delimiter.trim().length === 0) {
      this._log('error', C.ERROR_DELIMITER_REQUIRED);
      throw new TypeError(C.ERROR_DELIMITER_REQUIRED);
    }
  }

  private _checkPrefix(prefix: string): void {
    if (typeof prefix !== 'string') {
      this._log('error', C.ERROR_PREFIX_TYPE);
      throw new TypeError(C.ERROR_PREFIX_TYPE);
    }
  }

  // private _checkMaxKeys(maxKeys: number): void {
  //   if (typeof maxKeys !== 'number' || maxKeys <= 0) {
  //     this._log('error', C.ERROR_MAX_KEYS_TYPE);
  //     throw new TypeError(C.ERROR_MAX_KEYS_TYPE);
  //   }
  // }

  private _checkOpts(opts: object): void {
    if (typeof opts !== 'object') {
      this._log('error', `${C.ERROR_PREFIX}opts must be an object`);
      throw new TypeError(`${C.ERROR_PREFIX}opts must be an object`);
    }
  }

  private _filterIfHeaders(opts: Record<string, unknown>): {
    filteredOpts: Record<string, string>;
    conditionalHeaders: Record<string, unknown>;
  } {
    const filteredOpts: Record<string, string> = {};
    const conditionalHeaders: Record<string, unknown> = {};

    for (const [key, value] of Object.entries(opts)) {
      if (C.IFHEADERS.has(key.toLowerCase())) {
        conditionalHeaders[key] = value;
      } else {
        filteredOpts[key] = value as string;
      }
    }

    return { filteredOpts, conditionalHeaders };
  }

  // private _validateData(data: unknown): BodyInit {
  //   if (data instanceof ArrayBuffer) {
  //     return data;
  //   }
  //   if (data instanceof Uint8Array) {
  //     return data as unknown as BodyInit;
  //   }
  //   if ((globalThis.Buffer && data instanceof globalThis.Buffer) || typeof data === 'string') {
  //     return data as BodyInit;
  //   }
  //   this._log('error', C.ERROR_DATA_BUFFER_REQUIRED);
  //   throw new TypeError(C.ERROR_DATA_BUFFER_REQUIRED);
  // }

  private _validateUploadPartParams(
    key: string,
    uploadId: string,
    data: IT.DataInput,
    partNumber: number,
    opts: object,
  ): BodyInit {
    this._checkKey(key);
    if (typeof uploadId !== 'string' || uploadId.trim().length === 0) {
      this._log('error', C.ERROR_UPLOAD_ID_REQUIRED);
      throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED);
    }
    if (!Number.isInteger(partNumber) || partNumber <= 0) {
      this._log('error', `${C.ERROR_PREFIX}partNumber must be a positive integer`);
      throw new TypeError(`${C.ERROR_PREFIX}partNumber must be a positive integer`);
    }
    this._checkOpts(opts);
    return data as BodyInit;
  }

  private async _sign(
    method: IT.HttpMethod,
    keyPath: string,
    query: Record<string, unknown> = {},
    headers: Record<string, string | number> = {},
  ): Promise<{ url: string; headers: Record<string, string | number> }> {
    // Create URL without appending keyPath first
    const url = new URL(this.endpoint);

    // Properly format the pathname to avoid double slashes
    if (keyPath && keyPath.length > 0) {
      url.pathname =
        url.pathname === '/' ? `/${keyPath.replace(/^\/+/, '')}` : `${url.pathname}/${keyPath.replace(/^\/+/, '')}`;
    }

    // If no credentials, return unsigned request (for public bucket access)
    if (!this._hasCredentials()) {
      headers[C.HEADER_HOST] = url.host;
      return { url: url.toString(), headers };
    }

    const d = new Date();
    const year = d.getUTCFullYear();
    const month = String(d.getUTCMonth() + 1).padStart(2, '0');
    const day = String(d.getUTCDate()).padStart(2, '0');

    const shortDatetime = `${year}${month}${day}`;
    const fullDatetime = `${shortDatetime}T${String(d.getUTCHours()).padStart(2, '0')}${String(d.getUTCMinutes()).padStart(2, '0')}${String(d.getUTCSeconds()).padStart(2, '0')}Z`;
    const credentialScope = `${shortDatetime}/${this.region}/${C.S3_SERVICE}/${C.AWS_REQUEST_TYPE}`;

    headers[C.HEADER_AMZ_CONTENT_SHA256] = C.UNSIGNED_PAYLOAD;
    headers[C.HEADER_AMZ_DATE] = fullDatetime;
    headers[C.HEADER_HOST] = url.host;

    const ignoredHeaders = new Set(['authorization', 'content-length', 'content-type', 'user-agent']);

    const sortedHeaders = Object.entries(headers)
      .map(([key, value]): [string, string] => [key.toLowerCase(), String(value).trim()])
      .filter(([lowerKey]) => !ignoredHeaders.has(lowerKey))
      .sort(([a], [b]) => byCodePoint(a, b));

    const canonicalHeaders = sortedHeaders.map(([k, v]) => `${k}:${v}`).join('\n');
    const signedHeaders = sortedHeaders.map(([k]) => k).join(';');
    const canonicalRequest = `${method}\n${url.pathname}\n${this._buildCanonicalQueryString(query)}\n${canonicalHeaders}\n\n${signedHeaders}\n${C.UNSIGNED_PAYLOAD}`;
    const stringToSign = `${C.AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${hexFromBuffer(await sha256(canonicalRequest))}`;
    if (shortDatetime !== this.signingKeyDate || !this.signingKey) {
      this.signingKeyDate = shortDatetime;
      this.signingKey = await this._getSignatureKey(shortDatetime);
    }
    const signature = hexFromBuffer(await hmac(this.signingKey, stringToSign));
    headers[C.HEADER_AUTHORIZATION] =
      `${C.AWS_ALGORITHM} Credential=${this.#accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
    return { url: url.toString(), headers };
  }

  private async _signedRequest(
    method: IT.HttpMethod, // 'GET' | 'HEAD' | 'PUT' | 'POST' | 'DELETE'
    key: string, // ‘’ allowed for bucket‑level ops
    {
      query = {}, // ?query=string
      body = '', // BodyInit | undefined
      headers = {}, // extra/override headers
      tolerated = [], // [200, 404] etc.
      withQuery = false, // append query string to signed URL
    }: {
      query?: Record<string, unknown>;
      body?: BodyInit;
      headers?: Record<string, string | number | undefined> | IT.SSECHeaders | IT.AWSHeaders;
      tolerated?: number[];
      withQuery?: boolean;
    } = {},
  ): Promise<Response> {
    // Basic validation
    // if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) {
    //   throw new Error(`${C.ERROR_PREFIX}Unsupported HTTP method ${method as string}`);
    // }

    const { filteredOpts, conditionalHeaders } = ['GET', 'HEAD'].includes(method)
      ? this._filterIfHeaders(query)
      : { filteredOpts: query, conditionalHeaders: {} };
    const baseHeaders: Record<string, string | number> = {
      [C.HEADER_AMZ_CONTENT_SHA256]: C.UNSIGNED_PAYLOAD,
      // ...(['GET', 'HEAD'].includes(method) ? { [C.HEADER_CONTENT_TYPE]: C.JSON_CONTENT_TYPE } : {}),
      ...headers,
      ...conditionalHeaders,
    };

    const encodedKey = key ? uriResourceEscape(key) : '';
    const { url, headers: signedHeaders } = await this._sign(method, encodedKey, filteredOpts, baseHeaders);
    if (Object.keys(query).length > 0) {
      withQuery = true; // append query string to signed URL
    }
    const finalUrl =
      withQuery && Object.keys(filteredOpts).length ? `${url}?${this._buildCanonicalQueryString(filteredOpts)}` : url;
    const signedHeadersString = Object.fromEntries(
      Object.entries(signedHeaders).map(([k, v]) => [k, String(v)]),
    ) as Record<string, string>;
    return this._sendRequest(finalUrl, method, signedHeadersString, body, tolerated);
  }

  /**
   * Sanitizes an ETag value by removing surrounding quotes and whitespace.
   * Still returns RFC compliant ETag. https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3
   * @param {string} etag - The ETag value to sanitize.
   * @returns {string} The sanitized ETag value.
   * @example
   * const cleanEtag = s3.sanitizeETag('"abc123"'); // Returns: 'abc123'
   */
  public sanitizeETag(etag: string): string {
    return sanitizeETag(etag);
  }

  /**
   * Creates a new bucket.
   * This method sends a request to create a new bucket in the specified in endpoint.
   * @returns A promise that resolves to true if the bucket was created successfully, false otherwise.
   */
  public async createBucket(): Promise<boolean> {
    const xmlBody = `
      <CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
        <LocationConstraint>${this.region}</LocationConstraint>
      </CreateBucketConfiguration>
    `;
    const headers = {
      [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
      [C.HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
    };
    const res = await this._signedRequest('PUT', '', {
      body: xmlBody,
      headers,
      tolerated: [200, 404, 403, 409], // don’t throw on 404/403 // 409 = bucket already exists
    });
    return res.status === 200;
  }

  private _extractBucketName(): string {
    const url = this.endpoint;

    // Path-style: bucket is the first non-empty path segment
    const firstSegment = url.pathname.split('/').find(Boolean);
    if (firstSegment) {
      return firstSegment;
    }

    // Virtual-hosted style: bucket is the first subdomain label
    const hostname = url.hostname;

    // IP addresses (v4: digits+dots, v6: contains colons) can't carry a bucket subdomain
    if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(':')) {
      return '';
    }

    const labels = hostname.split('.');

    // Need ≥3 labels for virtual-hosted (bucket.service.tld)
    // Single-label (localhost) or two-label (example.com) have no room for a bucket subdomain
    if (labels.length < 3) {
      return '';
    }

    return labels[0]!;
  }

  /**
   * Checks if a bucket exists.
   * This method sends a request to check if the specified bucket exists in the S3-compatible service.
   * @returns A promise that resolves to true if the bucket exists, false otherwise.
   */
  public async bucketExists(): Promise<boolean> {
    const res = await this._signedRequest('HEAD', '', { tolerated: [200, 404, 403] });
    return res.status === 200;
  }

  /**
   * Sets bucket versioning status (PutBucketVersioning).
   * Required before object versioning APIs (`listObjectVersions`, versioned delete/copy) are useful.
   * @param {'Enabled' | 'Suspended'} status - Versioning status to apply.
   * @returns {Promise<boolean>} True when the service accepts the configuration (HTTP 200).
   * @example
   * await s3.setBucketVersioning('Enabled');
   */
  public async setBucketVersioning(status: 'Enabled' | 'Suspended'): Promise<boolean> {
    if (status !== 'Enabled' && status !== 'Suspended') {
      throw new TypeError(`${C.ERROR_PREFIX}status must be 'Enabled' or 'Suspended'`);
    }
    const xmlBody =
      '<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">' +
      `<Status>${status}</Status>` +
      '</VersioningConfiguration>';
    const res = await this._signedRequest('PUT', '', {
      query: { versioning: '' },
      body: xmlBody,
      headers: {
        [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
        [C.HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
      },
      withQuery: true,
      tolerated: [200],
    });
    return res.status === 200;
  }

  /**
   * Gets bucket versioning status (GetBucketVersioning).
   * @returns {Promise<'Enabled' | 'Suspended' | 'Off'>} Current status. `'Off'` when the config is empty/unset.
   */
  public async getBucketVersioning(): Promise<'Enabled' | 'Suspended' | 'Off'> {
    const res = await this._signedRequest('GET', '', {
      query: { versioning: '' },
      withQuery: true,
      tolerated: [200, 404],
    });
    if (res.status !== 200) {
      void res.body?.cancel();
      return 'Off';
    }
    const raw = parseXml(await res.text()) as Record<string, unknown>;
    const cfg = (raw.VersioningConfiguration || raw.versioningConfiguration || raw) as Record<string, unknown>;
    const status = cfg.Status ?? cfg.status;
    if (status === 'Enabled' || status === 'Suspended') {
      return status;
    }
    return 'Off';
  }

  /**
   * Lists objects in the bucket with optional filtering and no pagination.
   * This method retrieves all objects matching the criteria (not paginated like listObjectsV2).
   * Pass `{ versions: true }` in opts to list object versions (ListObjectVersions API).
   * @param {string} [delimiter='/'] - The delimiter to use for grouping objects.
   * @param {string} [prefix=''] - The prefix to filter objects by.
   * @param {number} [maxKeys] - The maximum number of keys to return. If not provided, all keys will be returned.
   * @param {Record<string, unknown>} [opts={}] - Additional options for the request. Use `{ versions: true }` for version listing.
   * @returns {Promise<IT.ListObject[] | null>} A promise that resolves to an array of objects, or null if the bucket does not exist. An empty bucket resolves to an empty array.
   * @example
   * // List all objects
   * const objects = await s3.listObjects();
   *
   * // List objects with prefix
   * const photos = await s3.listObjects('/', 'photos/', 100);
   *
   * // List object versions (includes VersionId / IsLatest; may include delete markers)
   * const versions = await s3.listObjects('/', 'photos/', undefined, { versions: true });
   */
  public async listObjects(
    delimiter: string = '/',
    prefix: string = '',
    maxKeys?: number,
    opts: Record<string, unknown> = {},
  ): Promise<IT.ListObject[] | null> {
    this._checkDelimiter(delimiter);
    this._checkPrefix(prefix);
    this._checkOpts(opts);

    if (this._bun && delimiter === '/' && !this._isVersionsMode(opts)) {
      const extraKeys = Object.keys(opts).filter(k => k !== 'delimiter');
      if (extraKeys.length === 0) {
        return this._bunListAll(prefix, maxKeys, opts.delimiter as string | undefined);
      }
    }

    const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
    const unlimited = !(maxKeys && maxKeys > 0);
    let remaining = unlimited ? Infinity : maxKeys;
    let token: string | undefined;
    const all: IT.ListObject[] = [];

    do {
      const batchResult = await this._fetchObjectBatch(keyPath, prefix, remaining, token, opts);

      if (batchResult === null) {
        return null; // 404 - bucket not found
      }

      all.push(...batchResult.objects);

      if (!unlimited) {
        remaining -= batchResult.objects.length;
      }

      token = batchResult.continuationToken;
    } while (token && remaining > 0);

    return all;
  }

  /**
   * Lists objects in the bucket with optional filtering and pagination using a continuation token.
   * This method retrieves objects matching the criteria (paginated like listObjectsV2).
   * Pass `{ versions: true }` in opts to list object versions (uses key-marker / version-id-marker under the hood;
   * the returned token is opaque and only valid with the same opts).
   * @param {string} [delimiter='/'] - The delimiter to use for grouping objects.
   * @param {string} [prefix=''] - The prefix to filter objects by.
   * @param {number} [maxKeys] - The maximum number of keys to return. Uses a default value of 100.
   * @param {string} [nextContinuationToken] - The nextContinuationToken to continue previous results. If not provided, starts from the beginning.
   * @param {Record<string, unknown>} [opts={}] - Additional options for the request. Use `{ versions: true }` for version listing.
   * @returns {Promise<{objects: IT.ListObject[] | null; nextContinuationToken?: string } | undefined | null>} A promise that resolves to an array of objects, along with nextContinuationToken if there are more reccords, or null if the bucket does not exist.
   * @example
   * // List all objects
   * const { objects, nextContinuationToken } = await s3.listObjectsPaged();
   *
   * // List 200 objects with prefix
   * const photos = await s3.listObjectsPaged('/', 'photos/', 200, "token...");
   */
  public async listObjectsPaged(
    delimiter: string = '/',
    prefix: string = '',
    maxKeys: number = 100,
    nextContinuationToken?: string,
    opts: Record<string, unknown> = {},
  ): Promise<{ objects: IT.ListObject[] | null; nextContinuationToken?: string } | undefined | null> {
    this._checkDelimiter(delimiter);
    this._checkPrefix(prefix);
    this._checkOpts(opts);

    const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
    let token: string | undefined = nextContinuationToken;
    let remaining = maxKeys;
    const all: IT.ListObject[] = [];

    do {
      const batchResult = await this._fetchObjectBatch(keyPath, prefix, remaining, token, opts);
      if (batchResult === null) {
        return null; // 404 - bucket not found
      }

      all.push(...batchResult.objects);
      remaining -= batchResult.objects.length;
      token = batchResult.continuationToken;
    } while (token && remaining > 0);

    return { objects: all, nextContinuationToken: token };
  }

  /**
   * Lists all versions (and delete markers) of a specific object key.
   * Auto-paginates until every version is returned (or maxKeys is reached).
   * Entries include `VersionId`, `IsLatest`, and optionally `IsDeleteMarker`.
   *
   * @param {string} key - Exact object key whose versions to list.
   * @param {number} [maxKeys] - Optional cap on how many version entries to return.
   * @returns {Promise<IT.ListObject[] | null>} All versions for the key, or null if the bucket is not found.
   * @example
   * const versions = await s3.listObjectVersions('file.jpg');
   * const latest = versions?.find(v => v.IsLatest);
   * const older = versions?.filter(v => !v.IsLatest && !v.IsDeleteMarker);
   */
  public async listObjectVersions(key: string, maxKeys?: number): Promise<IT.ListObject[] | null> {
    this._checkKey(key);
    // Narrow server-side with prefix=key, then filter exact Key match (prefix is a string prefix).
    const listed = await this.listObjects('/', key, maxKeys, { versions: true });
    if (listed === null) {
      return null;
    }
    return listed.filter(obj => obj.Key === key);
  }

  private async _fetchObjectBatch(
    keyPath: string,
    prefix: string,
    remaining: number,
    token: string | undefined,
    opts: Record<string, unknown>,
  ): Promise<{ objects: IT.ListObject[]; continuationToken?: string } | null> {
    const query = this._buildListObjectsQuery(prefix, remaining, token, opts);

    const res = await this._signedRequest('GET', keyPath, {
      query,
      withQuery: true,
      tolerated: [200, 404],
    });

    if (res.status === 404) {
      void res.body?.cancel();
      return null;
    }

    if (res.status !== 200) {
      await this._handleListObjectsError(res);
    }

    const xmlText = await res.text();
    return this._parseListObjectsResponse(xmlText, this._isVersionsMode(opts));
  }

  private _isVersionsMode(opts: Record<string, unknown>): boolean {
    const v = opts.versions;
    return v === true || v === '' || v === 'true' || v === 1;
  }

  private _encodeVersionListToken(keyMarker: string, versionIdMarker: string): string {
    return JSON.stringify({ k: keyMarker, v: versionIdMarker });
  }

  private _decodeVersionListToken(token: string): { keyMarker: string; versionIdMarker: string } {
    try {
      const parsed = JSON.parse(token) as { k?: unknown; v?: unknown };
      if (parsed && typeof parsed.k === 'string') {
        return {
          keyMarker: parsed.k,
          versionIdMarker: typeof parsed.v === 'string' ? parsed.v : '',
        };
      }
    } catch {
      // fall through — treat raw token as key-marker only
    }
    return { keyMarker: token, versionIdMarker: '' };
  }

  private _buildListObjectsQuery(
    prefix: string,
    remaining: number,
    token: string | undefined,
    opts: Record<string, unknown>,
  ): Record<string, unknown> {
    const batchSize = Math.min(remaining, 1000); // S3 ceiling
    const versionsMode = this._isVersionsMode(opts);
    // Do not forward control key that we map ourselves
    const restOpts: Record<string, unknown> = { ...opts };
    delete restOpts.versions;

    if (versionsMode) {
      const markers = token ? this._decodeVersionListToken(token) : undefined;
      return {
        versions: '',
        'max-keys': String(batchSize),
        ...(prefix ? { prefix } : {}),
        ...(markers
          ? {
              'key-marker': markers.keyMarker,
              'version-id-marker': markers.versionIdMarker,
            }
          : {}),
        ...restOpts,
      };
    }

    return {
      'list-type': C.LIST_TYPE, // =2 for V2
      'max-keys': String(batchSize),
      ...(prefix ? { prefix } : {}),
      ...(token ? { 'continuation-token': token } : {}),
      ...restOpts,
    };
  }

  private async _handleListObjectsError(res: Response): Promise<never> {
    const errorBody = await res.text();
    const parsedErrorBody = this._parseErrorXml(res.headers, errorBody);
    const errorCode = res.headers.get('x-amz-error-code') ?? parsedErrorBody.svcCode ?? 'Unknown';
    const errorMessage = res.headers.get('x-amz-error-message') ?? parsedErrorBody.errorMessage ?? res.statusText;

    this._log(
      'error',
      `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
    );

    throw new Error(
      `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
    );
  }

  private _parseListObjectsResponse(
    xmlText: string,
    versionsMode = false,
  ): {
    objects: IT.ListObject[];
    continuationToken?: string;
  } {
    const raw = parseXml(xmlText) as Record<string, unknown>;

    if (typeof raw !== 'object' || !raw || 'error' in raw) {
      this._log('error', `${C.ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`);
      throw new Error(`${C.ERROR_PREFIX}Unexpected listObjects response shape`);
    }

    const out = (raw.ListVersionsResult ||
      raw.listVersionsResult ||
      raw.ListBucketResult ||
      raw.listBucketResult ||
      raw) as Record<string, unknown>;
    const objects = this._extractObjectsFromResponse(out);
    const continuationToken = versionsMode
      ? this._extractVersionListToken(out)
      : this._extractContinuationToken(out);

    return { objects, continuationToken };
  }

  private _mapListEntry(item: Record<string, unknown>, isDeleteMarker = false): IT.ListObject {
    const keyRaw = item.Key ?? item.key ?? '';
    const key = typeof keyRaw === 'string' ? keyRaw : '';
    const versionId = item.VersionId ?? item.versionId;
    const isLatestRaw = item.IsLatest ?? item.isLatest;
    const etagRaw = item.ETag ?? item.etag ?? item.eTag ?? '';
    const storageRaw = item.StorageClass ?? item.storageClass ?? '';
    const lmRaw = item.LastModified ?? item.lastModified ?? 0;
    const entry: IT.ListObject = {
      Key: key,
      Size: Number(item.Size ?? item.size ?? 0),
      LastModified: new Date(typeof lmRaw === 'string' || typeof lmRaw === 'number' ? lmRaw : 0),
      ETag: typeof etagRaw === 'string' ? etagRaw : '',
      StorageClass: typeof storageRaw === 'string' ? storageRaw : '',
    };
    if (typeof versionId === 'string' && versionId !== '') {
      entry.VersionId = versionId;
    }
    if (isLatestRaw !== undefined && isLatestRaw !== null && isLatestRaw !== '') {
      entry.IsLatest = isLatestRaw === true || isLatestRaw === 'true';
    }
    if (isDeleteMarker) {
      entry.IsDeleteMarker = true;
    }
    return entry;
  }

  private _pushListEntries(raw: unknown, isDeleteMarker: boolean, out: IT.ListObject[]): void {
    if (!raw) {
      return;
    }
    for (const item of this._asArray(raw)) {
      out.push(this._mapListEntry(item as Record<string, unknown>, isDeleteMarker));
    }
  }

  private _pushCommonPrefixes(raw: unknown, out: IT.ListObject[]): void {
    if (!raw) {
      return;
    }
    for (const item of this._asArray(raw)) {
      const entry = item as Record<string, unknown>;
      const prefix = entry.Prefix || entry.prefix;
      if (typeof prefix === 'string') {
        out.push({ Key: prefix, Size: 0, LastModified: new Date(0), ETag: '', StorageClass: '' });
      }
    }
  }

  private _extractObjectsFromResponse(response: Record<string, unknown>): IT.ListObject[] {
    const objects: IT.ListObject[] = [];
    this._pushListEntries(response.Contents || response.contents, false, objects);
    this._pushListEntries(response.Version || response.version, false, objects);
    this._pushListEntries(response.DeleteMarker || response.deleteMarker, true, objects);
    this._pushCommonPrefixes(response.CommonPrefixes || response.commonPrefixes, objects);
    return objects;
  }

  private _extractContinuationToken(response: Record<string, unknown>): string | undefined {
    const truncated = response.IsTruncated === 'true' || response.isTruncated === 'true' || false;

    if (!truncated) {
      return undefined;
    }

    return (response.NextContinuationToken ||
      response.nextContinuationToken ||
      response.NextMarker ||
      response.nextMarker) as string | undefined;
  }

  private _extractVersionListToken(response: Record<string, unknown>): string | undefined {
    const truncated = response.IsTruncated === 'true' || response.isTruncated === 'true' || false;
    if (!truncated) {
      return undefined;
    }

    const keyMarker = (response.NextKeyMarker ?? response.nextKeyMarker ?? '') as string;
    const versionIdMarker = (response.NextVersionIdMarker ?? response.nextVersionIdMarker ?? '') as string;
    // Always encode when truncated so the next request can resume correctly
    return this._encodeVersionListToken(String(keyMarker), String(versionIdMarker));
  }

  private async _bunListAll(
    prefix: string,
    maxKeys: number | undefined,
    delimiter: string | undefined,
  ): Promise<IT.ListObject[] | null> {
    const unlimited = !(maxKeys && maxKeys > 0);
    let remaining = unlimited ? Infinity : maxKeys;
    let token: string | undefined;
    const all: IT.ListObject[] = [];

    try {
      do {
        const batchSize = Math.min(remaining === Infinity ? 1000 : remaining, 1000);
        const res = await this._bunFetchPage(prefix, delimiter, batchSize, token);
        const mapped = this._bunMapListResult(res);
        const prev = token;

        token = res.nextContinuationToken;
        all.push(...mapped);

        if (!unlimited) {
          remaining -= mapped.length;
        }

        // Only a page we still need to follow can stall: a missing or repeated token means the
        // next request would either be skipped or replay this one forever.
        if (res.isTruncated && remaining > 0 && (!token || token === prev)) {
          throw new Error(C.ERROR_BUN_PAGINATION_STALLED);
        }
      } while (token && remaining > 0);
    } catch (e) {
      // _fetchObjectBatch tolerates 404 whatever the provider calls it, so a bucket addressed
      // through a path the provider reads as a key (NoSuchKey) must land on null here too.
      if (this._isBunNotFound(e)) {
        return null;
      }
      throw this._bunError(e);
    }

    return all;
  }

  private _bunFetchPage(
    prefix: string,
    delimiter: string | undefined,
    maxKeys: number,
    continuationToken?: string,
  ): Promise<IT.NativeS3ListResult> {
    return this._bun!.list({
      prefix: prefix || undefined,
      // No delimiter means a flat listing, matching the signed-request path.
      delimiter,
      maxKeys,
      ...(continuationToken ? { continuationToken } : {}),
    });
  }

  private _bunMapListResult(res: IT.NativeS3ListResult): IT.ListObject[] {
    const objects: IT.ListObject[] = [];

    if (res.contents) {
      for (const item of res.contents) {
        objects.push({
          Key: item.key,
          Size: item.size,
          LastModified: item.lastModified instanceof Date ? item.lastModified : new Date(item.lastModified),
          ETag: item.eTag ?? '',
          StorageClass: item.storageClass ?? '',
        });
      }
    }

    if (res.commonPrefixes) {
      for (const item of res.commonPrefixes) {
        objects.push({
          Key: item.prefix,
          Size: 0,
          LastModified: new Date(0),
          ETag: '',
          StorageClass: '',
        });
      }
    }

    return objects;
  }

  /**
   * Lists multipart uploads in the bucket.
   * This method sends a request to list multipart uploads in the specified bucket.
   * @param {string} [delimiter='/'] - The delimiter to use for grouping uploads.
   * @param {string} [prefix=''] - The prefix to filter uploads by.
   * @param {IT.HttpMethod} [method='GET'] - The HTTP method to use for the request (GET or HEAD).
   * @param {Record<string, string | number | boolean | undefined>} [opts={}] - Additional options for the request.
   * @returns A promise that resolves to a list of multipart uploads or an error.
   */
  public async listMultipartUploads(
    delimiter: string = '/',
    prefix: string = '',
    method: IT.HttpMethod = 'GET',
    opts: Record<string, string | number | boolean | undefined> = {},
  ): Promise<IT.ListMultipartUploadSuccess | IT.MultipartUploadError> {
    this._checkDelimiter(delimiter);
    this._checkPrefix(prefix);
    this._validateMethodIsGetOrHead(method);
    this._checkOpts(opts);

    const query = { uploads: '', ...opts };
    const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);

    const res = await this._signedRequest(method, keyPath, {
      query,
      withQuery: true,
    });
    // doublecheck if this is needed
    // if (method === 'HEAD') {
    //   return {
    //     size: +(res.headers.get(C.HEADER_CONTENT_LENGTH) ?? '0'),
    //     mtime: res.headers.get(C.HEADER_LAST_MODIFIED) ? new Date(res.headers.get(C.HEADER_LAST_MODIFIED)!) : undefined,
    //     etag: res.headers.get(C.HEADER_ETAG) ?? '',
    //   };
    // }
    const raw = parseXml(await res.text()) as unknown;
    if (typeof raw !== 'object' || raw === null) {
      throw new Error(`${C.ERROR_PREFIX}Unexpected listMultipartUploads response shape`);
    }
    if ('listMultipartUploadsResult' in raw) {
      return raw.listMultipartUploadsResult as IT.ListMultipartUploadSuccess;
    }
    return raw as IT.MultipartUploadError;
  }

  /**
   * Get an object from the S3-compatible service.
   * This method sends a request to retrieve the specified object from the S3-compatible service.
   * @param {string} key - The key of the object to retrieve.
   * @param {Record<string, unknown>} [opts] - Additional options for the request.
   * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
   * @returns A promise that resolves to the object data (string) or null if not found.
   */
  public async getObject(
    key: string,
    opts: Record<string, unknown> = {},
    ssecHeaders?: IT.SSECHeaders,
  ): Promise<string | null> {
    if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
      return this._bunRead(key, f => f.text());
    }
    const res = await this._signedRequest('GET', key, {
      query: opts, // use opts.query if it exists, otherwise use an empty object
      tolerated: [200, 404, 412, 304],
      headers: ssecHeaders ? { ...ssecHeaders } : undefined,
    });
    const s = res.status;
    if (s === 200) {
      return res.text();
    }
    void res.body?.cancel();
    return null;
  }

  /**
   * Get an object response from the S3-compatible service.
   * This method sends a request to retrieve the specified object and returns the full response.
   * @param {string} key - The key of the object to retrieve.
   * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
   * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
   * @returns A promise that resolves to the Response object or null if not found.
   */
  public async getObjectResponse(
    key: string,
    opts: Record<string, unknown> = {},
    ssecHeaders?: IT.SSECHeaders,
  ): Promise<Response | null> {
    const res = await this._signedRequest('GET', key, {
      query: opts,
      tolerated: [200, 404, 412, 304],
      headers: ssecHeaders ? { ...ssecHeaders } : undefined,
    });
    if (res.status === 200) {
      return res;
    }
    void res.body?.cancel();
    return null;
  }

  /**
   * Get an object as an ArrayBuffer from the S3-compatible service.
   * This method sends a request to retrieve the specified object and returns it as an ArrayBuffer.
   * @param {string} key - The key of the object to retrieve.
   * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
   * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
   * @returns A promise that resolves to the object data as an ArrayBuffer or null if not found.
   */
  public async getObjectArrayBuffer(
    key: string,
    opts: Record<string, unknown> = {},
    ssecHeaders?: IT.SSECHeaders,
  ): Promise<ArrayBuffer | null> {
    if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
      return this._bunRead(key, f => f.arrayBuffer());
    }
    const res = await this._signedRequest('GET', key, {
      query: opts,
      tolerated: [200, 404, 412, 304],
      headers: ssecHeaders ? { ...ssecHeaders } : undefined,
    });
    if (res.status === 200) {
      return res.arrayBuffer();
    }
    void res.body?.cancel();
    return null;
  }

  /**
   * Get an object as JSON from the S3-compatible service.
   * This method sends a request to retrieve the specified object and returns it as JSON.
   * @param {string} key - The key of the object to retrieve.
   * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
   * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
   * @returns A promise that resolves to the object data as JSON or null if not found.
   */
  public async getObjectJSON<T = unknown>(
    key: string,
    opts: Record<string, unknown> = {},
    ssecHeaders?: IT.SSECHeaders,
  ): Promise<T | null> {
    if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
      return this._bunRead(key, f => f.json()) as Promise<T | null>;
    }
    const res = await this._signedRequest('GET', key, {
      query: opts,
      tolerated: [200, 404, 412, 304],
      headers: ssecHeaders ? { ...ssecHeaders } : undefined,
    });
    if (res.status === 200) {
      return res.json() as Promise<T>;
    }
    void res.body?.cancel();
    return null;
  }

  /**
   * Get an object with its ETag from the S3-compatible service.
   * This method sends a request to retrieve the specified object and its ETag.
   * @param {string} key - The key of the object to retrieve.
   * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
   * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
   * @returns A promise that resolves to an object containing the ETag and the object data as an ArrayBuffer or null if not found.
   */
  public async getObjectWithETag(
    key: string,
    opts: Record<string, unknown> = {},
    ssecHeaders?: IT.SSECHeaders,
  ): Promise<{ etag: string | null; data: ArrayBuffer | null }> {
    try {
      const res = await this._signedRequest('GET', key, {
        query: opts,
        tolerated: [200, 404, 412, 304],
        headers: ssecHeaders ? { ...ssecHeaders } : undefined,
      });
      const s = res.status;
      if (s === 404 || s === 412 || s === 304) {
        void res.body?.cancel();
        return { etag: null, data: null };
      }

      const etag = res.headers.get(C.HEADER_ETAG);
      if (!etag) {
        throw new Error(`${C.ERROR_PREFIX}ETag not found in response headers`);
      }
      return { etag: sanitizeETag(etag), data: await res.arrayBuffer() };
    } catch (err) {
      this._log('error', `Error getting object ${key} with ETag: ${String(err)}`);
      throw err;
    }
  }

  /**
   * Get an object as a raw response from the S3-compatible service.
   * This method sends a request to retrieve the specified object and returns the raw response.
   * @param {string} key - The key of the object to retrieve.
   * @param {boolean} [wholeFile=true] - Whether to retrieve the whole file or a range.
   * @param {number} [rangeFrom=0] - The starting byte for the range (if not whole file).
   * @param {number} [rangeTo=this.requestSizeInBytes] - The ending byte for the range (if not whole file).
   * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
   * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
   * @returns A promise that resolves to the Response object.
   */
  public async getObjectRaw(
    key: string,
    wholeFile = true,
    rangeFrom = 0,
    rangeTo?: number,
    opts: Record<string, unknown> = {},
    ssecHeaders?: IT.SSECHeaders,
  ): Promise<Response> {
    let rangeHdr: Record<string, string | number> = {};

    if (!wholeFile) {
      rangeHdr =
        rangeTo === undefined ? { range: `bytes=${rangeFrom}-` } : { range: `bytes=${rangeFrom}-${rangeTo - 1}` };
    }
    return this._signedRequest('GET', key, {
      query: { ...opts },
      headers: { ...rangeHdr, ...ssecHeaders },
      withQuery: true, // keep ?query=string behaviour
    });
  }

  /**
   * Get the content length of an object.
   * This method sends a HEAD request to retrieve the content length of the specified object.
   * @param {string} key - The key of the object to retrieve the content length for.
   * @returns A promise that resolves to the content length of the object in bytes; 0 when the object exists but the response carries no content-length header.
   * @throws {Error} If the object does not exist (HTTP 404) or the request otherwise fails; the underlying S3ServiceError is attached as `.cause`.
   */
  public async getContentLength(key: string, ssecHeaders?: IT.SSECHeaders): Promise<number> {
    try {
      if (this._bun && !ssecHeaders) {
        try {
          return (await this._bun.file(key).stat()).size;
        } catch (e) {
          throw this._bunError(e);
        }
      }
      const res = await this._signedRequest('HEAD', key, {
        headers: ssecHeaders ? { ...ssecHeaders } : undefined,
      });
      const len = res.headers.get(C.HEADER_CONTENT_LENGTH);
      return len ? +len : 0;
    } catch (err) {
      this._log('error', `Error getting content length for object ${key}: ${String(err)}`);
      throw new Error(`${C.ERROR_PREFIX}Error getting content length for object ${key}: ${String(err)}`, {
        cause: err,
      });
    }
  }

  /**
   * Checks if an object exists in the S3-compatible service.
   * This method sends a HEAD request to check if the specified object exists.
   * @param {string} key - The key of the object to check.
   * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
   * @returns A promise that resolves to true if the object exists, false if not found, or null if ETag mismatch.
   */
  public async objectExists(key: string, opts: Record<string, unknown> = {}): Promise<IT.ExistResponseCode> {
    if (this._bun && !Object.keys(opts).length) {
      try {
        return await this._bun.file(key).exists();
      } catch (e) {
        throw this._bunError(e);
      }
    }
    const res = await this._signedRequest('HEAD', key, {
      query: opts,
      tolerated: [200, 404, 412, 304],
    });

    if (res.status === 404) {
      return false; // not found
    }
    if (res.status === 412 || res.status === 304) {
      return null; // ETag mismatch
    }
    return true; // found (200)
  }

  /**
   * Retrieves the ETag of an object without downloading its content.
   * @param {string} key - The key of the object to retrieve the ETag for.
   * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
   * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
   * @returns {Promise<string | null>} A promise that resolves to the ETag value or null if the object is not found.
   * @throws {Error} If the ETag header is not found in the response.
   * @example
   * const etag = await s3.getEtag('path/to/file.txt');
   * if (etag) {
   *   console.log(`File ETag: ${etag}`);
   * }
   */
  public async getEtag(
    key: string,
    opts: Record<string, unknown> = {},
    ssecHeaders?: IT.SSECHeaders,
  ): Promise<string | null> {
    if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
      return this._bunRead(key, async f => {
        const { etag } = await f.stat();
        if (!etag) {
          throw new Error(`${C.ERROR_PREFIX}ETag not found in response headers`);
        }
        return sanitizeETag(etag);
      });
    }
    const res = await this._signedRequest('HEAD', key, {
      query: opts,
      tolerated: [200, 304, 404, 412],
      headers: ssecHeaders ? { ...ssecHeaders } : undefined,
    });

    if (res.status === 404) {
      return null;
    }

    if (res.status === 412 || res.status === 304) {
      return null; // ETag mismatch
    }

    const etag = res.headers.get(C.HEADER_ETAG);
    if (!etag) {
      throw new Error(`${C.ERROR_PREFIX}ETag not found in response headers`);
    }

    return sanitizeETag(etag);
  }

  /**
   * Uploads an object to the S3-compatible service.
   * @param {string} key - The key/path where the object will be stored.
   * @param {string | IT.MaybeBuffer | ReadableStream | File | Blob} data - The data to upload (string or Buffer).
   * @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
   * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
   * @param {IT.AWSHeaders} [additionalHeaders] - Additional x-amz-* headers specific to this request, if any.
   * @returns {Promise<Response>} A promise that resolves to the Response object from the upload request.
   * @throws {TypeError} If data is not a string or Buffer.
   * @example
   * // Upload text file
   * await s3.putObject('hello.txt', 'Hello, World!', 'text/plain');
   *
   * // Upload binary data
   * const buffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
   * await s3.putObject('image.png', buffer, 'image/png');
   */
  public async putObject(
    key: string,
    data: string | IT.DataInput | ReadableStream | File | Blob,
    fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
    ssecHeaders?: IT.SSECHeaders,
    additionalHeaders?: IT.AWSHeaders,
    contentLength?: number,
  ): Promise<Response> {
    const size = contentLength ?? getByteSize(data);
    return this._signedRequest('PUT', key, {
      body: data as BodyInit,
      headers: {
        ...(size && { [C.HEADER_CONTENT_LENGTH]: size }),
        [C.HEADER_CONTENT_TYPE]: fileType,
        ...additionalHeaders,
        ...ssecHeaders,
      },
      tolerated: [200],
    });
  }

  /**
   * Put object that automatically chooses single PUT vs multipart.
   * Same signature/shape as putObject so callers don't need to change.
   * @param {string} key - The key/path where the object will be stored.
   * @param {string | IT.MaybeBuffer | ReadableStream | File | Blob} data - The data to upload (string or Buffer).
   * @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
   * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
   * @param {IT.AWSHeaders} [additionalHeaders] - Additional x-amz-* headers specific to this request, if any.
   * @param {number} [contentLength] - Optional known content length of data.
   * @returns {Promise<Response | { ok: boolean; status: number; headers: Map<string, string> }>} A promise that resolves to the Response object from the upload request.
   * @throws {TypeError} If data is not a string or Buffer.
   * @example
   * // Upload text file
   * await s3.putAnyObject('hello.txt', 'Hello, World!', 'text/plain');
   *
   * // Upload binary data
   * const buffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
   * await s3.putAnyObject('image.png', buffer, 'image/png');
   */
  public async putAnyObject(
    key: string,
    data: IT.DataInput,
    fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
    ssecHeaders?: IT.SSECHeaders,
    additionalHeaders?: IT.AWSHeaders,
    contentLength?: number,
  ): Promise<Response | { ok: boolean; status: number; headers: Map<string, string> }> {
    const size = contentLength ?? getByteSize(data);

    // Single PUT for small files
    if (!Number.isNaN(size) && size <= this.minPartSize) {
      return this.putObject(key, data, fileType, ssecHeaders, additionalHeaders, contentLength);
    }

    this._checkKey(key);
    return this._multipartUpload(key, data, fileType, ssecHeaders, additionalHeaders);
  }

  private async _multipartUpload(
    key: string,
    data: IT.DataInput,
    fileType: string,
    ssecHeaders?: IT.SSECHeaders,
    additionalHeaders?: IT.AWSHeaders,
  ): Promise<Response | { ok: boolean; status: number; headers: Map<string, string> }> {
    const uploadId = await this.getMultipartUploadId(key, fileType, ssecHeaders, additionalHeaders);

    try {
      const parts = await this._uploadPartsOptimized(key, uploadId, data, ssecHeaders, additionalHeaders);
      parts.sort((a, b) => a.partNumber - b.partNumber);
      const result = await this.completeMultipartUpload(key, uploadId, parts);
      return this._createSuccessResponse(result.etag || '');
    } catch (err) {
      await this._safeAbortUpload(key, uploadId);
      throw err;
    }
  }

  private async _uploadKnownSizePartsParallel(
    key: string,
    uploadId: string,
    data: Uint8Array | Blob,
    ssecHeaders?: IT.SSECHeaders,
    additionalHeaders?: IT.AWSHeaders,
    concurrency: number = 4,
    maxRetries: number = 3,
  ): Promise<IT.UploadPart[]> {
    const partSize = this.minPartSize;
    const totalSize = data instanceof Blob ? data.size : data.byteLength;
    const totalParts = Math.ceil(totalSize / partSize);
    const results: IT.UploadPart[] = new Array(totalParts) as IT.UploadPart[];
    let nextIndex = 0;

    const worker = async (): Promise<void> => {
      while (true) {
        const index = nextIndex++;
        if (index >= totalParts) {
          return;
        }

        const start = index * partSize;
        const end = Math.min(start + partSize, totalSize);
        const part =
          data instanceof Blob
            ? await data.slice(start, end).arrayBuffer() // Must await - R2 needs actual bytes
            : data.subarray(start, end);

        results[index] = await this._uploadPartWithRetry(
          key,
          uploadId,
          part,
          index + 1,
          ssecHeaders,
          additionalHeaders,
          maxRetries,
        );
      }
    };

    await Promise.all(Array.from({ length: Math.min(concurrency, totalParts) }, () => worker()));
    return results;
  }

  private async _uploadPartsOptimized(
    key: string,
    uploadId: string,
    data: IT.DataInput,
    ssecHeaders?: IT.SSECHeaders,
    additionalHeaders?: IT.AWSHeaders,
    concurrency: number = 4,
    maxRetries: number = 3,
  ): Promise<IT.UploadPart[]> {
    const bytes = toUint8Array(data);
    if (bytes) {
      return this._uploadKnownSizePartsParallel(
        key,
        uploadId,
        bytes,
        ssecHeaders,
        additionalHeaders,
        concurrency,
        maxRetries,
      );
    }
    if (data instanceof Blob) {
      return this._uploadKnownSizePartsParallel(
        key,
        uploadId,
        data,
        ssecHeaders,
        additionalHeaders,
        concurrency,
        maxRetries,
      );
    }
    return this._uploadStreamingParts(
      key,
      uploadId,
      data as ReadableStream,
      ssecHeaders,
      additionalHeaders,
      concurrency,
      maxRetries,
    );
  }

  private async _uploadStreamingParts(
    key: string,
    uploadId: string,
    stream: ReadableStream,
    ssecHeaders?: IT.SSECHeaders,
    additionalHeaders?: IT.AWSHeaders,
    concurrency: number = 4,
    maxRetries: number = 3,
  ): Promise<IT.UploadPart[]> {
    const parts: IT.UploadPart[] = [];
    const active = new Set<Promise<void>>();
    let partNumber = 0;

    for await (const partData of generateParts(stream, this.minPartSize)) {
      const currentPartNumber = ++partNumber;

      while (active.size >= concurrency) {
        await Promise.race(active);
      }

      const p = this._uploadPartWithRetry(
        key,
        uploadId,
        partData,
        currentPartNumber,
        ssecHeaders,
        additionalHeaders,
        maxRetries,
      ).then(part => {
        parts.push(part);
        active.delete(p);
      });

      active.add(p);
    }

    await Promise.all(active);
    return parts;
  }

  private async _uploadPartWithRetry(
    key: string,
    uploadId: string,
    data: IT.PartData,
    partNumber: number,
    ssecHeaders?: IT.SSECHeaders,
    additionalHeaders?: IT.AWSHeaders,
    maxRetries: number = 3,
  ): Promise<IT.UploadPart> {
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        return await this.uploadPart(key, uploadId, data, partNumber, {}, ssecHeaders, additionalHeaders);
      } catch (err) {
        if (attempt === maxRetries) {
          throw err;
        }
        await new Promise(r => setTimeout(r, Math.min(1000 * 2 ** attempt, 10000)));
      }
    }
    throw new Error('Unreachable');
  }

  private async _safeAbortUpload(key: string, uploadId: string): Promise<void> {
    try {
      await this.abortMultipartUpload(key, uploadId);
    } catch (err) {
      this._log('warn', `Failed to abort multipart upload: ${String(err)}`);
    }
  }

  private _createSuccessResponse(
    etag: string,
  ): Response | { ok: boolean; status: number; headers: Map<string, string> } {
    if (typeof Response !== 'undefined') {
      const headers = new Headers();
      if (etag) {
        headers.set('ETag', etag);
      }
      return new Response('', { status: 200, headers });
    }
    return { ok: true, status: 200, headers: new Map([['ETag', etag]]) };
  }

  /**
   * Initiates a multipart upload and returns the upload ID.
   * @param {string} key - The key/path where the object will be stored.
   * @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
   * @param {IT.SSECHeaders?} [ssecHeaders] - Server-Side Encryption headers, if any.
   * @returns {Promise<string>} A promise that resolves to the upload ID for the multipart upload.
   * @throws {TypeError} If key is invalid or fileType is not a string.
   * @throws {Error} If the multipart upload fails to initialize.
   * @example
   * const uploadId = await s3.getMultipartUploadId('large-file.zip', 'application/zip');
   * console.log(`Started multipart upload: ${uploadId}`);
   */
  public async getMultipartUploadId(
    key: string,
    fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
    ssecHeaders?: IT.SSECHeaders,
    additionalHeaders?: IT.AWSHeaders,
  ): Promise<string> {
    this._checkKey(key);
    if (typeof fileType !== 'string') {
      throw new TypeError(`${C.ERROR_PREFIX}fileType must be a string`);
    }
    const query = { uploads: '' };
    const headers = { [C.HEADER_CONTENT_TYPE]: fileType, ...ssecHeaders, ...additionalHeaders };

    const res = await this._signedRequest('POST', key, {
      query,
      headers,
      withQuery: true,
    });
    const parsed = parseXml(await res.text()) as Record<string, unknown>;

    if (parsed && typeof parsed === 'object') {
      // Check for both cases of InitiateMultipartUploadResult
      const uploadResult =
        (parsed.initiateMultipartUploadResult as Record<string, unknown>) ||
        (parsed.InitiateMultipartUploadResult as Record<string, unknown>);

      if (uploadResult && typeof uploadResult === 'object') {
        // Check for both cases of uploadId
        const uploadId = uploadResult.uploadId || uploadResult.UploadId;

        if (uploadId && typeof uploadId === 'string') {
          return uploadId;
        }
      }
    }

    throw new Error(`${C.ERROR_PREFIX}Failed to create multipart upload: ${JSON.stringify(parsed)}`);
  }

  /**
   * Uploads a part in a multipart upload.
   * @param {string} key - The key of the object being uploaded.
   * @param {string} uploadId - The upload ID from getMultipartUploadId.
   * @param {string | IT.MaybeBuffer | ReadableStream | File | Blob} data - The data for this part.
   * @param {number} partNumber - The part number (must be between 1 and 10,000).
   * @param {Record<string, unknown>} [opts={}] - Additional options for the request.
   * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
   * @returns {Promise<IT.UploadPart>} A promise that resolves to an object containing the partNumber and etag.
   * @throws {TypeError} If any parameter is invalid.
   * @example
   * const part = await s3.uploadPart(
   *   'large-file.zip',
   *   uploadId,
   *   partData,
   *   1
   * );
   * console.log(`Part ${part.partNumber} uploaded with ETag: ${part.etag}`);
   */
  public async uploadPart(
    key: string,
    uploadId: string,
    data: IT.DataInput,
    partNumber: number,
    opts: Record<string, unknown> = {},
    ssecHeaders?: IT.SSECHeaders,
    additionalHeaders?: IT.AWSHeaders,
  ): Promise<IT.UploadPart> {
    const body = this._validateUploadPartParams(key, uploadId, data, partNumber, opts);

    const query = { uploadId, partNumber, ...opts };
    const size = getByteSize(data);
    const res = await this._signedRequest('PUT', key, {
      query,
      body,
      headers: {
        ...(size && !Number.isNaN(size) && { [C.HEADER_CONTENT_LENGTH]: size }),
        ...ssecHeaders,
        ...additionalHeaders,
      },
    });

    return { partNumber, etag: sanitizeETag(res.headers.get('etag') || '') };
  }

  /**
   * Completes a multipart upload by combining all uploaded parts.
   * @param {string} key - The key of the object being uploaded.
   * @param {string} uploadId - The upload ID from getMultipartUploadId.
   * @param {Array<IT.UploadPart>} parts - Array of uploaded parts with partNumber and etag.
   * @returns {Promise<IT.CompleteMultipartUploadResult>} A promise that resolves to the completion result containing the final ETag.
   * @throws {Error} If the multipart upload fails to complete.
   * @example
   * const result = await s3.completeMultipartUpload(
   *   'large-file.zip',
   *   uploadId,
   *   [
   *     { partNumber: 1, etag: 'abc123' },
   *     { partNumber: 2, etag: 'def456' }
   *   ]
   * );
   * console.log(`Upload completed with ETag: ${result.etag}`);
   */
  public async completeMultipartUpload(
    key: string,
    uploadId: string,
    parts: Array<IT.UploadPart>,
  ): Promise<IT.CompleteMultipartUploadResult> {
    const query = { uploadId };
    const xmlBody = this._buildCompleteMultipartUploadXml(parts);
    const headers = {
      [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
      [C.HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
    };

    const res = await this._signedRequest('POST', key, {
      query,
      body: xmlBody,
      headers,
      withQuery: true,
    });

    const parsed = parseXml(await res.text()) as Record<string, unknown>;
    if (parsed && typeof parsed === 'object') {
      // Check for both cases
      const result = parsed.completeMultipartUploadResult || parsed.CompleteMultipartUploadResult || parsed;

      if (result && typeof result === 'object') {
        const resultObj = result as Record<string, unknown>;

        // Handle ETag in all its variations
        const etag = resultObj.ETag || resultObj.eTag || resultObj.etag;
        if (etag && typeof etag === 'string') {
          return {
            ...resultObj,
            etag: sanitizeETag(etag),
          } as IT.CompleteMultipartUploadResult;
        }

        return result as IT.CompleteMultipartUploadResult;
      }
    }

    throw new Error(`${C.ERROR_PREFIX}Failed to complete multipart upload: ${JSON.stringify(parsed)}`);
  }

  /**
   * Aborts a multipart upload and removes all uploaded parts.
   * @param {string} key - The key of the object being uploaded.
   * @param {string} uploadId - The upload ID to abort.
   * @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
   * @returns {Promise<object>} A promise that resolves to an object containing the abort status and details.
   * @throws {TypeError} If key or uploadId is invalid.
   * @throws {Error} If the abort operation fails.
   * @example
   * try {
   *   const result = await s3.abortMultipartUpload('large-file.zip', uploadId);
   *   console.log('Upload aborted:', result.status);
   * } catch (error) {
   *   console.error('Failed to abort upload:', error);
   * }
   */
  public async abortMultipartUpload(key: string, uploadId: string, ssecHeaders?: IT.SSECHeaders): Promise<object> {
    this._checkKey(key);
    if (!uploadId) {
      throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED);
    }

    const query = { uploadId };
    const headers = { [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE, ...(ssecHeaders ? { ...ssecHeaders } : {}) };

    const res = await this._signedRequest('DELETE', key, {
      query,
      headers,
      withQuery: true,
    });
    const parsed = parseXml(await res.text()) as Record<string, unknown>;
    if (
      parsed &&
      'error' in parsed &&
      typeof parsed.error === 'object' &&
      parsed.error !== null &&
      'message' in parsed.error
    ) {
      this._log('error', `${C.ERROR_PREFIX}Failed to abort multipart upload: ${String(parsed.error.message)}`);
      throw new Error(`${C.ERROR_PREFIX}Failed to abort multipart upload: ${String(parsed.error.message)}`);
    }
    return { status: 'Aborted', key, uploadId, response: parsed };
  }

  private _buildCompleteMultipartUploadXml(parts: Array<IT.UploadPart>): string {
    let xml = '<CompleteMultipartUpload>';
    for (const part of parts) {
      xml += `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${part.etag}</ETag></Part>`;
    }
    xml += '</CompleteMultipartUpload>';
    return xml;
  }

  /**
   * Executes the copy operation for local copying (same bucket/endpoint).
   * @private
   */
  private async _executeCopyOperation(
    destinationKey: string,
    copySource: string,
    options: IT.CopyObjectOptions,
  ): Promise<IT.CopyObjectResult> {
    const {
      metadataDirective = 'COPY',
      metadata = {},
      contentType,
      storageClass,
      taggingDirective,
      websiteRedirectLocation,
      sourceSSECHeaders = {},
      destinationSSECHeaders = {},
      additionalHeaders = {},
    } = options;

    const headers: Record<string, string | number> = {
      'x-amz-copy-source': copySource,
      'x-amz-metadata-directive': metadataDirective,
      ...additionalHeaders,
      ...(contentType && { [C.HEADER_CONTENT_TYPE]: contentType }),
      ...(storageClass && { 'x-amz-storage-class': storageClass }),
      ...(taggingDirective && { 'x-amz-tagging-directive': taggingDirective }),
      ...(websiteRedirectLocation && { 'x-amz-website-redirect-location': websiteRedirectLocation }),
      ...this._buildSSECHeaders(sourceSSECHeaders, destinationSSECHeaders),
      ...(metadataDirective === 'REPLACE' ? this._buildMetadataHeaders(metadata) : {}),
    };

    try {
      const res = await this._signedRequest('PUT', destinationKey, {
        headers,
        tolerated: [200],
      });
      const versionId = res.headers.get(C.HEADER_AMZ_VERSION_ID) ?? undefined;
      const result = this._parseCopyObjectResponse(await res.text());
      return versionId ? { ...result, versionId } : result;
    } catch (err) {
      this._log('error', `Error in copy operation to ${destinationKey}`, {
        error: String(err),
      });
      throw err;
    }
  }

  /**
   * Copies an object within the same bucket.
   *
   * @param {string} sourceKey - The key of the source object to copy
   * @param {string} destinationKey - The key where the object will be copied to
   * @param {IT.CopyObjectOptions} [options={}] - Copy operation options
   * @param {string} [options.versionId] - Source object version to copy (for versioned buckets)
   * @param {string} [options.metadataDirective='COPY'] - How to handle metadata ('COPY' | 'REPLACE')
   * @param {Record<string,string>} [options.metadata={}] - New metadata (only used if metadataDirective='REPLACE')
   * @param {string} [options.contentType] - New content type for the destination object
   * @param {string} [options.storageClass] - Storage class for the destination object
   * @param {string} [options.taggingDirective] - How to handle object tags ('COPY' | 'REPLACE')
   * @param {string} [options.websiteRedirectLocation] - Website redirect location for the destination
   * @param {IT.SSECHeaders} [options.sourceSSECHeaders={}] - Encryption headers for reading source (if encrypted)
   * @param {IT.SSECHeaders} [options.destinationSSECHeaders={}] - Encryption headers for destination
   * @param {IT.AWSHeaders} [options.additionalHeaders={}] - Extra x-amz-* headers
   *
   * @returns {Promise<IT.CopyObjectResult>} Copy result with etag, lastModified date, and `versionId` of the new object version (versioned buckets)
   * @throws {TypeError} If sourceKey or destinationKey is invalid
   * @throws {Error} If copy operation fails or S3 returns an error
   *
   * @example
   * // Simple copy
   * const result = await s3.copyObject('report-2024.pdf', 'archive/report-2024.pdf');
   * console.log(`Copied with ETag: ${result.etag}`);
   *
   * @example
   * // Restore an older version onto the same key
   * await s3.copyObject('file.jpg', 'file.jpg', { versionId: 'older-version-id' });
   *
   * @example
   * // Copy with new metadata and content type
   * const result = await s3.copyObject('data.csv', 'processed/data.csv', {
   *   metadataDirective: 'REPLACE',
   *   metadata: {
   *     'processed-date': new Date().toISOString(),
   *     'original-name': 'data.csv'
   *   },
   *   contentType: 'text/csv; charset=utf-8'
   * });
   *
   * @example
   * // Copy encrypted object (Cloudflare R2 SSE-C)
   * const ssecKey = 'n1TKiTaVHlYLMX9n0zHXyooMr026vOiTEFfT+719Hho=';
   * await s3.copyObject('sensitive.json', 'backup/sensitive.json', {
   *   sourceSSECHeaders: {
   *     'x-amz-copy-source-server-side-encryption-customer-algorithm': 'AES256',
   *     'x-amz-copy-source-server-side-encryption-customer-key': ssecKey,
   *     'x-amz-copy-source-server-side-encryption-customer-key-md5': 'gepZmzgR7Be/1+K1Aw+6ow=='
   *   },
   *   destinationSSECHeaders: {
   *     'x-amz-server-side-encryption-customer-algorithm': 'AES256',
   *     'x-amz-server-side-encryption-customer-key': ssecKey,
   *     'x-amz-server-side-encryption-customer-key-md5': 'gepZmzgR7Be/1+K1Aw+6ow=='
   *   }
   * });
   */
  public copyObject(
    sourceKey: string,
    destinationKey: string,
    options: IT.CopyObjectOptions = {},
  ): Promise<IT.CopyObjectResult> {
    // Validate parameters
    this._checkKey(sourceKey);
    this._checkKey(destinationKey);

    let copySource = `/${this.bucketName}/${uriEscape(sourceKey)}`;
    if (options.versionId) {
      copySource += `?versionId=${encodeURIComponent(options.versionId)}`;
    }

    return this._executeCopyOperation(destinationKey, copySource, options);
  }

  private _buildSSECHeaders(
    sourceHeaders: Record<string, string | number>,
    destHeaders: Record<string, string | number>,
  ): Record<string, string | number> {
    const headers: Record<string, string | number> = {};
    for (const [k, v] of Object.entries({ ...sourceHeaders, ...destHeaders })) {
      if (v !== undefined) {
        headers[k] = v;
      }
    }
    return headers;
  }

  /**
   * Moves an object within the same bucket (copy + delete atomic-like operation).
   *
   * WARNING: Not truly atomic - if delete fails after successful copy, the object
   * will exist in both locations. Consider your use case carefully.
   *
   * @param {string} sourceKey - The key of the source object to move
   * @param {string} destinationKey - The key where the object will be moved to
   * @param {IT.CopyObjectOptions} [options={}] - Options passed to the copy operation
   *
   * @returns {Promise<IT.CopyObjectResult>} Result from the copy operation
   * @throws {TypeError} If sourceKey or destinationKey is invalid
   * @throws {Error} If copy succeeds but delete fails (includes copy result in error)
   *
   * @example
   * // Simple move
   * await s3.moveObject('temp/upload.tmp', 'files/document.pdf');
   *
   * @example
   * // Move with metadata update
   * await s3.moveObject('unprocessed/image.jpg', 'processed/image.jpg', {
   *   metadataDirective: 'REPLACE',
   *   metadata: {
   *     'status': 'processed',
   *     'processed-at': Date.now().toString()
   *   },
   *   contentType: 'image/jpeg'
   * });
   *
   * @example
   * // Safe move with error handling
   * try {
   *   const result = await s3.moveObject('inbox/file.dat', 'archive/file.dat');
   *   console.log(`Moved successfully: ${result.etag}`);
   * } catch (error) {
   *   // Check if copy succeeded but delete failed
   *   if (error.message.includes('delete source object after successful copy')) {
   *     console.warn('File copied but not deleted from source - manual cleanup needed');
   *   }
   * }
   */
  public async moveObject(
    sourceKey: string,
    destinationKey: string,
    options: IT.CopyObjectOptions = {},
  ): Promise<IT.CopyObjectResult> {
    try {
      // First copy the object
      const copyResult = await this.copyObject(sourceKey, destinationKey, options);

      // Then delete the source
      const deleteSuccess = await this.deleteObject(sourceKey);
      if (!deleteSuccess) {
        throw new Error(`${C.ERROR_PREFIX}Failed to delete source object after successful copy`);
      }

      return copyResult;
    } catch (err) {
      this._log('error', `Error moving object from ${sourceKey} to ${destinationKey}`, {
        error: String(err),
      });
      throw err;
    }
  }

  private _buildMetadataHeaders(metadata: Record<string, string>): Record<string, string> {
    const headers: Record<string, string> = {};
    for (const [k, v] of Object.entries(metadata)) {
      headers[k.startsWith('x-amz-meta-') ? k : `x-amz-meta-${k}`] = v;
    }
    return headers;
  }

  private _parseCopyObjectResponse(xmlText: string): IT.CopyObjectResult {
    const parsed = parseXml(xmlText) as Record<string, unknown>;
    if (!parsed || typeof parsed !== 'object') {
      throw new Error(`${C.ERROR_PREFIX}Unexpected copyObject response format`);
    }
    const result = (parsed.CopyObjectResult || parsed.copyObjectResult || parsed) as Record<string, unknown>;
    const etag = result.ETag || result.eTag || result.etag;
    const lastModified = result.LastModified || result.lastModified;
    if (!etag || typeof etag !== 'string') {
      throw new Error(`${C.ERROR_PREFIX}ETag not found in copyObject response`);
    }
    return {
      etag: sanitizeETag(etag),
      lastModified: lastModified ? new Date(lastModified as string) : undefined,
    };
  }

  /**
   * Deletes an object from the bucket.
   * Accepts either a key string or a {@link IT.DeleteObject} with optional `versionId`
   * for permanently removing a specific version on a versioned bucket.
   *
   * By default resolves to a boolean. Pass `{ versionInfo: true }` to receive a
   * {@link IT.DeleteObjectResult} carrying the deleted `versionId`, whether a delete
   * marker was created (`deleteMarker`), and the new marker's `deleteMarkerVersionId`.
   * Requesting version info forces the signed HTTP path (Bun's native delete can't
   * surface those response headers).
   * @param {string | IT.DeleteObject} target - Object key or `{ key, versionId? }`.
   * @param {{ versionInfo?: boolean }} [options] - Pass `{ versionInfo: true }` for a detailed result.
   * @returns Boolean, or {@link IT.DeleteObjectResult} when `versionInfo` is set.
   * @example
   * await s3.deleteObject('file.jpg');
   * await s3.deleteObject({ key: 'file.jpg', versionId: 'abc123' });
   * const info = await s3.deleteObject('file.jpg', { versionInfo: true });
   * if (info.deleteMarker) console.log(info.deleteMarkerVersionId);
   */
  public async deleteObject(target: string | IT.DeleteObject): Promise<boolean>;
  public async deleteObject(
    target: string | IT.DeleteObject,
    options: { versionInfo: true },
  ): Promise<IT.DeleteObjectResult>;
  public async deleteObject(
    target: string | IT.DeleteObject,
    options: { versionInfo?: boolean } = {},
  ): Promise<boolean | IT.DeleteObjectResult> {
    const { key, versionId } = this._normalizeDeleteTarget(target);
    const wantInfo = options.versionInfo === true;

    // Bun-native delete can't surface version/delete-marker headers — use the signed path when info is requested.
    if (this._bun && !versionId && !wantInfo) {
      try {
        await this._bun.file(key).delete();
      } catch (e) {
        throw this._bunError(e);
      }
      return true;
    }

    const res = await this._signedRequest('DELETE', key, {
      query: versionId ? { versionId } : {},
      tolerated: [200, 204],
    });
    const deleted = res.status === 200 || res.status === 204;
    if (!wantInfo) {
      return deleted;
    }

    const versionIdHeader = res.headers.get(C.HEADER_AMZ_VERSION_ID) ?? undefined;
    if (res.headers.get(C.HEADER_AMZ_DELETE_MARKER) === 'true') {
      // A delete marker was created; S3 returns its id in the x-amz-version-id header.
      return {
        key,
        deleted,
        deleteMarker: true,
        ...(versionIdHeader ? { deleteMarkerVersionId: versionIdHeader } : {}),
      };
    }
    const resolvedVersionId = versionIdHeader ?? versionId;
    return {
      key,
      deleted,
      ...(resolvedVersionId ? { versionId: resolvedVersionId } : {}),
    };
  }

  private _normalizeDeleteTarget(target: string | IT.DeleteObject): IT.DeleteObject {
    if (typeof target === 'string') {
      this._checkKey(target);
      return { key: target };
    }
    if (!target || typeof target !== 'object' || typeof target.key !== 'string') {
      this._log('error', `${C.ERROR_PREFIX}delete target must be a key string or { key, versionId? }`);
      throw new TypeError(`${C.ERROR_PREFIX}delete target must be a key string or { key, versionId? }`);
    }
    this._checkKey(target.key);
    if (target.versionId !== undefined && typeof target.versionId !== 'string') {
      throw new TypeError(`${C.ERROR_PREFIX}versionId must be a string when provided`);
    }
    return {
      key: target.key,
      ...(target.versionId ? { versionId: target.versionId } : {}),
    };
  }

  private _deleteTargetId(target: IT.DeleteObject): string {
    return target.versionId ? `${target.key}\0${target.versionId}` : target.key;
  }

  private async _deleteObjectsProcess(targets: IT.DeleteObject[]): Promise<boolean[]> {
    const out = await this._sendDeleteRequest(targets);
    const matches = this._resolveDeleteMatches(out, targets);
    const boolMap = new Map<string, boolean>();
    for (const [id, m] of matches) {
      boolMap.set(id, m.deleted);
    }
    this._logDeleteErrors(out, boolMap);
    return targets.map(t => matches.get(this._deleteTargetId(t))?.deleted ?? false);
  }

  private async _deleteObjectsProcessInfo(targets: IT.DeleteObject[]): Promise<IT.DeleteObjectResult[]> {
    const out = await this._sendDeleteRequest(targets);
    const matches = this._resolveDeleteMatches(out, targets);
    const boolMap = new Map<string, boolean>();
    for (const [id, m] of matches) {
      boolMap.set(id, m.deleted);
    }
    this._logDeleteErrors(out, boolMap);
    return targets.map(t => {
      const id = this._deleteTargetId(t);
      const entry = matches.get(id)?.entry;
      const entryVersionId = entry ? ((entry.versionId || entry.VersionId) as string | undefined) : undefined;
      const entryDeleteMarkerVersionId = entry
        ? ((entry.deleteMarkerVersionId || entry.DeleteMarkerVersionId) as string | undefined)
        : undefined;
      const isDeleteMarker = entry
        ? (entry.deleteMarker ?? entry.DeleteMarker) === true ||
          (entry.deleteMarker ?? entry.DeleteMarker) === 'true'
        : false;
      const deleted = matches.get(id)?.deleted ?? false;
      if (isDeleteMarker) {
        // Bulk delete: AWS returns the marker id in <DeleteMarkerVersionId>.
        const markerVersionId = entryDeleteMarkerVersionId ?? entryVersionId;
        return {
          key: t.key,
          deleted,
          deleteMarker: true,
          ...(markerVersionId ? { deleteMarkerVersionId: markerVersionId } : {}),
        };
      }
      return {
        key: t.key,
        deleted,
        ...((entryVersionId || t.versionId) ? { versionId: entryVersionId ?? t.versionId } : {}),
      };
    });
  }

  private async _sendDeleteRequest(targets: IT.DeleteObject[]): Promise<Record<string, unknown>> {
    const objectsXml = targets
      .map(t => {
        const versionXml = t.versionId ? `<VersionId>${escapeXml(t.versionId)}</VersionId>` : '';
        return `<Object><Key>${escapeXml(t.key)}</Key>${versionXml}</Object>`;
      })
      .join('');
    const xmlBody = '<Delete>' + objectsXml + '</Delete>';
    const sha256base64 = base64FromBuffer(await sha256(xmlBody));

    const res = await this._signedRequest('POST', '', {
      query: { delete: '' },
      body: xmlBody,
      headers: {
        [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
        [C.HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
        [C.HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
      },
      withQuery: true,
    });

    const parsed = parseXml(await res.text()) as Record<string, unknown>;
    if (!parsed || typeof parsed !== 'object') {
      throw new Error(`${C.ERROR_PREFIX}Failed to delete objects: ${JSON.stringify(parsed)}`);
    }
    return (parsed.DeleteResult || parsed.deleteResult || parsed) as Record<string, unknown>;
  }

  private _deleteVersionMatches(t: IT.DeleteObject, key: string, versionId?: string): boolean {
    if (t.key !== key) {
      return false;
    }
    if (versionId && t.versionId && t.versionId !== versionId) {
      return false;
    }
    // Response without a version cannot satisfy a request that asked for one.
    if (!versionId && t.versionId) {
      return false;
    }
    return true;
  }

  // Mutates `matches`: marks the first not-yet-deleted target matching key/versionId.
  // Uses `return` (not `break`) on a hit — nothing follows the loop, so don't "fix" it back.
  private _markFirstPendingDeleted(
    targets: IT.DeleteObject[],
    key: string,
    versionId: string | undefined,
    matches: Map<string, { deleted: boolean; entry?: Record<string, unknown> }>,
    entry: Record<string, unknown>,
  ): void {
    for (const t of targets) {
      if (!this._deleteVersionMatches(t, key, versionId)) {
        continue;
      }
      const id = this._deleteTargetId(t);
      const cur = matches.get(id);
      if (cur && !cur.deleted) {
        matches.set(id, { deleted: true, entry });
        return;
      }
    }
  }

  private _resolveDeleteMatches(
    out: Record<string, unknown>,
    targets: IT.DeleteObject[],
  ): Map<string, { deleted: boolean; entry?: Record<string, unknown> }> {
    const matches = new Map<string, { deleted: boolean; entry?: Record<string, unknown> }>(
      targets.map(t => [this._deleteTargetId(t), { deleted: false }]),
    );

    const deleted = out.deleted || out.Deleted;
    if (!deleted) {
      return matches;
    }

    for (const item of this._asArray(deleted)) {
      if (!item || typeof item !== 'object') {
        continue;
      }
      const entry = item as Record<string, unknown>;
      const key = entry.key || entry.Key;
      if (!key || typeof key !== 'string') {
        continue;
      }
      const versionId = (entry.versionId || entry.VersionId) as string | undefined;
      const id = this._deleteTargetId({ key, versionId });

      const direct = matches.get(id);
      if (direct && !direct.deleted) {
        matches.set(id, { deleted: true, entry });
        continue;
      }
      this._markFirstPendingDeleted(targets, key, versionId, matches, entry);
    }
    return matches;
  }

  private _logDeleteErrors(out: Record<string, unknown>, resultMap: Map<string, boolean>): void {
    const errors = out.error || out.Error;
    if (!errors) {
      return;
    }
    for (const item of this._asArray(errors)) {
      if (!item || typeof item !== 'object') {
        continue;
      }
      const obj = item as Record<string, unknown>;
      const key = obj.key || obj.Key;
      if (!key || typeof key !== 'string') {
        continue;
      }
      const versionId = (obj.versionId || obj.VersionId) as string | undefined;
      const id = this._deleteTargetId({ key, versionId });
      if (resultMap.has(id)) {
        resultMap.set(id, false);
      } else if (resultMap.has(key)) {
        resultMap.set(key, false);
      }
      const message = versionId
        ? `Failed to delete object: ${key} version ${versionId}`
        : `Failed to delete object: ${key}`;
      this._log('warn', message, {
        code: obj.code || obj.Code || 'Unknown',
        message: obj.message || obj.Message || 'Unknown error',
      });
    }
  }

  /**
   * Deletes multiple objects from the bucket.
   * Each entry may be a key string or a {@link IT.DeleteObject} with optional `versionId`.
   *
   * By default resolves to an array of booleans (one per target, in order). Pass
   * `{ versionInfo: true }` to receive an array of {@link IT.DeleteObjectResult} carrying
   * the deleted `versionId`, whether a delete marker was created, and the new marker's
   * `deleteMarkerVersionId` for each target.
   * @param {Array<string | IT.DeleteObject>} targets - Objects to delete.
   * @param {{ versionInfo?: boolean }} [options] - Pass `{ versionInfo: true }` for detailed per-target results.
   * @returns Booleans, or {@link IT.DeleteObjectResult[]} when `versionInfo` is set.
   * @example
   * await s3.deleteObjects(['a.txt', { key: 'b.txt', versionId: 'v1' }]);
   * const info = await s3.deleteObjects(['a.txt'], { versionInfo: true });
   */
  public async deleteObjects(targets: Array<string | IT.DeleteObject>): Promise<boolean[]>;
  public async deleteObjects(
    targets: Array<string | IT.DeleteObject>,
    options: { versionInfo: true },
  ): Promise<IT.DeleteObjectResult[]>;
  public async deleteObjects(
    targets: Array<string | IT.DeleteObject>,
    options: { versionInfo?: boolean } = {},
  ): Promise<boolean[] | IT.DeleteObjectResult[]> {
    if (!Array.isArray(targets) || targets.length === 0) {
      return [];
    }
    const normalized = targets.map(t => this._normalizeDeleteTarget(t));
    const maxBatchSize = 1000; // S3 limit for delete batch size

    if (normalized.length > maxBatchSize) {
      const batches: IT.DeleteObject[][] = [];
      for (let i = 0; i < normalized.length; i += maxBatchSize) {
        batches.push(normalized.slice(i, i + maxBatchSize));
      }
      if (options.versionInfo) {
        const results = await Promise.all(batches.map(b => this._deleteObjectsProcessInfo(b)));
        return results.flat();
      }
      const results = await Promise.all(batches.map(b => this._deleteObjectsProcess(b)));
      return results.flat();
    }
    return options.versionInfo
      ? this._deleteObjectsProcessInfo(normalized)
      : this._deleteObjectsProcess(normalized);
  }

  private async _sendRequest(
    url: string,
    method: IT.HttpMethod,
    headers: Record<string, string>,
    body?: BodyInit,
    toleratedStatusCodes: number[] = [],
  ): Promise<Response> {
    this._log('info', `Sending ${method} request to ${url}`, `headers: ${JSON.stringify(headers)}`);
    try {
      const res = await this._fetch(url, {
        method,
        headers,
        body: method === 'GET' || method === 'HEAD' ? undefined : body,
        signal: this.requestAbortTimeout ? AbortSignal.timeout(this.requestAbortTimeout) : undefined,
      });
      this._log('info', `Response status: ${res.status}, tolerated: ${toleratedStatusCodes.join(',')}`);
      if (res.ok || toleratedStatusCodes.includes(res.status)) {
        return res;
      }
      await this._handleErrorResponse(res);
      return res;
    } catch (err: unknown) {
      const code = extractErrCode(err);
      if (code && ['ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT', 'ECONNREFUSED'].includes(code)) {
        throw new S3NetworkError(`S3 network error: ${code}`, code, err);
      }
      throw err;
    }
  }

  private _parseErrorXml(headers: Headers, body: string): { svcCode?: string; errorMessage?: string } {
    if (headers.get('content-type') !== 'application/xml') {
      return {};
    }
    const parsedBody = parseXml(body);
    if (
      !parsedBody ||
      typeof parsedBody !== 'object' ||
      !('Error' in parsedBody) ||
      !parsedBody.Error ||
      typeof parsedBody.Error !== 'object'
    ) {
      return {};
    }
    const error = parsedBody.Error;
    return {
      svcCode: 'Code' in error && typeof error.Code === 'string' ? error.Code : undefined,
      errorMessage: 'Message' in error && typeof error.Message === 'string' ? error.Message : undefined,
    };
  }

  private async _handleErrorResponse(res: Response): Promise<void> {
    const errorBody = await res.text();
    const parsedErrorBody = this._parseErrorXml(res.headers, errorBody);
    const svcCode = res.headers.get('x-amz-error-code') ?? parsedErrorBody.svcCode ?? 'Unknown';
    const errorMessage = res.headers.get('x-amz-error-message') ?? parsedErrorBody.errorMessage ?? res.statusText;
    this._log(
      'error',
      `${C.ERROR_PREFIX}Request failed with status ${res.status}: ${svcCode} - ${errorMessage},err body: ${errorBody}`,
    );
    throw new S3ServiceError(`S3 returned ${res.status} – ${svcCode}`, res.status, svcCode, errorBody);
  }

  private _buildCanonicalQueryString(queryParams: Record<string, unknown>): string {
    if (!queryParams || Object.keys(queryParams).length === 0) {
      return '';
    }
    return Object.keys(queryParams)
      .map((key): [string, string] => [encodeURIComponent(key), encodeURIComponent(String(queryParams[key]))])
      .sort(([a], [b]) => byCodePoint(a, b))
      .map(([k, v]) => `${k}=${v}`)
      .join('&');
  }
  /**
   * Generates a pre-signed URL for direct client access to an S3 object.
   * The URL embeds authentication in query parameters instead of headers,
   * allowing unauthenticated HTTP clients to perform the specified operation.
   *
   * @param {'GET' | 'PUT'} method - HTTP method ('GET' for download, 'PUT' for upload)
   * @param {string} key - The object key/path
   * @param {number} [expiresIn=3600] - URL expiration time in seconds (1–604800)
   * @param {Record<string, string>} [queryParams={}] - Additional query parameters to include in the URL
   * @param {Record<string, string>} [headers={}] - HTTP headers to sign. The consumer of the URL
   *   MUST send these exact headers with matching values. The `host` header is always signed automatically.
   * @returns {Promise<string>} Pre-signed URL string
   * @throws {TypeError} If key is empty or expiresIn is out of range
   * @example
   * // Download URL valid for 1 hour
   * const url = await s3.getPresignedUrl('GET', 'photos/vacation.jpg');
   *
   * // Upload URL valid for 5 minutes with signed Content-Type
   * const url = await s3.getPresignedUrl('PUT', 'uploads/file.bin', 300, {}, {
   *   'Content-Type': 'application/octet-stream',
   * });
   *
   * // Client-side usage (must include signed headers)
   * await fetch(url, { method: 'PUT', body: data, headers: { 'Content-Type': 'application/octet-stream' } });
   */
  public async getPresignedUrl(
    method: 'GET' | 'PUT',
    key: string,
    expiresIn: number = 3600,
    queryParams: Record<string, string> = {},
    headers: Record<string, string> = {},
  ): Promise<string> {
    this._checkKey(key);
    if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 604800) {
      throw new TypeError(`${C.ERROR_PREFIX}expiresIn must be between 1 and 604800 seconds`);
    }
    if (this._bun && !Object.keys(queryParams).length && !Object.keys(headers).length) {
      return this._bun.presign(key, { method, expiresIn: Math.floor(expiresIn) });
    }
    return this._presign(method, uriResourceEscape(key), Math.floor(expiresIn), queryParams, headers);
  }

  private async _presign(
    method: string,
    keyPath: string,
    expiresIn: number,
    queryParams: Record<string, string>,
    headers: Record<string, string>,
  ): Promise<string> {
    const url = new URL(this.endpoint);
    if (keyPath.length > 0) {
      url.pathname =
        url.pathname === '/' ? `/${keyPath.replace(/^\/+/, '')}` : `${url.pathname}/${keyPath.replace(/^\/+/, '')}`;
    }

    const d = new Date();
    const year = d.getUTCFullYear();
    const month = String(d.getUTCMonth() + 1).padStart(2, '0');
    const day = String(d.getUTCDate()).padStart(2, '0');
    const shortDatetime = `${year}${month}${day}`;
    const fullDatetime = `${shortDatetime}T${String(d.getUTCHours()).padStart(2, '0')}${String(d.getUTCMinutes()).padStart(2, '0')}${String(d.getUTCSeconds()).padStart(2, '0')}Z`;
    const credentialScope = `${shortDatetime}/${this.region}/${C.S3_SERVICE}/${C.AWS_REQUEST_TYPE}`;

    const headerEntries: Array<[string, string]> = [['host', url.host]];
    for (const [key, value] of Object.entries(headers)) {
      const lowerKey = key.toLowerCase();
      if (lowerKey !== 'host') {
        headerEntries.push([lowerKey, String(value).trim()]);
      }
    }
    headerEntries.sort(([a], [b]) => byCodePoint(a, b));

    const canonicalHeaders = headerEntries.map(([k, v]) => `${k}:${v}`).join('\n');
    const signedHeaders = headerEntries.map(([k]) => k).join(';');

    const allQueryParams: Record<string, string> = {
      ...queryParams,
      'X-Amz-Algorithm': C.AWS_ALGORITHM,
      'X-Amz-Credential': `${this.#accessKeyId}/${credentialScope}`,
      'X-Amz-Date': fullDatetime,
      'X-Amz-Expires': String(expiresIn),
      'X-Amz-SignedHeaders': signedHeaders,
    };

    const canonicalQueryString = this._buildCanonicalQueryString(allQueryParams);
    const canonicalRequest = `${method}\n${url.pathname}\n${canonicalQueryString}\n${canonicalHeaders}\n\n${signedHeaders}\n${C.UNSIGNED_PAYLOAD}`;
    const stringToSign = `${C.AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${hexFromBuffer(await sha256(canonicalRequest))}`;

    if (shortDatetime !== this.signingKeyDate || !this.signingKey) {
      this.signingKeyDate = shortDatetime;
      this.signingKey = await this._getSignatureKey(shortDatetime);
    }

    const signature = hexFromBuffer(await hmac(this.signingKey, stringToSign));
    return `${url.origin}${url.pathname}?${canonicalQueryString}&X-Amz-Signature=${signature}`;
  }

  private async _getSignatureKey(dateStamp: string): Promise<ArrayBuffer> {
    const kDate = await hmac(`AWS4${this.#secretAccessKey}`, dateStamp);
    const kRegion = await hmac(kDate, this.region);
    const kService = await hmac(kRegion, C.S3_SERVICE);
    return await hmac(kService, C.AWS_REQUEST_TYPE);
  }
}

export { S3mini };
export default S3mini;
