/*
 * Copyright 2025 Daytona Platforms Inc.
 * SPDX-License-Identifier: Apache-2.0
 */

import * as pathe from 'pathe'
import axios from 'axios'
import {
  Configuration,
  FileInfo,
  Match,
  ReplaceRequest,
  ReplaceResult,
  SearchFilesResponse,
} from '@daytona/toolbox-api-client'
import { FileSystemApi } from '@daytona/toolbox-api-client'
import { dynamicImport } from './utils/Import'
import { RUNTIME, Runtime } from './utils/Runtime'
import { createDaytonaError, DaytonaError } from './errors/DaytonaError'
import type { Readable } from 'stream'
import {
  coerceUploadSource,
  createAbortError,
  normalizeResponseStream,
  processDownloadFilesResponseWithBusboy,
  processDownloadFilesResponseWithBuffered,
  wrapWithUploadProgress,
} from './utils/FileTransfer'
import { WithInstrumentation } from './utils/otel.decorator'

/**
 * Parameters for setting file permissions in the Sandbox.
 *
 * @interface
 * @property {string} [mode] - File mode/permissions in octal format (e.g. "644")
 * @property {string} [owner] - User owner of the file
 * @property {string} [group] - Group owner of the file
 *
 * @example
 * const permissions: FilePermissionsParams = {
 *   mode: '644',
 *   owner: 'daytona',
 *   group: 'users'
 * };
 */
export type FilePermissionsParams = {
  /** Group owner of the file */
  group?: string
  /** File mode/permissions in octal format (e.g. "644") */
  mode?: string
  /** User owner of the file */
  owner?: string
}

/**
 * Represents a file to be uploaded to the Sandbox.
 *
 * @interface
 * @property {string | Buffer} source - File to upload. If a Buffer, it is interpreted as the file content which is loaded into memory.
 * Make sure it fits into memory, otherwise use the local file path which content will be streamed to the Sandbox.
 * @property {string} destination - Absolute destination path in the Sandbox. Relative paths are resolved based on the sandbox working directory.
 */
export interface FileUpload {
  source: string | Buffer
  destination: string
}

/**
 * Represents a request to download a single file from the Sandbox.
 *
 * @interface
 * @property {string} source - Source path in the Sandbox. Relative paths are resolved based on the user's
 * root directory.
 * @property {string} [destination] - Destination path in the local filesystem where the file content will be
 * streamed to. If not provided, the file will be downloaded in the bytes buffer (might cause memory issues if the file is large).
 */
export interface FileDownloadRequest {
  source: string
  destination?: string
}

/**
 * Structured error metadata for a failed bulk file download item.
 *
 * @interface
 * @property {string} message - Human-readable error message.
 * @property {number | undefined} [statusCode] - HTTP-style status code for the per-file failure.
 * @property {string | undefined} [errorCode] - Machine-readable error code for the per-file failure.
 */
export interface FileDownloadErrorDetails {
  message: string
  statusCode?: number
  errorCode?: string
}

/**
 * Represents the response to a single file download request.
 *
 * @interface
 * @property {string} source - The original source path requested for download.
 * @property {Buffer | string | undefined} [result] - The download result - file path (if destination provided in the request)
 * or bytes content (if no destination in the request), undefined if failed or no data received.
 * @property {string | undefined} [error] - Error message if the download failed, undefined if successful.
 * @property {FileDownloadErrorDetails | undefined} [errorDetails] - Structured error metadata when the server provides it.
 */
export interface FileDownloadResponse {
  source: string
  result?: Buffer | string
  error?: string
  errorDetails?: FileDownloadErrorDetails
}

/**
 * Represents metadata for a file download operation.
 *
 * @interface
 * @property {string | undefined} [destination] - Destination path in the local filesystem where the file content will be streamed to.
 * @property {string | undefined} [error] - Error message if the download failed, undefined if successful.
 * @property {FileDownloadErrorDetails | undefined} [errorDetails] - Structured error metadata for a failed download item.
 * @property {Buffer | string | Uint8Array | undefined} [result] - The download result - file path (if destination provided in the request)
 * or bytes content (if no destination in the request), undefined if failed or no data received.
 */
export interface DownloadMetadata {
  destination?: string
  error?: string
  errorDetails?: FileDownloadErrorDetails
  result?: Buffer | string | Uint8Array
}

