# Copyright Daytona Platforms Inc.
# SPDX-License-Identifier: Apache-2.0

# frozen_string_literal: true

require 'timeout'

module Daytona
  class Sandbox # rubocop:disable Metrics/ClassLength
    include Instrumentation

    DEFAULT_TIMEOUT = 60

    # @return [String] The ID of the sandbox
    attr_reader :id

    # @return [String] The organization ID of the sandbox
    attr_reader :organization_id

    # @return [String] The snapshot used for the sandbox
    attr_reader :snapshot

    # @return [String] The user associated with the project
    attr_reader :user

    # @return [Hash<String, String>] Environment variables for the sandbox
    attr_reader :env

    # @return [Hash<String, String>] Labels for the sandbox
    attr_reader :labels

    # @return [Boolean] Whether the sandbox http preview is public
    attr_reader :public

    # @return [Boolean] Whether to block all network access for the sandbox
    attr_reader :network_block_all

    # @return [String] Comma-separated list of allowed CIDR network addresses for the sandbox
    attr_reader :network_allow_list

    # @return [String] The target environment for the sandbox
    attr_reader :target

    # @return [Float] The CPU quota for the sandbox
    attr_reader :cpu

    # @return [Float] The GPU quota for the sandbox
    attr_reader :gpu

    # @return [Float] The memory quota for the sandbox
    attr_reader :memory

    # @return [Float] The disk quota for the sandbox
    attr_reader :disk

    # @return [DaytonaApiClient::SandboxState] The state of the sandbox
    attr_reader :state

    # @return [DaytonaApiClient::SandboxDesiredState] The desired state of the sandbox
    attr_reader :desired_state

    # @return [String] The error reason of the sandbox
    attr_reader :error_reason

    # @return [String] The state of the backup
    attr_reader :backup_state

    # @return [String] The creation timestamp of the last backup
    attr_reader :backup_created_at

    # @return [Float] Auto-stop interval in minutes (0 means disabled)
    attr_reader :auto_stop_interval

    # @return [Float] Auto-archive interval in minutes
    attr_reader :auto_archive_interval

    # @return [Float] Auto-delete interval in minutes
    # (negative value means disabled, 0 means delete immediately upon stopping)
    attr_reader :auto_delete_interval

    # @return [Array<DaytonaApiClient::SandboxVolume>] Array of volumes attached to the sandbox
    attr_reader :volumes

    # @return [DaytonaApiClient::BuildInfo] Build information for the sandbox
    attr_reader :build_info

    # @return [String] The creation timestamp of the sandbox
    attr_reader :created_at

    # @return [String] The last update timestamp of the sandbox
    attr_reader :updated_at

    # @return [String] The last activity timestamp of the sandbox
    attr_reader :last_activity_at

    # @return [String] The version of the daemon running in the sandbox
    attr_reader :daemon_version

    # @return [Daytona::Config]
    attr_reader :config

    # @return [DaytonaApiClient::SandboxApi]
    attr_reader :sandbox_api

    # @return [Daytona::Process]
    attr_reader :process

    # @return [Daytona::FileSystem]
    attr_reader :fs

    # @return [Daytona::Git]
    attr_reader :git

    # @return [Daytona::ComputerUse]
    attr_reader :computer_use

    # @return [Daytona::CodeInterpreter]
    attr_reader :code_interpreter

    # @params config [Daytona::Config]
    # @params sandbox_api [DaytonaApiClient::SandboxApi]
    # @params sandbox_dto [DaytonaApiClient::Sandbox]
    # @params otel_state [Daytona::OtelState, nil]
    def initialize(sandbox_dto:, config:, sandbox_api:, otel_state: nil) # rubocop:disable Metrics/MethodLength
      process_response(sandbox_dto)
      @config = config
      @sandbox_api = sandbox_api
      @otel_state = otel_state

      # Create toolbox API clients with dynamic configuration
      toolbox_api_config = build_toolbox_api_config

      # Helper to create API client with authentication header
      create_authenticated_client = lambda do
        client = DaytonaToolboxApiClient::ApiClient.new(toolbox_api_config)
        client.default_headers['Authorization'] = "Bearer #{config.api_key || config.jwt_token}"
        client.default_headers['X-Daytona-Source'] = 'sdk-ruby'
        client.default_headers['X-Daytona-SDK-Version'] = Sdk::VERSION
        client.default_headers['X-Daytona-Organization-ID'] = config.organization_id if config.jwt_token
        client.user_agent = "sdk-ruby/#{Sdk::VERSION}"
        client
      end

      process_api = DaytonaToolboxApiClient::ProcessApi.new(create_authenticated_client.call)
      fs_api = DaytonaToolboxApiClient::FileSystemApi.new(create_authenticated_client.call)
      git_api = DaytonaToolboxApiClient::GitApi.new(create_authenticated_client.call)
      lsp_api = DaytonaToolboxApiClient::LspApi.new(create_authenticated_client.call)
      computer_use_api = DaytonaToolboxApiClient::ComputerUseApi.new(create_authenticated_client.call)
      interpreter_api = DaytonaToolboxApiClient::InterpreterApi.new(create_authenticated_client.call)
      info_api = DaytonaToolboxApiClient::InfoApi.new(create_authenticated_client.call)

      @process = Process.new(
        sandbox_id: id,
        toolbox_api: process_api,
        get_preview_link: proc { |port| preview_url(port) },
        language: (labels || {}).fetch(CODE_TOOLBOX_LANGUAGE_LABEL, 'python'),
        otel_state:
      )
      @fs = FileSystem.new(sandbox_id: id, toolbox_api: fs_api, otel_state:)
      @git = Git.new(sandbox_id: id, toolbox_api: git_api, otel_state:)
      @computer_use = ComputerUse.new(sandbox_id: id, toolbox_api: computer_use_api, otel_state:)
      @code_interpreter = CodeInterpreter.new(
        sandbox_id: id,
        toolbox_api: interpreter_api,
        get_preview_link: proc { |port| preview_url(port) },
        otel_state:
      )
      @lsp_api = lsp_api
      @info_api = info_api
    end

    # Archives the sandbox, making it inactive and preserving its state. When sandboxes are
    # archived, the entire filesystem state is moved to cost-effective object storage, making it
    # possible to keep sandboxes available for an extended period. The tradeoff between archived
    # and stopped states is that starting an archived sandbox takes more time, depending on its size.
    # Sandbox must be stopped before archiving.
    #
    # @return [void]
    def archive
      sandbox_api.archive_sandbox(id)
      refresh
    end

    # Sets the auto-archive interval for the Sandbox.
    # The Sandbox will automatically archive after being continuously stopped for the specified interval.
    #
    # @param interval [Integer]
    # @return [Integer]
    # @raise [Daytona:Sdk::Error]
    def auto_archive_interval=(interval)
      raise Sdk::Error, 'Auto-archive interval must be a non-negative integer' if interval.negative?

      sandbox_api.set_auto_archive_interval(id, interval)
      @auto_archive_interval = interval
    end

    # Sets the auto-delete interval for the Sandbox.
    # The Sandbox will automatically delete after being continuously stopped for the specified interval.
    #
    # @param interval [Integer]
    # @return [Integer]
    # @raise [Daytona:Sdk::Error]
    def auto_delete_interval=(interval)
      sandbox_api.set_auto_delete_interval(id, interval)
      @auto_delete_interval = interval
    end

    # Updates outbound network policy on the runner (block all, restore access, or CIDR allow list).
    #
    # @param network_block_all [Boolean, nil]
    # @param network_allow_list [String, nil]
    # @return [void]
    # @raise [Daytona::Sdk::Error]
    def update_network_settings(network_block_all: nil, network_allow_list: nil)
      if network_block_all.nil? && network_allow_list.nil?
        raise Sdk::Error,
              'At least one of network_block_all or network_allow_list must be provided'
      end

      body = DaytonaApiClient::UpdateSandboxNetworkSettings.new(
        network_block_all:,
        network_allow_list:
      )
      data = sandbox_api.update_network_settings(id, body)
      @network_block_all = data.network_block_all
      @network_allow_list = data.network_allow_list
    end

    # Sets the auto-stop interval for the Sandbox.
    # The Sandbox will automatically stop after being idle (no new events) for the specified interval.
    # Events include any state changes or interactions with the Sandbox through the SDK.
    # Interactions using Sandbox Previews are not included.
    #
    # @param interval [Integer]
    # @return [Integer]
    # @raise [Daytona:Sdk::Error]
    def auto_stop_interval=(interval)
      raise Sdk::Error, 'Auto-stop interval must be a non-negative integer' if interval.negative?

      sandbox_api.set_autostop_interval(id, interval)
      @auto_stop_interval = interval
    end

    # Creates an SSH access token for the sandbox.
    #
    # @param expires_in_minutes [Integer] TThe number of minutes the SSH access token will be valid for
    # @return [DaytonaApiClient::SshAccessDto]
    def create_ssh_access(expires_in_minutes) = sandbox_api.create_ssh_access(id, { expires_in_minutes: })

    # @return [void]
    def delete
      sandbox_api.delete_sandbox(id)
      refresh
    rescue DaytonaApiClient::ApiError => e
      raise unless e.code == 404

      @state = 'destroyed'
    end

    # Gets the user's home directory path for the logged in user inside the Sandbox.
    #
    # @return [String] The absolute path to the Sandbox user's home directory for the logged in user
    #
    # @example
    #   user_home_dir = sandbox.get_user_home_dir
    #   puts "Sandbox user home: #{user_home_dir}"
    def get_user_home_dir
      @info_api.get_user_home_dir.dir
    rescue StandardError => e
      raise Sdk::Error, "Failed to get user home directory: #{e.message}"
    end

    # Gets the working directory path inside the Sandbox.
    #
    # @return [String] The absolute path to the Sandbox working directory. Uses the WORKDIR specified
    #   in the Dockerfile if present, or falling back to the user's home directory if not.
    #
    # @example
    #   work_dir = sandbox.get_work_dir
    #   puts "Sandbox working directory: #{work_dir}"
    def get_work_dir
      @info_api.get_work_dir.dir
    rescue StandardError => e
      raise Sdk::Error, "Failed to get working directory path: #{e.message}"
    end

    # Sets labels for the Sandbox.
    #
    # @param labels [Hash<String, String>]
    # @return [Hash<String, String>]
    def labels=(labels)
      @labels = sandbox_api.replace_labels(id, DaytonaApiClient::SandboxLabels.build_from_hash(labels:)).labels
    end

    # Retrieves the preview link for the sandbox at the specified port. If the port is closed,
    # it will be opened automatically. For private sandboxes, a token is included to grant access
    # to the URL.
    #
    # @param port [Integer]
    # @return [DaytonaApiClient::PortPreviewUrl]
    def preview_url(port) = sandbox_api.get_port_preview_url(id, port)

    # Creates a signed preview URL for the sandbox at the specified port.
    #
    # @param port [Integer] The port to open the preview link on
    # @param expires_in_seconds [Integer, nil] The number of seconds the signed preview URL
    #   will be valid for. Defaults to 60 seconds.
    # @return [DaytonaApiClient::SignedPortPreviewUrl] The signed preview URL response object
    #
    # @example
    #   signed_url = sandbox.create_signed_preview_url(3000, 120)
    #   puts "Signed URL: #{signed_url.url}"
    #   puts "Token: #{signed_url.token}"
    def create_signed_preview_url(port, expires_in_seconds = nil)
      sandbox_api.get_signed_port_preview_url(id, port, { expires_in_seconds: })
    end

    # Expires a signed preview URL for the sandbox at the specified port.
    #
    # @param port [Integer] The port to expire the signed preview URL on
    # @param token [String] The token to expire
    # @return [void]
    #
    # @example
    #   sandbox.expire_signed_preview_url(3000, "token-value")
    def expire_signed_preview_url(port, token)
      sandbox_api.expire_signed_port_preview_url(id, port, token)
    end

    # Refresh the Sandbox data from the API.
    #
    # @return [void]
    def refresh = process_response(sandbox_api.get_sandbox(id))

    # Refreshes the sandbox activity to reset the timer for automated lifecycle management actions.
    #
    # This method updates the sandbox's last activity timestamp without changing its state.
    # It is useful for keeping long-running sessions alive while there is still user activity.
    #
    # @return [void]
    #
    # @example
    #   sandbox.refresh_activity
    def refresh_activity
      sandbox_api.update_last_activity(id)
      nil
    rescue StandardError => e
      raise Sdk::Error, "Failed to refresh sandbox activity: #{e.message}"
    end

    # Revokes an SSH access token for the sandbox.
    #
    # @param token [String]
    # @return [void]
    def revoke_ssh_access(token) = sandbox_api.revoke_ssh_access(id, token:)

    # Starts the Sandbox and waits for it to be ready.
    #
    # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
    # @return [void]
    def start(timeout = DEFAULT_TIMEOUT)
      with_timeout(
        timeout:,
        message: "Sandbox #{id} failed to become ready within the #{timeout} seconds timeout period",
        setup: proc { process_response(sandbox_api.start_sandbox(id)) }
      ) { wait_for_states(operation: OPERATION_START, target_states: [DaytonaApiClient::SandboxState::STARTED]) }
    end

    # Recovers the Sandbox from a recoverable error and waits for it to be ready.
    #
    # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
    # @return [void]
    #
    # @example
    #   sandbox = daytona.get('my-sandbox-id')
    #   sandbox.recover(timeout: 40)  # Wait up to 40 seconds
    #   puts 'Sandbox recovered successfully'
    def recover(timeout = DEFAULT_TIMEOUT)
      with_timeout(
        timeout:,
        message: "Sandbox #{id} failed to recover within the #{timeout} seconds timeout period",
        setup: proc { process_response(sandbox_api.recover_sandbox(id)) }
      ) { wait_for_states(operation: OPERATION_START, target_states: [DaytonaApiClient::SandboxState::STARTED]) }
    rescue StandardError => e
      raise Sdk::Error, "Failed to recover sandbox: #{e.message}"
    end

    # Stops the Sandbox and waits for it to be stopped.
    #
    # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
    # @param force [Boolean] If true, uses SIGKILL instead of SIGTERM (defaults to false).
    # @return [void]
    def stop(timeout = DEFAULT_TIMEOUT, force: false) # rubocop:disable Metrics/MethodLength
      with_timeout(
        timeout:,
        message: "Sandbox #{id} failed to become stopped within the #{timeout} seconds timeout period",
        setup: proc {
          sandbox_api.stop_sandbox(id, { force: force })
          refresh
        }
      ) do
        wait_for_states(
          operation: OPERATION_STOP,
          target_states: [DaytonaApiClient::SandboxState::STOPPED, DaytonaApiClient::SandboxState::DESTROYED]
        )
      end
    end

    # Resizes the Sandbox resources.
    #
    # Changes the CPU, memory, or disk allocation for the Sandbox. Resizing a started
    # sandbox allows increasing CPU and memory. To resize disk or decrease resources,
    # the sandbox must be stopped first.
    #
    # @param resources [Daytona::Resources] New resource configuration
    # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
    # @return [void]
    #
    # @example Resize a started sandbox (CPU and memory can be increased)
    #   sandbox.resize(Daytona::Resources.new(cpu: 4, memory: 8))
    #
    # @example Resize a stopped sandbox (CPU, memory, and disk can be changed)
    #   sandbox.stop
    #   sandbox.resize(Daytona::Resources.new(cpu: 2, memory: 4, disk: 30))
    def resize(resources, timeout = DEFAULT_TIMEOUT)
      raise Sdk::Error, 'Resources must not be nil' if resources.nil?

      with_timeout(
        timeout:,
        message: "Sandbox #{id} failed to resize within the #{timeout} seconds timeout period",
        setup: proc {
          resize_attrs = {}
          resize_attrs[:cpu] = resources.cpu if resources.cpu
          resize_attrs[:memory] = resources.memory if resources.memory
          resize_attrs[:disk] = resources.disk if resources.disk
          resize_request = DaytonaApiClient::ResizeSandbox.new(resize_attrs)
          process_response(sandbox_api.resize_sandbox(id, resize_request))
        }
      ) { wait_for_resize_complete }
    end

    # Waits for the Sandbox resize operation to complete.
    # Polls the Sandbox status until the state is no longer 'resizing'.
    #
    # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
    # @return [void]
    def wait_for_resize_complete(_timeout = DEFAULT_TIMEOUT)
      wait_for_states(operation: OPERATION_RESIZE, target_states: [DaytonaApiClient::SandboxState::STARTED,
                                                                   DaytonaApiClient::SandboxState::STOPPED])
    end

    # Creates a new Language Server Protocol (LSP) server instance.
    # The LSP server provides language-specific features like code completion,
    # diagnostics, and more.
    #
    # @param language_id [Symbol] The language server type (e.g., Daytona::LspServer::Language::PYTHON)
    # @param path_to_project [String] Path to the project root directory. Relative paths are resolved
    #                      based on the sandbox working directory.
    # @return [Daytona::LspServer]
    def create_lsp_server(language_id:, path_to_project:)
      LspServer.new(language_id:, path_to_project:, toolbox_api: @lsp_api, sandbox_id: id, otel_state:)
    end

    #  Validates an SSH access token for the sandbox.
    #
    # @param token [String]
    # @return [DaytonaApiClient::SshAccessValidationDto]
    def validate_ssh_access(token) = sandbox_api.validate_ssh_access(token)

    # Waits for the Sandbox to reach the 'started' state. Polls the Sandbox status until it
    # reaches the 'started' state or encounters an error.
    #
    # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
    # @return [void]
    def wait_for_sandbox_start(_timeout = DEFAULT_TIMEOUT)
      wait_for_states(operation: OPERATION_START, target_states: [DaytonaApiClient::SandboxState::STARTED])
    end

    # Waits for the Sandbox to reach the 'stopped' state. Polls the Sandbox status until it
    # reaches the 'stopped' state or encounters an error.
    # Treats destroyed as stopped to cover ephemeral sandboxes that are automatically deleted after stopping.
    #
    # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
    # @return [void]
    def wait_for_sandbox_stop(_timeout = DEFAULT_TIMEOUT)
      wait_for_states(operation: OPERATION_STOP, target_states: [DaytonaApiClient::SandboxState::STOPPED,
                                                                 DaytonaApiClient::SandboxState::DESTROYED])
    end

    # Forks the Sandbox, creating a new Sandbox with an identical filesystem.
    # The forked Sandbox is a copy-on-write clone of the original. It starts
    # with the same disk contents but operates independently from that point on.
    #
    # @param name [String, nil] Optional name for the forked Sandbox
    # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
    # @return [Daytona::Sandbox] The forked Sandbox
    def experimental_fork(name: nil, timeout: DEFAULT_TIMEOUT) # rubocop:disable Metrics/MethodLength
      forked_dto = nil
      with_timeout(
        timeout:,
        message: "Sandbox #{id} fork failed to become ready within the #{timeout} seconds timeout period",
        setup: proc {
          forked_dto = sandbox_api.fork_sandbox(id, DaytonaApiClient::ForkSandbox.new(name:))
        }
      ) do
        forked = Sandbox.new(
          sandbox_dto: forked_dto,
          config:,
          sandbox_api:,
          code_toolbox:,
          otel_state:
        )
        forked.send(:wait_for_states, operation: OPERATION_START,
                                      target_states: [DaytonaApiClient::SandboxState::STARTED])
        return forked
      end
    end

    # Creates a snapshot from the current state of the Sandbox.
    # The Sandbox will temporarily enter a 'snapshotting' state and return to its previous state when complete.
    #
    # @param name [String] Name for the new snapshot
    # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
    # @return [void]
    def experimental_create_snapshot(name:, timeout: DEFAULT_TIMEOUT)
      with_timeout(
        timeout:,
        message: "Sandbox #{id} snapshot failed within the #{timeout} seconds timeout period",
        setup: proc {
          sandbox_api.create_sandbox_snapshot(id, DaytonaApiClient::CreateSandboxSnapshot.new(name:))
          refresh
        }
      ) { wait_for_snapshot_complete }
    end

    instrument :archive, :auto_archive_interval=, :auto_delete_interval=, :auto_stop_interval=,
               :update_network_settings,
               :create_ssh_access, :delete, :get_user_home_dir, :get_work_dir, :labels=,
               :preview_url, :create_signed_preview_url, :expire_signed_preview_url,
               :refresh, :refresh_activity, :revoke_ssh_access, :start, :recover, :stop,
               :create_lsp_server, :validate_ssh_access, :wait_for_sandbox_start,
               :wait_for_sandbox_stop, :resize, :wait_for_resize_complete,
               :experimental_fork, :experimental_create_snapshot,
               component: 'Sandbox'

    private

    # @return [Daytona::OtelState, nil]
    attr_reader :otel_state

    # Build toolbox API configuration with dynamic base URL from preview link
    # @return [DaytonaToolboxApiClient::Configuration]
    def build_toolbox_api_config
      DaytonaToolboxApiClient::Configuration.new.configure do |cfg|
        proxy_url = @toolbox_proxy_url
        proxy_url += '/' unless proxy_url.end_with?('/')
        full_url = "#{proxy_url}#{id}"
        uri = URI(full_url)

        cfg.scheme = uri.scheme
        cfg.host = uri.authority # Includes hostname:port
        cfg.base_path = uri.path.empty? ? '/' : uri.path

        cfg
      end
    end

    # @params sandbox_dto [DaytonaApiClient::Sandbox]
    # @return [void]
    def process_response(sandbox_dto) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
      @id = sandbox_dto.id
      @organization_id = sandbox_dto.organization_id
      @snapshot = sandbox_dto.snapshot
      @user = sandbox_dto.user
      @env = sandbox_dto.env
      @labels = sandbox_dto.labels
      @public = sandbox_dto.public
      @target = sandbox_dto.target
      @cpu = sandbox_dto.cpu
      @gpu = sandbox_dto.gpu
      @memory = sandbox_dto.memory
      @disk = sandbox_dto.disk
      @state = sandbox_dto.state
      @desired_state = sandbox_dto.desired_state
      @error_reason = sandbox_dto.error_reason
      @backup_state = sandbox_dto.backup_state
      @backup_created_at = sandbox_dto.backup_created_at
      @auto_stop_interval = sandbox_dto.auto_stop_interval
      @auto_archive_interval = sandbox_dto.auto_archive_interval
      @auto_delete_interval = sandbox_dto.auto_delete_interval
      @volumes = sandbox_dto.volumes
      @build_info = sandbox_dto.build_info
      @created_at = sandbox_dto.created_at
      @updated_at = sandbox_dto.updated_at
      @last_activity_at = sandbox_dto.last_activity_at
      @daemon_version = sandbox_dto.daemon_version
      @network_block_all = sandbox_dto.network_block_all
      @network_allow_list = sandbox_dto.network_allow_list
      @toolbox_proxy_url = sandbox_dto.toolbox_proxy_url
    end

    # Monitors block not to exceed max execution time.
    #
    # @param setup [#call, Nil] Optional setup block
    # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
    # @param message [String] Error message
    # @return [void]
    # @raise [Daytona::Sdk::Error]
    def with_timeout(message:, setup:, timeout: DEFAULT_TIMEOUT, &)
      start_at = Time.now
      setup&.call

      Timeout.timeout(
        setup ? [NO_TIMEOUT, timeout - (Time.now - start_at)].max : timeout,
        Sdk::Error,
        message,
        &
      )
    end

    # Waits for the Sandbox to reach the one of the target states. Polls the Sandbox status until it
    # reaches the one of the target states or encounters an error. It will wait up to 60 seconds
    # for the Sandbox to reach one of the target states.
    #
    # @param operation [#to_s] Operation name for error message
    # @param target_states [Array<DaytonaApiClient::SandboxState>] List of the target states
    # @return [void]
    # @raise [Daytona::Sdk::Error]
    def wait_for_states(operation:, target_states:)
      interval = INITIAL_POLL_INTERVAL
      start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
      loop do
        case state
        when *target_states then return
        when DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED
          raise Sdk::Error, "Sandbox #{id} failed to #{operation} with state: #{state}, error reason: #{error_reason}"
        end

        sleep(interval)
        if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time > 5
          interval = [interval * BACKOFF_MULTIPLIER, MAX_POLL_INTERVAL].min
        end
        refresh
      end
    end

    INITIAL_POLL_INTERVAL = 0.1
    private_constant :INITIAL_POLL_INTERVAL

    MAX_POLL_INTERVAL = 1.0
    private_constant :MAX_POLL_INTERVAL

    BACKOFF_MULTIPLIER = 1.1
    private_constant :BACKOFF_MULTIPLIER

    NO_TIMEOUT = 0
    private_constant :NO_TIMEOUT

    OPERATION_START = :start
    private_constant :OPERATION_START

    OPERATION_STOP = :stop
    private_constant :OPERATION_STOP

    OPERATION_RESIZE = :resize
    private_constant :OPERATION_RESIZE

    def wait_for_snapshot_complete
      interval = INITIAL_POLL_INTERVAL
      start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
      while state == DaytonaApiClient::SandboxState::SNAPSHOTTING
        refresh

        if [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED].include?(state)
          raise Sdk::Error,
                "Sandbox #{id} snapshot failed with state: #{state}, error reason: #{error_reason}"
        end

        break if state != DaytonaApiClient::SandboxState::SNAPSHOTTING

        sleep(interval)
        if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time > 5
          interval = [interval * BACKOFF_MULTIPLIER, MAX_POLL_INTERVAL].min
        end
      end
    end
  end
end