export type DownloadProgress = {
  /** Cumulative bytes received so far. */
  bytesReceived: number
  /** Total bytes expected, if known. Undefined if unavailable. */
  totalBytes?: number
}

export type UploadProgress = {
  /** Cumulative bytes sent so far. */
  bytesSent: number
}

/**
 * Options for streaming file downloads.
 *
 * @interface
 * @property {number} [timeout] - Timeout in seconds. 0 means no timeout. Default is 30 minutes.
 * @property {AbortSignal} [signal] - AbortSignal for cancelling the download.
 * @property {(progress: DownloadProgress) => void} [onProgress] - Callback invoked with cumulative progress information.
 */
export type DownloadStreamOptions = {
  /** Callback invoked with cumulative progress information as the download progresses. */
  onProgress?: (progress: DownloadProgress) => void
  /** AbortSignal for cancelling the download. */
  signal?: AbortSignal
  /** Timeout in seconds. 0 means no timeout. Default is 30 minutes. */
  timeout?: number
}

/**
 * Source for a streaming file upload. The same input shapes accepted by
 * {@link FileSystem.uploadFile} are also valid here, plus Node `Readable`
 * streams and Web `ReadableStream` instances.
 */
export type UploadSource = Buffer | Uint8Array | string | Readable | ReadableStream<Uint8Array>

/**
 * Options for streaming file uploads.
 *
 * @interface
 * @property {number} [timeout] - Timeout in seconds. 0 means no timeout. Default is 30 minutes.
 * @property {AbortSignal} [signal] - AbortSignal for cancelling the upload.
 * @property {(progress: UploadProgress) => void} [onProgress] - Callback invoked with cumulative progress information.
 */
export type UploadStreamOptions = {
  /** Callback invoked with cumulative progress information as the upload progresses. */
  onProgress?: (progress: UploadProgress) => void
  /** AbortSignal for cancelling the upload. */
  signal?: AbortSignal
  /** Timeout in seconds. 0 means no timeout. Default is 30 minutes. */
  timeout?: number
}

function createFileDownloadError(error: string, errorDetails?: FileDownloadErrorDetails): DaytonaError {
  if (!errorDetails) {
    return new DaytonaError(error)
  }

  return createDaytonaError(errorDetails.message, errorDetails.statusCode, undefined, errorDetails.errorCode)
}

/**
 * Provides file system operations within a Sandbox.
 *
 * @class
 */
export class FileSystem {
  constructor(
    private readonly clientConfig: Configuration,
    private readonly apiClient: FileSystemApi,
  ) {}

  /**
   * Create a new directory in the Sandbox with specified permissions.
   *
   * @param {string} path - Path where the directory should be created. Relative paths are resolved based on the sandbox working directory.
   * @param {string} mode - Directory permissions in octal format (e.g. "755")
   * @returns {Promise<void>}
   *
   * @example
   * // Create a directory with standard permissions
   * await fs.createFolder('app/data', '755');
   */
  @WithInstrumentation()
  public async createFolder(path: string, mode: string): Promise<void> {
    const response = await this.apiClient.createFolder(path, mode)
    return response.data
  }

  /**
   * Deletes a file or directory from the Sandbox.
   *
   * @param {string} path - Path to the file or directory to delete. Relative paths are resolved based on the sandbox working directory.
   * @param {boolean} [recursive] - If the file is a directory, this must be true to delete it.
   * @returns {Promise<void>}
   *
   * @example
   * // Delete a file
   * await fs.deleteFile('app/temp.log');
   */
  @WithInstrumentation()
  public async deleteFile(path: string, recursive?: boolean): Promise<void> {
    const response = await this.apiClient.deleteFile(path, recursive)
    return response.data
  }

  /**
   * Downloads a file from the Sandbox. This method loads the entire file into memory, so it is not recommended
   * for downloading large files.
   *
   * @param {string} remotePath - Path to the file to download. Relative paths are resolved based on the sandbox working directory.
   * @param {number} [timeout] - Timeout for the download operation in seconds. 0 means no timeout.
   * Default is 30 minutes.
   * @returns {Promise<Buffer>} The file contents as a Buffer.
   *
   * @example
   * // Download and process a file
   * const fileBuffer = await fs.downloadFile('tmp/data.json');
   * console.log('File content:', fileBuffer.toString());
   */
  public async downloadFile(remotePath: string, timeout?: number): Promise<Buffer>
  /**
   * Downloads a file from the Sandbox and saves it to a local file. This method uses streaming to download the file,
   * so it is recommended for downloading larger files.
   *
   * @param {string} remotePath - Path to the file to download in the Sandbox. Relative paths are resolved based on the sandbox working directory.
   * @param {string} localPath - Path to save the downloaded file.
   * @param {number} [timeout] - Timeout for the download operation in seconds. 0 means no timeout.
   * Default is 30 minutes.
   * @returns {Promise<void>}
   *
   * @example
   * // Download and save a file
   * await fs.downloadFile('tmp/data.json', 'local_file.json');
   */
  public async downloadFile(remotePath: string, localPath: string, timeout?: number): Promise<void>
  @WithInstrumentation()
  public async downloadFile(src: string, dst?: string | number, timeout: number = 30 * 60): Promise<Buffer | void> {
    const remotePath = src

    if (typeof dst !== 'string') {
      if (dst) {
        timeout = dst as number
      }

      const response = await this.downloadFiles([{ source: remotePath }], timeout)

      if (response[0].error) {
        throw createFileDownloadError(response[0].error, response[0].errorDetails)
      }

      return response[0].result as Buffer
    }

    const response = await this.downloadFiles([{ source: remotePath, destination: dst }], timeout)

    if (response[0].error) {
      throw createFileDownloadError(response[0].error, response[0].errorDetails)
    }
  }

  /**
   * Downloads a single file from the Sandbox as a readable stream without buffering
   * the entire file into memory. The returned stream can be piped directly to an HTTP
   * response, a file write stream, or any other writable destination.
   *
   * This method is only supported in Node.js-compatible runtimes (Node.js, Bun).
   * Browser and serverless environments should use {@link downloadFile} instead.
   *
   * @param {string} remotePath - Path to the file in the Sandbox. Relative paths are
   *   resolved based on the sandbox working directory.
   * @param {number | DownloadStreamOptions} [timeoutOrOptions] - Timeout in seconds, or an options
   *   object with timeout, AbortSignal for cancellation, and/or onProgress callback.
   *   Default timeout is 30 minutes.
   * @returns {Promise<Readable>} A Node.js Readable stream of the file content.
   *
   * @example
   * // Pipe directly to an HTTP response
   * const stream = await sandbox.fs.downloadFileStream('outputs/report.pdf');
   * stream.pipe(res);
   *
   * @example
   * // Download with progress tracking and cancellation
   * const controller = new AbortController();
   * const stream = await sandbox.fs.downloadFileStream('outputs/large-file.bin', {
   *   signal: controller.signal,
   *   onProgress: ({ bytesReceived, totalBytes }) => console.log(bytesReceived, totalBytes),
   * });
   * stream.pipe(createWriteStream('local-file.bin'));
   */
  public async downloadFileStream(remotePath: string, timeout?: number): Promise<Readable>
  public async downloadFileStream(remotePath: string, options?: DownloadStreamOptions): Promise<Readable>
  @WithInstrumentation()
  public async downloadFileStream(
    remotePath: string,
    timeoutOrOptions?: number | DownloadStreamOptions,
  ): Promise<Readable> {
    const options: DownloadStreamOptions =
      typeof timeoutOrOptions === 'number' ? { timeout: timeoutOrOptions } : (timeoutOrOptions ?? {})
    const timeout = options.timeout ?? 30 * 60
    const isNonStreamingRuntime = RUNTIME === Runtime.BROWSER || RUNTIME === Runtime.SERVERLESS
    if (isNonStreamingRuntime) {
      throw new DaytonaError(
        'downloadFileStream is not supported in browser or serverless environments. Use downloadFile instead.',
      )
    }

    if (options.signal?.aborted) {
      throw new DaytonaError('Download cancelled')
    }

    const toDownloadCancelledError = () => new DaytonaError('Download cancelled')
    const isCanceledError = (err: unknown) => {
      const error = err as { code?: string; name?: string }
      return Boolean(axios.isCancel?.(err) || error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED')
    }

    let response
    try {
      response = await this.apiClient.downloadFiles(
        { paths: [remotePath] },
        {
          responseType: 'stream',
          timeout: timeout * 1000,
          signal: options.signal,
        },
      )
    } catch (err) {
      if (options.signal?.aborted || isCanceledError(err)) {
        throw toDownloadCancelledError()
      }
      throw err
    }

    const responseStream = normalizeResponseStream(response.data)
    const metadataMap = new Map<string, DownloadMetadata>()
    metadataMap.set(remotePath, {})

    return new Promise<Readable>((resolve, reject) => {
      let resolvedStream: Readable | null = null

      processDownloadFilesResponseWithBusboy(
        responseStream,
        response.headers as Record<string, string>,
        metadataMap,
        (_source, fileStream, totalBytes) => {
          if (options.onProgress) {
            const { Transform } = require('stream') as typeof import('stream')
            let bytesReceived = 0
            const progress = new Transform({
              transform(chunk, _encoding, callback) {
                bytesReceived += chunk.length
                options.onProgress!({ bytesReceived, totalBytes })
                callback(null, chunk)
              },
            })
            fileStream.pipe(progress)
            // busboy's teardown can emit 'error' on the file stream after 'end',
            // even when every byte was delivered — ignore those late errors.
            let fileStreamEnded = false
            fileStream.once('end', () => {
              fileStreamEnded = true
            })
            fileStream.on('error', (err: Error) => {
              if (fileStreamEnded) return
              if (!progress.destroyed) progress.destroy(err)
            })
            resolvedStream = progress
          } else {
            resolvedStream = fileStream as Readable
          }
          resolve(resolvedStream)
        },
      )
        .then(() => {
          if (!resolvedStream) {
            const metadata = metadataMap.get(remotePath)
            if (metadata?.error) {
              reject(createFileDownloadError(metadata.error, metadata.errorDetails))
            } else {
              reject(new DaytonaError(`No file data received for: ${remotePath}`))
            }
          }
        })
        .catch((err) => {
          const normalizedError = options.signal?.aborted || isCanceledError(err) ? toDownloadCancelledError() : err
          if (!resolvedStream) {
            reject(normalizedError)
            return
          }
          const stream = resolvedStream as Readable & { readableEnded?: boolean }
          if (stream.destroyed || stream.readableEnded) {
            return
          }
          stream.destroy(normalizedError instanceof Error ? normalizedError : new Error(String(normalizedError)))
        })
    })
  }

  /**
   * Downloads multiple files from the Sandbox. If the files already exist locally, they will be overwritten.
   *
   * @param {FileDownloadRequest[]} files - Array of file download requests.
   * @param {number} [timeoutSec] - Timeout for the download operation in seconds. 0 means no timeout.
   * Default is 30 minutes.
   * @returns {Promise<FileDownloadResponse[]>} Array of download results.
   *
   * @throws {DaytonaError} If the request itself fails (network issues, invalid request/response, etc.). Individual
   * file download errors are returned in `FileDownloadResponse.error`. When the daemon provides structured
   * per-file metadata, it is also available in `FileDownloadResponse.errorDetails`.
   *
   * @example
   * // Download multiple files
   * const results = await fs.downloadFiles([
   *   { source: 'tmp/data.json' },
   *   { source: 'tmp/config.json', destination: 'local_config.json' }
   * ]);
   * results.forEach(result => {
   *   if (result.error) {
   *     console.error(`Error downloading ${result.source}: ${result.error}`);
   *   } else if (result.result) {
   *     console.log(`Downloaded ${result.source} to ${result.result}`);
   *   }
   * });
   */
  @WithInstrumentation()
  public async downloadFiles(
    files: FileDownloadRequest[],
    timeoutSec: number = 30 * 60,
  ): Promise<FileDownloadResponse[]> {
    if (files.length === 0) return []

    const isNonStreamingRuntime = RUNTIME === Runtime.BROWSER || RUNTIME === Runtime.SERVERLESS

    // Prepare destinations and metadata
    const metadataMap = new Map<string, DownloadMetadata>()

    for (const f of files) {
      metadataMap.set(f.source, { destination: f.destination })
      if (f.destination) {
        const fs = await dynamicImport('fs', 'Downloading files to local files is not supported: ')
        await fs.promises.mkdir(pathe.dirname(f.destination), { recursive: true })
      }
    }

    const response = await this.apiClient.downloadFiles(
      { paths: files.map((f) => f.source) },
      {
        responseType: isNonStreamingRuntime ? 'arraybuffer' : 'stream',
        timeout: timeoutSec * 1000,
      },
    )

    const stream = normalizeResponseStream(response.data)

    // Node.js path: use busboy for efficient streaming
    if (isNonStreamingRuntime) {
      await processDownloadFilesResponseWithBuffered(stream, response.headers as Record<string, string>, metadataMap)
    } else {
      await processDownloadFilesResponseWithBusboy(stream, response.headers as Record<string, string>, metadataMap)
    }

    return files.map((f) => {
      const metadata = metadataMap.get(f.source)
      const error = metadata?.error || (!metadata?.result ? 'No data received for this file' : undefined)

      return {
        source: f.source,
        result: error ? undefined : (metadata!.result as Buffer | string),
        error,
        errorDetails: error ? metadata?.errorDetails : undefined,
      }
    })
  }

  /**
   * Searches for text patterns within files in the Sandbox.
   *
   * @param {string} path - Directory to search in. Relative paths are resolved based on the sandbox working directory.
   * @param {string} pattern - Search pattern
   * @returns {Promise<Array<Match>>} Array of matches with file and line information
   *
   * @example
   * // Find all TODO comments in TypeScript files
   * const matches = await fs.findFiles('app/src', 'TODO:');
   * matches.forEach(match => {
   *   console.log(`${match.file}:${match.line}: ${match.content}`);
   * });
   */
  @WithInstrumentation()
  public async findFiles(path: string, pattern: string): Promise<Array<Match>> {
    const response = await this.apiClient.findInFiles(path, pattern)
    return response.data
  }

  /**
   * Retrieves detailed information about a file or directory.
   *
   * @param {string} path - Path to the file or directory. Relative paths are resolved based on the sandbox working directory.
   * @returns {Promise<FileInfo>} Detailed file information including size, permissions, modification time
   *
   * @example
   * // Get file details
   * const info = await fs.getFileDetails('app/config.json');
   * console.log(`Size: ${info.size}, Modified: ${info.modTime}`);
   */
  @WithInstrumentation()
  public async getFileDetails(path: string): Promise<FileInfo> {
    const response = await this.apiClient.getFileInfo(path)
    return response.data
  }

  /**
   * Lists contents of a directory in the Sandbox.
   *
   * @param {string} path - Directory path to list. Relative paths are resolved based on the sandbox working directory.
   * @returns {Promise<FileInfo[]>} Array of file and directory information
   *
   * @example
   * // List directory contents
   * const files = await fs.listFiles('app/src');
   * files.forEach(file => {
   *   console.log(`${file.name} (${file.size} bytes)`);
   * });
   */
  @WithInstrumentation()
  public async listFiles(path: string): Promise<FileInfo[]> {
    const response = await this.apiClient.listFiles(path)
    return response.data
  }

  /**
   * Moves or renames a file or directory.
   *
   * @param {string} source - Source path. Relative paths are resolved based on the sandbox working directory.
   * @param {string} destination - Destination path. Relative paths are resolved based on the sandbox working directory.
   * @returns {Promise<void>}
   *
   * @example
   * // Move a file to a new location
   * await fs.moveFiles('app/temp/data.json', 'app/data/data.json');
   */
  @WithInstrumentation()
  public async moveFiles(source: string, destination: string): Promise<void> {
    const response = await this.apiClient.moveFile(source, destination)
    return response.data
  }

  /**
   * Replaces text content in multiple files.
   *
   * @param {string[]} files - Array of file paths to process. Relative paths are resolved based on the sandbox working directory.
   * @param {string} pattern - Pattern to replace
   * @param {string} newValue - Replacement text
   * @returns {Promise<Array<ReplaceResult>>} Results of the replace operation for each file
   *
   * @example
   * // Update version number across multiple files
   * const results = await fs.replaceInFiles(
   *   ['app/package.json', 'app/version.ts'],
   *   '"version": "1.0.0"',
   *   '"version": "1.1.0"'
   * );
   */
  @WithInstrumentation()
  public async replaceInFiles(files: string[], pattern: string, newValue: string): Promise<Array<ReplaceResult>> {
    const replaceRequest: ReplaceRequest = {
      files,
      newValue,
      pattern,
    }

    const response = await this.apiClient.replaceInFiles(replaceRequest)
    return response.data
  }

  /**
   * Searches for files and directories by name pattern in the Sandbox.
   *
   * @param {string} path - Directory to search in. Relative paths are resolved based on the sandbox working directory.
   * @param {string} pattern - File name pattern (supports globs)
   * @returns {Promise<SearchFilesResponse>} Search results with matching files
   *
   * @example
   * // Find all TypeScript files
   * const result = await fs.searchFiles('app', '*.ts');
   * result.files.forEach(file => console.log(file));
   */
  @WithInstrumentation()
  public async searchFiles(path: string, pattern: string): Promise<SearchFilesResponse> {
    const response = await this.apiClient.searchFiles(path, pattern)
    return response.data
  }

  /**
   * Sets permissions and ownership for a file or directory.
   *
   * @param {string} path - Path to the file or directory. Relative paths are resolved based on the sandbox working directory.
   * @param {FilePermissionsParams} permissions - Permission settings
   * @returns {Promise<void>}
   *
   * @example
   * // Set file permissions and ownership
   * await fs.setFilePermissions('app/script.sh', {
   *   owner: 'daytona',
   *   group: 'users',
   *   mode: '755'  // Execute permission for shell script
   * });
   */
  @WithInstrumentation()
  public async setFilePermissions(path: string, permissions: FilePermissionsParams): Promise<void> {
    const response = await this.apiClient.setFilePermissions(
      path,
      permissions.owner!,
      permissions.group!,
      permissions.mode!,
    )
    return response.data
  }

  /**
   * Uploads a file to the Sandbox. This method loads the entire file into memory, so it is not recommended
   * for uploading large files.
   *
   * @param {Buffer} file - Buffer of the file to upload.
   * @param {string} remotePath - Destination path in the Sandbox. Relative paths are resolved based on the sandbox working directory.
   * @param {number} [timeout] - Timeout for the upload operation in seconds. 0 means no timeout.
   * Default is 30 minutes.
   * @returns {Promise<void>}
   *
   * @example
   * // Upload a configuration file
   * await fs.uploadFile(Buffer.from('{"setting": "value"}'), 'tmp/config.json');
   */
  public async uploadFile(file: Buffer, remotePath: string, timeout?: number): Promise<void>
  /**
   * Uploads a file from the local file system to the Sandbox. This method uses streaming to upload the file,
   * so it is recommended for uploading larger files.
   *
   * @param {string} localPath - Path to the local file to upload.
   * @param {string} remotePath - Destination path in the Sandbox. Relative paths are resolved based on the sandbox working directory.
   * @param {number} [timeout] - Timeout for the upload operation in seconds. 0 means no timeout.
   * Default is 30 minutes.
   * @returns {Promise<void>}
   *
   * @example
   * // Upload a local file
   * await fs.uploadFile('local_file.txt', 'tmp/file.txt');
   */
  public async uploadFile(localPath: string, remotePath: string, timeout?: number): Promise<void>
  @WithInstrumentation()
  public async uploadFile(src: string | Buffer, dst: string, timeout: number = 30 * 60): Promise<void> {
    await this.uploadFiles([{ source: src, destination: dst }], timeout)
  }

  /**
   * Uploads a single file to the Sandbox using true streaming, with optional progress
   * tracking and cancellation. Memory usage stays flat regardless of source size: the
   * SDK pipes the source through a transform that counts bytes and forwards to the
   * underlying multipart upload without buffering the whole payload. The HTTP layer
   * uses chunked transfer encoding, so the source's natural EOF terminates the upload —
   * no advance size is needed.
   *
   * @param {UploadSource} source - The data to upload. Accepts the same `Buffer | string`
   *   inputs as {@link FileSystem.uploadFile}, plus Node `Readable` streams and Web
   *   `ReadableStream` instances. When a string is passed, it is treated as a local
   *   file path and read in streaming chunks.
   * @param {string} remotePath - Destination path in the Sandbox. Relative paths are
   *   resolved against the sandbox working directory.
   * @param {UploadStreamOptions} [options] - Streaming options: AbortSignal, onProgress
   *   callback, timeout.
   * @returns {Promise<void>}
   *
   * @example
   * // Upload a 2 GB file with progress tracking and the ability to cancel
   * import { createReadStream } from 'fs';
   * const controller = new AbortController();
   * await sandbox.fs.uploadFileStream(createReadStream('big.bin'), 'tmp/big.bin', {
   *   signal: controller.signal,
   *   onProgress: ({ bytesSent }) => console.log(`${bytesSent} bytes sent`),
   * });
   */
  @WithInstrumentation()
  public async uploadFileStream(
    source: UploadSource,
    remotePath: string,
    options: UploadStreamOptions = {},
  ): Promise<void> {
    const isNonStreamingRuntime =
      RUNTIME === Runtime.DENO || RUNTIME === Runtime.BROWSER || RUNTIME === Runtime.SERVERLESS
    if (isNonStreamingRuntime) {
      throw new DaytonaError(
        'uploadFileStream is not supported in browser, Deno, or serverless runtimes. Use uploadFile instead.',
      )
    }

    if (options.signal?.aborted) {
      throw createAbortError(remotePath)
    }

    const timeout = options.timeout ?? 30 * 60
    const sourceStream = await coerceUploadSource(source)
    const tracked = wrapWithUploadProgress(sourceStream, options.onProgress, options.signal)

    const FormDataClass = (await dynamicImport('form-data', 'Uploading files is not supported: ')) as any
    const form = new FormDataClass()
    form.append('files[0].path', remotePath)
    // No knownLength: form-data falls back to chunked transfer encoding, which the
    // daemon's MultipartReader handles transparently. The source's EOF terminates
    // the upload — no advance byte count needed.
    form.append('files[0].file', tracked, { filename: remotePath })

    try {
      await this.apiClient.uploadFiles({
        data: form,
        maxRedirects: 0,
        timeout: timeout * 1000,
        signal: options.signal,
      })
    } catch (err) {
      if (options.signal?.aborted) {
        throw createAbortError(remotePath)
      }
      throw err
    }
  }

  /**
   * Uploads multiple files to the Sandbox. If files already exist at the destination paths,
   * they will be overwritten.
   *
   * @param {FileUpload[]} files - Array of files to upload.
   * @param {number} [timeout] - Timeout for the upload operation in seconds. 0 means no timeout.
   * Default is 30 minutes.
   * @returns {Promise<void>}
   *
   * @example
   * // Upload multiple text files
   * const files = [
   *   {
   *     source: Buffer.from('Content of file 1'),
   *     destination: '/tmp/file1.txt'
   *   },
   *   {
   *     source: 'app/data/file2.txt',
   *     destination: '/tmp/file2.txt'
   *   },
   *   {
   *     source: Buffer.from('{"key": "value"}'),
   *     destination: '/tmp/config.json'
   *   }
   * ];
   * await fs.uploadFiles(files);
   */
  @WithInstrumentation()
  public async uploadFiles(files: FileUpload[], timeout: number = 30 * 60): Promise<void> {
    const isNonStreamingRuntime =
      RUNTIME === Runtime.DENO || RUNTIME === Runtime.BROWSER || RUNTIME === Runtime.SERVERLESS
    const FormDataClass = isNonStreamingRuntime
      ? FormData
      : ((await dynamicImport('form-data', 'Uploading files is not supported: ')) as any)
    const form = new FormDataClass()

    for (const [i, { source, destination }] of files.entries()) {
      form.append(`files[${i}].path`, destination)
      const payload = await this.makeFilePayload(source)
      form.append(`files[${i}].file`, payload as any, destination)
    }

    if (isNonStreamingRuntime) {
      const url = `${this.clientConfig.basePath}/files/bulk-upload`
      await fetch(url, {
        method: 'POST',
        headers: this.clientConfig.baseOptions.headers,
        body: form,
        signal: timeout ? AbortSignal.timeout(timeout * 1000) : undefined,
      })
    } else {
      await this.apiClient.uploadFiles({
        data: form,
        maxRedirects: 0,
        timeout: timeout * 1000,
      })
    }
  }

  private async makeFilePayload(source: Uint8Array | string) {
    // String = file path
    if (typeof source === 'string') {
      const fs = await dynamicImport('fs', 'Uploading file from local file system is not supported: ')
      return fs.createReadStream(source)
    }

    // Blob
    if (RUNTIME === Runtime.BROWSER || RUNTIME === Runtime.SERVERLESS || RUNTIME === Runtime.DENO) {
      // Use .slice() to ensure we have a concrete ArrayBuffer, not ArrayBufferLike
      return new Blob([source.slice()], { type: 'application/octet-stream' })
    }

    // Readable stream
    const stream = await dynamicImport('stream', 'Uploading file is not supported: ')
    return stream.Readable.from(source)
  }
}
