#!/usr/bin/env ruby
# frozen_string_literal: true

# PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
#
# unsandbox.com Ruby SDK (Synchronous)
#
# Library Usage:
#     require_relative 'un'
#
#     # Execute code synchronously
#     result = Un.execute_code("python", 'print("hello")')
#
#     # Execute asynchronously and get job_id
#     job_id = Un.execute_async("javascript", 'console.log("hello")')
#
#     # Wait for job completion with exponential backoff
#     result = Un.wait_for_job(job_id)
#
#     # List all jobs
#     jobs = Un.list_jobs
#
#     # Get supported languages (cached for 1 hour)
#     languages = Un.get_languages
#
#     # Snapshot operations
#     snapshot_id = Un.session_snapshot(session_id)
#
# CLI Usage:
#     ruby un.rb script.py                  # Execute Python script
#     ruby un.rb -s bash 'echo hello'       # Inline bash command
#     ruby un.rb session --list             # List sessions
#     ruby un.rb service --list             # List services
#     ruby un.rb snapshot --list            # List snapshots
#     ruby un.rb key                        # Check API key
#
# Authentication Priority (4-tier):
#     1. Method arguments (public_key:, secret_key:)
#     2. --account N flag (accounts.csv row N, overrides env vars)
#     3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
#     4. Config file (~/.unsandbox/accounts.csv, line 0 by default)
#     5. Local directory (./accounts.csv, line 0 by default)
#
# Request Authentication (HMAC-SHA256):
#     Authorization: Bearer <public_key>
#     X-Timestamp: <unix_seconds>
#     X-Signature: HMAC-SHA256(secret_key, "timestamp:METHOD:path:body")
#
# Languages Cache:
#     - Cached in ~/.unsandbox/languages.json
#     - TTL: 1 hour
#     - Updated on successful API calls

require 'net/http'
require 'uri'
require 'json'
require 'openssl'
require 'fileutils'
require 'optparse'
require 'cgi'
require 'base64'

# Unsandbox Ruby SDK module (synchronous)
module Un
  # API base URL
  API_BASE = 'https://api.unsandbox.com'

  # Polling delays in milliseconds for exponential backoff
  POLL_DELAYS_MS = [300, 450, 700, 900, 650, 1600, 2000].freeze

  # Languages cache TTL in seconds (1 hour)
  LANGUAGES_CACHE_TTL = 3600

  # HTTP request timeout in seconds
  REQUEST_TIMEOUT = 120

  # Error raised when credentials cannot be found or are invalid
  class CredentialsError < StandardError; end

  # Error raised for API request failures
  class APIError < StandardError
    attr_reader :status_code, :response_body

    # @param message [String] Error message
    # @param status_code [Integer, nil] HTTP status code
    # @param response_body [String, nil] Response body
    def initialize(message, status_code: nil, response_body: nil)
      super(message)
      @status_code = status_code
      @response_body = response_body
    end
  end

  class << self
    # Execute code synchronously (blocks until completion)
    #
    # @param language [String] Programming language (e.g., "python", "javascript", "go")
    # @param code [String] Source code to execute
    # @param public_key [String, nil] Optional API key (uses credentials resolution if not provided)
    # @param secret_key [String, nil] Optional API secret (uses credentials resolution if not provided)
    # @return [Hash] Response hash containing stdout, stderr, exit code, etc.
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.execute_code("python", 'print("hello")')
    #     puts result["stdout"]  # => "hello\n"
    def execute_code(language, code, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      response = make_request(
        'POST',
        '/execute',
        pk,
        sk,
        { language: language, code: code }
      )

      # If we got a job_id, poll until completion
      job_id = response['job_id']
      status = response['status']

      if job_id && %w[pending running].include?(status)
        return wait_for_job(job_id, public_key: pk, secret_key: sk)
      end

      response
    end

    # Execute code asynchronously (returns immediately with job_id)
    #
    # @param language [String] Programming language (e.g., "python", "javascript")
    # @param code [String] Source code to execute
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [String] Job ID string
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     job_id = Un.execute_async("python", 'import time; time.sleep(10); print("done")')
    #     # Later...
    #     result = Un.wait_for_job(job_id)
    def execute_async(language, code, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      response = make_request(
        'POST',
        '/execute',
        pk,
        sk,
        { language: language, code: code }
      )
      response['job_id']
    end

    # Get current status/result of a job (single poll, no waiting)
    #
    # @param job_id [String] Job ID from execute_async
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Job response hash
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     job = Un.get_job(job_id)
    #     puts job["status"]  # => "running" or "completed"
    def get_job(job_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('GET', "/jobs/#{job_id}", pk, sk)
    end

    # Wait for job completion with exponential backoff polling
    #
    # Polling delays (ms): [300, 450, 700, 900, 650, 1600, 2000, ...]
    # Cumulative: 300, 750, 1450, 2350, 3000, 4600, 6600ms+
    #
    # @param job_id [String] Job ID from execute_async
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param timeout [Integer] Maximum time to wait in seconds (default: 3600)
    # @return [Hash] Final job result when status is terminal (completed, failed, timeout, cancelled)
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails or timeout reached
    #
    # @example
    #     result = Un.wait_for_job(job_id, timeout: 60)
    #     puts result["stdout"]
    def wait_for_job(job_id, public_key: nil, secret_key: nil, timeout: 3600)
      pk, sk = resolve_credentials(public_key, secret_key)
      poll_count = 0
      start_time = Time.now

      loop do
        # Check timeout
        elapsed = Time.now - start_time
        raise APIError, "Job wait timeout after #{timeout} seconds" if elapsed > timeout

        # Sleep before polling
        delay_idx = [poll_count, POLL_DELAYS_MS.length - 1].min
        sleep(POLL_DELAYS_MS[delay_idx] / 1000.0)
        poll_count += 1

        response = get_job(job_id, public_key: pk, secret_key: sk)
        status = response['status']

        return response if %w[completed failed timeout cancelled].include?(status)

        # Still running, continue polling
      end
    end

    # Cancel a running job
    #
    # @param job_id [String] Job ID to cancel
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with cancellation confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.cancel_job(job_id)
    def cancel_job(job_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('DELETE', "/jobs/#{job_id}", pk, sk)
    end

    # List all jobs for the authenticated account
    #
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Array<Hash>] List of job hashes
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     jobs = Un.list_jobs
    #     jobs.each { |job| puts "#{job['job_id']}: #{job['status']}" }
    def list_jobs(public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      response = make_request('GET', '/jobs', pk, sk)
      response['jobs'] || []
    end

    # Get list of supported programming languages
    #
    # Results are cached for 1 hour in ~/.unsandbox/languages.json
    #
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Array<String>] List of language identifiers (e.g., ["python", "javascript", "go", ...])
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     languages = Un.get_languages
    #     puts languages.join(", ")
    def get_languages(public_key: nil, secret_key: nil)
      # Try cache first
      cached = load_languages_cache
      return cached if cached

      pk, sk = resolve_credentials(public_key, secret_key)
      response = make_request('GET', '/languages', pk, sk)
      languages = response['languages'] || []

      # Cache the result
      save_languages_cache(languages)
      languages
    end

    # Detect programming language from filename extension
    #
    # @param filename [String] Filename to detect language from (e.g., "script.py")
    # @return [String, nil] Language identifier (e.g., "python") or nil if unknown
    #
    # @example
    #     Un.detect_language("hello.py")   # => "python"
    #     Un.detect_language("script.js")  # => "javascript"
    #     Un.detect_language("main.go")    # => "go"
    #     Un.detect_language("unknown")    # => nil
    def detect_language(filename)
      return nil if filename.nil? || !filename.include?('.')

      ext = filename.split('.').last&.downcase
      LANGUAGE_MAP[ext]
    end

    # Create a snapshot of a session
    #
    # @param session_id [String] Session ID to snapshot
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param name [String, nil] Optional snapshot name
    # @param ephemeral [Boolean] If true, create ephemeral snapshot (default: false)
    # @return [String] Snapshot ID
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     snapshot_id = Un.session_snapshot(session_id, name: "my-snapshot")
    def session_snapshot(session_id, public_key: nil, secret_key: nil, name: nil, ephemeral: false)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = { session_id: session_id, hot: ephemeral }
      data[:name] = name if name

      response = make_request('POST', '/snapshots', pk, sk, data)
      response['snapshot_id']
    end

    # Create a snapshot of a service
    #
    # @param service_id [String] Service ID to snapshot
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param name [String, nil] Optional snapshot name
    # @return [String] Snapshot ID
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     snapshot_id = Un.service_snapshot(service_id, name: "production-backup")
    def service_snapshot(service_id, public_key: nil, secret_key: nil, name: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = { service_id: service_id, hot: false }
      data[:name] = name if name

      response = make_request('POST', '/snapshots', pk, sk, data)
      response['snapshot_id']
    end

    # List all snapshots
    #
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Array<Hash>] List of snapshot hashes
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     snapshots = Un.list_snapshots
    #     snapshots.each { |s| puts "#{s['snapshot_id']}: #{s['name']}" }
    def list_snapshots(public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      response = make_request('GET', '/snapshots', pk, sk)
      response['snapshots'] || []
    end

    # Get details of a specific snapshot
    #
    # @param snapshot_id [String] Snapshot ID to get details for
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Snapshot details hash containing:
    #   - id: Snapshot ID
    #   - name: Snapshot name
    #   - type: "session" or "service"
    #   - source_id: Original resource ID
    #   - hot: Whether snapshot preserves running state
    #   - locked: Whether snapshot is locked
    #   - created_at: Creation timestamp
    #   - size_bytes: Size in bytes
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     snapshot = Un.get_snapshot(snapshot_id)
    #     puts "Snapshot: #{snapshot['name']} (#{snapshot['type']})"
    def get_snapshot(snapshot_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('GET', "/snapshots/#{snapshot_id}", pk, sk)
    end

    # Restore a snapshot
    #
    # @param snapshot_id [String] Snapshot ID to restore
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with restored resource info
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.restore_snapshot(snapshot_id)
    #     puts result["session_id"]  # or result["service_id"]
    def restore_snapshot(snapshot_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/snapshots/#{snapshot_id}/restore", pk, sk, {})
    end

    # Delete a snapshot
    #
    # @param snapshot_id [String] Snapshot ID to delete
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with deletion confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.delete_snapshot(snapshot_id)
    def delete_snapshot(snapshot_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request_with_sudo('DELETE', "/snapshots/#{snapshot_id}", pk, sk)
    end

    # Lock a snapshot to prevent deletion
    #
    # @param snapshot_id [String] Snapshot ID to lock
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with lock confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.lock_snapshot(snapshot_id)
    def lock_snapshot(snapshot_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/snapshots/#{snapshot_id}/lock", pk, sk, {})
    end

    # Unlock a snapshot to allow deletion
    #
    # @param snapshot_id [String] Snapshot ID to unlock
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with unlock confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.unlock_snapshot(snapshot_id)
    def unlock_snapshot(snapshot_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request_with_sudo('POST', "/snapshots/#{snapshot_id}/unlock", pk, sk, {})
    end

    # Clone a snapshot to create a new session or service
    #
    # @param snapshot_id [String] Snapshot ID to clone
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param name [String, nil] Optional name for the cloned resource
    # @param type [String] Type of resource to create ("session" or "service")
    # @param shell [String, nil] Optional shell for session clones
    # @param ports [Array<Integer>, nil] Optional ports for service clones
    # @return [Hash] Response hash with cloned resource info (session_id or service_id)
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example Clone to session
    #     result = Un.clone_snapshot(snapshot_id, type: "session")
    #     puts result["session_id"]
    #
    # @example Clone to service
    #     result = Un.clone_snapshot(snapshot_id, type: "service", ports: [80, 443])
    #     puts result["service_id"]
    def clone_snapshot(snapshot_id, public_key: nil, secret_key: nil, name: nil, type: 'session', shell: nil, ports: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = { type: type }
      data[:name] = name if name
      data[:shell] = shell if shell
      data[:ports] = ports if ports
      make_request('POST', "/snapshots/#{snapshot_id}/clone", pk, sk, data)
    end

    # ============================================================================
    # Image Functions (LXD Container Images)
    # ============================================================================

    # Publish an LXD container image from a session or service
    #
    # @param source_type [String] Source type ("session" or "service")
    # @param source_id [String] ID of the session or service to publish
    # @param name [String, nil] Optional name for the image
    # @param description [String, nil] Optional description for the image
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with image_id and other metadata
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example Publish from session
    #     result = Un.image_publish("session", session_id, name: "my-image")
    #     puts result["image_id"]
    #
    # @example Publish from service
    #     result = Un.image_publish("service", service_id, description: "Production snapshot")
    #     puts result["image_id"]
    def image_publish(source_type, source_id, name: nil, description: nil, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = { source_type: source_type, source_id: source_id }
      data[:name] = name if name
      data[:description] = description if description
      make_request('POST', '/images', pk, sk, data)
    end

    # List all images for the authenticated account
    #
    # @param filter_type [String, nil] Optional filter: "own", "shared", or "public"
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Array<Hash>] List of image hashes
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example List all images
    #     images = Un.list_images
    #     images.each { |img| puts "#{img['image_id']}: #{img['name']}" }
    #
    # @example List only owned images
    #     owned = Un.list_images(filter_type: "own")
    #
    # @example List shared images
    #     shared = Un.list_images(filter_type: "shared")
    def list_images(filter_type: nil, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      path = filter_type ? "/images/#{filter_type}" : '/images'
      response = make_request('GET', path, pk, sk)
      response['images'] || []
    end

    # Get image details by ID
    #
    # @param image_id [String] Image ID to retrieve
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Image details hash
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     image = Un.get_image(image_id)
    #     puts "#{image['name']}: #{image['description']}"
    def get_image(image_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('GET', "/images/#{image_id}", pk, sk)
    end

    # Delete an image
    #
    # @param image_id [String] Image ID to delete
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with deletion confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.delete_image(image_id)
    def delete_image(image_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request_with_sudo('DELETE', "/images/#{image_id}", pk, sk)
    end

    # Lock an image to prevent deletion
    #
    # @param image_id [String] Image ID to lock
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with lock confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.lock_image(image_id)
    def lock_image(image_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/images/#{image_id}/lock", pk, sk, {})
    end

    # Unlock an image to allow deletion
    #
    # @param image_id [String] Image ID to unlock
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with unlock confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.unlock_image(image_id)
    def unlock_image(image_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request_with_sudo('POST', "/images/#{image_id}/unlock", pk, sk, {})
    end

    # Set image visibility (private, public, or shared)
    #
    # @param image_id [String] Image ID to update
    # @param visibility [String] Visibility level: "private", "public", or "shared"
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with visibility confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example Make image public
    #     Un.set_image_visibility(image_id, "public")
    #
    # @example Make image private
    #     Un.set_image_visibility(image_id, "private")
    def set_image_visibility(image_id, visibility, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/images/#{image_id}/visibility", pk, sk, { visibility: visibility })
    end

    # Grant access to an image for another API key
    #
    # @param image_id [String] Image ID to share
    # @param trusted_api_key [String] Public API key to grant access to
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with grant confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.grant_image_access(image_id, "unsb-pk-xxxxx-xxxxx-xxxxx-xxxxx")
    def grant_image_access(image_id, trusted_api_key, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/images/#{image_id}/grant", pk, sk, { trusted_api_key: trusted_api_key })
    end

    # Revoke access to an image from another API key
    #
    # @param image_id [String] Image ID to revoke access from
    # @param trusted_api_key [String] Public API key to revoke access from
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with revoke confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.revoke_image_access(image_id, "unsb-pk-xxxxx-xxxxx-xxxxx-xxxxx")
    def revoke_image_access(image_id, trusted_api_key, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/images/#{image_id}/revoke", pk, sk, { trusted_api_key: trusted_api_key })
    end

    # List API keys with access to an image
    #
    # @param image_id [String] Image ID to list trusted keys for
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Array<Hash>] List of trusted API key hashes
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     trusted = Un.list_image_trusted(image_id)
    #     trusted.each { |t| puts t["api_key"] }
    def list_image_trusted(image_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      response = make_request('GET', "/images/#{image_id}/trusted", pk, sk)
      response['trusted'] || []
    end

    # Transfer image ownership to another API key
    #
    # @param image_id [String] Image ID to transfer
    # @param to_api_key [String] Public API key to transfer ownership to
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with transfer confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.transfer_image(image_id, "unsb-pk-xxxxx-xxxxx-xxxxx-xxxxx")
    def transfer_image(image_id, to_api_key, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/images/#{image_id}/transfer", pk, sk, { to_api_key: to_api_key })
    end

    # Spawn a new service from an image
    #
    # @param image_id [String] Image ID to spawn from
    # @param name [String, nil] Optional name for the service
    # @param ports [Array<Integer>, nil] Optional ports to expose
    # @param bootstrap [String, nil] Optional bootstrap script
    # @param network_mode [String] Network mode ("zerotrust" or "semitrusted", default: "zerotrust")
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with service_id and other metadata
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example Spawn with defaults
    #     result = Un.spawn_from_image(image_id)
    #     puts result["service_id"]
    #
    # @example Spawn with custom config
    #     result = Un.spawn_from_image(image_id, name: "web", ports: [80, 443], network_mode: "semitrusted")
    #     puts result["service_id"]
    def spawn_from_image(image_id, name: nil, ports: nil, bootstrap: nil, network_mode: 'zerotrust', public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = { network_mode: network_mode }
      data[:name] = name if name
      data[:ports] = ports if ports
      data[:bootstrap] = bootstrap if bootstrap
      make_request('POST', "/images/#{image_id}/spawn", pk, sk, data)
    end

    # Clone an image to create a new image
    #
    # @param image_id [String] Image ID to clone
    # @param name [String, nil] Optional name for the cloned image
    # @param description [String, nil] Optional description for the cloned image
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with new image_id
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.clone_image(image_id, name: "my-clone", description: "Cloned from production")
    #     puts result["image_id"]
    def clone_image(image_id, name: nil, description: nil, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = {}
      data[:name] = name if name
      data[:description] = description if description
      make_request('POST', "/images/#{image_id}/clone", pk, sk, data)
    end

    # ============================================================================
    # Session Functions
    # ============================================================================

    # List all sessions for the authenticated account
    #
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Array<Hash>] List of session hashes
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     sessions = Un.list_sessions
    #     sessions.each { |s| puts "#{s['id']}: #{s['status']}" }
    def list_sessions(public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      response = make_request('GET', '/sessions', pk, sk)
      response['sessions'] || []
    end

    # Get session details by ID
    #
    # @param session_id [String] Session ID to retrieve
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Session details hash
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     session = Un.get_session(session_id)
    #     puts session["status"]
    def get_session(session_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('GET', "/sessions/#{session_id}", pk, sk)
    end

    # Create a new interactive session
    #
    # @param language [String] Shell or language for the session (e.g., "bash", "python3")
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param network_mode [String] Network mode ("zerotrust" or "semitrusted")
    # @param ttl [Integer] Time-to-live in seconds (default: 3600)
    # @param multiplexer [String, nil] Terminal multiplexer ("tmux" or "screen")
    # @param vcpu [Integer] Number of vCPUs (1-8)
    # @return [Hash] Response hash with session_id and container_name
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.create_session("bash", network_mode: "semitrusted")
    #     puts result["session_id"]
    def create_session(language, public_key: nil, secret_key: nil, network_mode: 'zerotrust', ttl: 3600, multiplexer: nil, vcpu: 1)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = {
        network_mode: network_mode,
        ttl: ttl
      }
      data[:shell] = language if language
      data[:multiplexer] = multiplexer if multiplexer
      data[:vcpu] = vcpu if vcpu > 1
      make_request('POST', '/sessions', pk, sk, data)
    end

    # Delete (terminate) a session
    #
    # @param session_id [String] Session ID to delete
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with deletion confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.delete_session(session_id)
    def delete_session(session_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('DELETE', "/sessions/#{session_id}", pk, sk)
    end

    # Freeze a session (pause execution, reduce resource usage)
    #
    # @param session_id [String] Session ID to freeze
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with freeze confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.freeze_session(session_id)
    def freeze_session(session_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/sessions/#{session_id}/freeze", pk, sk, {})
    end

    # Unfreeze a session (resume execution)
    #
    # @param session_id [String] Session ID to unfreeze
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with unfreeze confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.unfreeze_session(session_id)
    def unfreeze_session(session_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/sessions/#{session_id}/unfreeze", pk, sk, {})
    end

    # Boost a session (increase vCPU allocation)
    #
    # @param session_id [String] Session ID to boost
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with boost confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.boost_session(session_id)
    def boost_session(session_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/sessions/#{session_id}/boost", pk, sk, {})
    end

    # Unboost a session (reduce vCPU allocation)
    #
    # @param session_id [String] Session ID to unboost
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with unboost confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.unboost_session(session_id)
    def unboost_session(session_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/sessions/#{session_id}/unboost", pk, sk, {})
    end

    # Execute a shell command in an existing session
    # Note: This is for non-interactive command execution, not for WebSocket shell access
    #
    # @param session_id [String] Session ID to execute command in
    # @param command [String] Command to execute
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with command output
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.shell_session(session_id, "ls -la")
    #     puts result["stdout"]
    def shell_session(session_id, command, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/sessions/#{session_id}/shell", pk, sk, { command: command })
    end

    # ============================================================================
    # Service Functions
    # ============================================================================

    # List all services for the authenticated account
    #
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Array<Hash>] List of service hashes
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     services = Un.list_services
    #     services.each { |s| puts "#{s['id']}: #{s['name']} (#{s['state']})" }
    def list_services(public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      response = make_request('GET', '/services', pk, sk)
      response['services'] || []
    end

    # Create a new persistent service
    #
    # @param name [String] Service name
    # @param ports [Array<Integer>] Ports to expose
    # @param bootstrap [String] Bootstrap script content or URL
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param network_mode [String] Network mode ("zerotrust" or "semitrusted")
    # @param vcpu [Integer] Number of vCPUs (1-8)
    # @param custom_domains [Array<String>, nil] Custom domains for the service
    # @param service_type [String, nil] Service type for SRV records (e.g., "minecraft")
    # @param unfreeze_on_demand [Boolean] If true, service will auto-wake on HTTP request (default: false)
    # @param input_files [Array<Hash>, nil] Optional list of hashes with "filename" and "content" (base64)
    # @return [Hash] Response hash with service_id
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.create_service("web", [80, 443], "apt install -y nginx && nginx")
    #     puts result["service_id"]
    def create_service(name, ports, bootstrap, public_key: nil, secret_key: nil, network_mode: 'semitrusted', vcpu: 1, custom_domains: nil, service_type: nil, unfreeze_on_demand: false, input_files: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = {
        name: name,
        ports: ports,
        bootstrap: bootstrap,
        network_mode: network_mode
      }
      data[:vcpu] = vcpu if vcpu > 1
      data[:custom_domains] = custom_domains if custom_domains
      data[:service_type] = service_type if service_type
      data[:unfreeze_on_demand] = unfreeze_on_demand if unfreeze_on_demand
      data[:input_files] = input_files if input_files && !input_files.empty?
      make_request('POST', '/services', pk, sk, data)
    end

    # Get service details by ID
    #
    # @param service_id [String] Service ID to retrieve
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Service details hash
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     service = Un.get_service(service_id)
    #     puts service["status"]
    def get_service(service_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('GET', "/services/#{service_id}", pk, sk)
    end

    # Update a service (e.g., resize vCPU)
    #
    # @param service_id [String] Service ID to update
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param vcpu [Integer, nil] New vCPU count (1-8)
    # @param name [String, nil] New service name
    # @return [Hash] Response hash with update confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.update_service(service_id, vcpu: 4)
    def update_service(service_id, public_key: nil, secret_key: nil, vcpu: nil, name: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = {}
      data[:vcpu] = vcpu if vcpu
      data[:name] = name if name
      make_request('PATCH', "/services/#{service_id}", pk, sk, data)
    end

    # Delete (destroy) a service
    #
    # @param service_id [String] Service ID to delete
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with deletion confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.delete_service(service_id)
    def delete_service(service_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request_with_sudo('DELETE', "/services/#{service_id}", pk, sk)
    end

    # Freeze a service (stop container, reduce resource usage)
    #
    # @param service_id [String] Service ID to freeze
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with freeze confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.freeze_service(service_id)
    def freeze_service(service_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/services/#{service_id}/freeze", pk, sk, {})
    end

    # Unfreeze a service (start container)
    #
    # @param service_id [String] Service ID to unfreeze
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with unfreeze confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.unfreeze_service(service_id)
    def unfreeze_service(service_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/services/#{service_id}/unfreeze", pk, sk, {})
    end

    # Lock a service to prevent deletion
    #
    # @param service_id [String] Service ID to lock
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with lock confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.lock_service(service_id)
    def lock_service(service_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/services/#{service_id}/lock", pk, sk, {})
    end

    # Unlock a service to allow deletion
    #
    # @param service_id [String] Service ID to unlock
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with unlock confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.unlock_service(service_id)
    def unlock_service(service_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request_with_sudo('POST', "/services/#{service_id}/unlock", pk, sk, {})
    end

    # Set unfreeze-on-demand for a service
    #
    # When enabled, a frozen service will automatically wake when it receives an HTTP request.
    #
    # @param service_id [String] Service ID to update
    # @param enabled [Boolean] Whether to enable unfreeze-on-demand
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with update confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.set_unfreeze_on_demand(service_id, true)
    def set_unfreeze_on_demand(service_id, enabled, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('PATCH', "/services/#{service_id}", pk, sk, { unfreeze_on_demand: enabled })
    end

    # Set show-freeze-page for a service
    #
    # When enabled, frozen services show a freeze page instead of 502 error.
    #
    # @param service_id [String] Service ID to update
    # @param enabled [Boolean] Whether to enable show-freeze-page
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with update confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.set_show_freeze_page(service_id, true)
    def set_show_freeze_page(service_id, enabled, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('PATCH', "/services/#{service_id}", pk, sk, { show_freeze_page: enabled })
    end

    # Get service logs (bootstrap output)
    #
    # @param service_id [String] Service ID to get logs for
    # @param all [Boolean] If true, get all logs; if false, get last 9000 lines (default: false)
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with log content
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     logs = Un.get_service_logs(service_id)
    #     puts logs["log"]
    def get_service_logs(service_id, all: false, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      path = "/services/#{service_id}/logs"
      path += '?all=true' if all
      make_request('GET', path, pk, sk)
    end

    # Get service environment vault status
    #
    # @param service_id [String] Service ID to get env status for
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with vault status (has_vault, count, updated_at)
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     status = Un.get_service_env(service_id)
    #     puts "Variables: #{status['count']}"
    def get_service_env(service_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('GET', "/services/#{service_id}/env", pk, sk)
    end

    # Set service environment variables (replaces existing vault)
    #
    # @param service_id [String] Service ID to set env for
    # @param env [String] Environment content in .env format (KEY=VALUE per line)
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.set_service_env(service_id, "API_KEY=secret\nDEBUG=true")
    def set_service_env(service_id, env, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      # This endpoint uses text/plain content type and PUT method
      make_request_text('PUT', "/services/#{service_id}/env", pk, sk, env)
    end

    # Delete service environment vault
    #
    # @param service_id [String] Service ID to delete env for
    # @param keys [Array<String>, nil] Specific keys to delete (nil = delete entire vault)
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with deletion confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example Delete entire vault
    #     Un.delete_service_env(service_id)
    #
    # @example Delete specific keys (if API supports it)
    #     Un.delete_service_env(service_id, keys: ["API_KEY", "DEBUG"])
    def delete_service_env(service_id, keys: nil, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      if keys
        make_request('DELETE', "/services/#{service_id}/env", pk, sk, { keys: keys })
      else
        make_request('DELETE', "/services/#{service_id}/env", pk, sk)
      end
    end

    # Export service environment vault (returns .env format)
    #
    # @param service_id [String] Service ID to export env from
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with env content
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.export_service_env(service_id)
    #     puts result["env"]  # API_KEY=secret\nDEBUG=true
    def export_service_env(service_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/services/#{service_id}/env/export", pk, sk, {})
    end

    # Redeploy a service (re-run bootstrap script)
    #
    # @param service_id [String] Service ID to redeploy
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param bootstrap [String, nil] New bootstrap script (optional)
    # @param input_files [Array<Hash>, nil] Optional list of hashes with "filename" and "content" (base64)
    # @return [Hash] Response hash with redeploy confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.redeploy_service(service_id)
    def redeploy_service(service_id, public_key: nil, secret_key: nil, bootstrap: nil, input_files: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = {}
      data[:bootstrap] = bootstrap if bootstrap
      data[:input_files] = input_files if input_files && !input_files.empty?
      make_request('POST', "/services/#{service_id}/redeploy", pk, sk, data)
    end

    # Execute a command in a running service
    #
    # @param service_id [String] Service ID to execute command in
    # @param command [String] Command to execute
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param timeout [Integer] Command timeout in milliseconds (default: 30000)
    # @return [Hash] Response hash with command output (stdout, stderr, exit_code)
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.execute_in_service(service_id, "ls -la")
    #     puts result["stdout"]
    def execute_in_service(service_id, command, public_key: nil, secret_key: nil, timeout: 30_000)
      pk, sk = resolve_credentials(public_key, secret_key)

      # Start async execution
      response = make_request('POST', "/services/#{service_id}/execute", pk, sk, {
                                command: command,
                                timeout: timeout
                              })

      job_id = response['job_id']
      return response unless job_id

      # Poll for completion
      wait_for_job(job_id, public_key: pk, secret_key: sk, timeout: (timeout / 1000) + 10)
    end

    # Resize a service's vCPU allocation
    #
    # @param service_id [String] Service ID to resize
    # @param vcpu [Integer] Number of vCPUs (1-8 typically)
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with updated service info
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.resize_service(service_id, 4)
    def resize_service(service_id, vcpu, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('PATCH', "/services/#{service_id}", pk, sk, { vcpu: vcpu })
    end

    # ============================================================================
    # Key Validation
    # ============================================================================

    # Validate API keys
    #
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with validation result and account info
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails or keys invalid
    #
    # @example
    #     result = Un.validate_keys
    #     puts result["valid"]  # true or false
    def validate_keys(public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      # Note: This endpoint is on the portal, not API, but we use same auth
      make_request('POST', '/keys/validate', pk, sk, {})
    end

    # Generate images from text prompt using AI.
    #
    # @param prompt [String] Text description of the image to generate
    # @param model [String, nil] Model to use (optional)
    # @param size [String] Image size (default: "1024x1024")
    # @param quality [String] "standard" or "hd" (default: "standard")
    # @param n [Integer] Number of images to generate (default: 1)
    # @param public_key [String, nil] API public key
    # @param secret_key [String, nil] API secret key
    # @return [Hash] Result with :images array and :created_at
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.image("A sunset over mountains")
    #     puts result["images"]  # Array of image data/URLs
    def image(prompt, model: nil, size: '1024x1024', quality: 'standard', n: 1,
              public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      payload = {
        prompt: prompt,
        size: size,
        quality: quality,
        n: n
      }
      payload[:model] = model if model

      make_request('POST', '/image', pk, sk, payload)
    end

    # ============================================================================
    # PaaS Logs Functions
    # ============================================================================

    # Fetch batch logs from the PaaS platform
    #
    # @param source [String] Log source - "all", "api", "portal", "pool/cammy", "pool/ai"
    # @param lines [Integer] Number of lines to fetch (1-10000)
    # @param since [String] Time window - "1m", "5m", "1h", "1d"
    # @param grep [String, nil] Optional filter pattern
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Log entries
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     logs = Un.logs_fetch(source: 'api', lines: 50, since: '5m')
    def logs_fetch(source: 'all', lines: 100, since: '5m', grep: nil,
                   public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      path = "/logs?source=#{source}&lines=#{lines}&since=#{since}"
      path += "&grep=#{CGI.escape(grep)}" if grep
      make_request('GET', path, pk, sk)
    end

    # Stream logs via Server-Sent Events
    #
    # Blocks until interrupted or server closes connection.
    #
    # @param source [String] Log source - "all", "api", "portal", "pool/cammy", "pool/ai"
    # @param grep [String, nil] Optional filter pattern
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @yield [source, line] Called for each log line received
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.logs_stream(source: 'api') do |src, line|
    #       puts "[#{src}] #{line}"
    #     end
    def logs_stream(source: 'all', grep: nil, public_key: nil, secret_key: nil, &block)
      pk, sk = resolve_credentials(public_key, secret_key)
      path = "/logs/stream?source=#{source}"
      path += "&grep=#{CGI.escape(grep)}" if grep

      uri = URI("#{API_BASE}#{path}")
      timestamp = Time.now.to_i
      signature = sign_request(sk, timestamp, 'GET', path)

      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = true
      http.read_timeout = nil

      request = Net::HTTP::Get.new(uri)
      request['Authorization'] = "Bearer #{pk}"
      request['X-Timestamp'] = timestamp.to_s
      request['X-Signature'] = signature
      request['Accept'] = 'text/event-stream'

      http.request(request) do |response|
        response.read_body do |chunk|
          chunk.split("\n").each do |line|
            next unless line.start_with?('data: ')

            data = line[6..]
            begin
              entry = JSON.parse(data)
              if block_given?
                yield entry['source'] || source, entry['line'] || data
              else
                puts "[#{entry['source'] || source}] #{entry['line'] || data}"
              end
            rescue JSON::ParserError
              if block_given?
                yield source, data
              else
                puts "[#{source}] #{data}"
              end
            end
          end
        end
      end
    end

    # ============================================================================
    # Utility Functions
    # ============================================================================

    SDK_VERSION = '4.2.0'

    # Thread-local storage for last error
    @last_error = nil

    class << self
      attr_accessor :last_error_value
      # CLI-set account index (-1 means not specified). When >= 0, takes
      # priority over env vars in resolve_credentials.
      attr_accessor :cli_account_index
    end
    @cli_account_index = -1

    # Get the SDK version string
    #
    # @return [String] Version string (e.g., "4.2.0")
    #
    # @example
    #     puts Un.version  # "4.2.0"
    def version
      SDK_VERSION
    end

    # Check if the API is healthy and responding
    #
    # @return [Boolean] true if API is healthy, false otherwise
    #
    # @example
    #     if Un.health_check
    #       puts "API is healthy"
    #     end
    def health_check
      uri = URI("#{API_BASE}/health")
      response = Net::HTTP.get_response(uri)
      response.code == '200'
    rescue StandardError => e
      Un.last_error_value = "Health check failed: #{e.message}"
      false
    end

    # Get the last error message
    #
    # @return [String, nil] Last error message or nil
    #
    # @example
    #     result = Un.health_check
    #     puts Un.last_error unless result
    def last_error
      Un.last_error_value
    end

    # Sign a message using HMAC-SHA256
    #
    # This is the underlying signing function used for request authentication.
    # Exposed for testing and debugging purposes.
    #
    # @param secret_key [String] The secret key for signing
    # @param message [String] The message to sign
    # @return [String] 64-character lowercase hex string
    #
    # @example
    #     signature = Un.hmac_sign("secret", "message")
    def hmac_sign(secret_key, message)
      OpenSSL::HMAC.hexdigest('SHA256', secret_key, message)
    end

    private

    # Language detection mapping (file extension -> language)
    LANGUAGE_MAP = {
      'py' => 'python',
      'js' => 'javascript',
      'ts' => 'typescript',
      'rb' => 'ruby',
      'php' => 'php',
      'pl' => 'perl',
      'sh' => 'bash',
      'r' => 'r',
      'lua' => 'lua',
      'go' => 'go',
      'rs' => 'rust',
      'c' => 'c',
      'cpp' => 'cpp',
      'cc' => 'cpp',
      'cxx' => 'cpp',
      'java' => 'java',
      'kt' => 'kotlin',
      'm' => 'objc',
      'cs' => 'csharp',
      'fs' => 'fsharp',
      'hs' => 'haskell',
      'ml' => 'ocaml',
      'clj' => 'clojure',
      'scm' => 'scheme',
      'ss' => 'scheme',
      'erl' => 'erlang',
      'ex' => 'elixir',
      'exs' => 'elixir',
      'jl' => 'julia',
      'd' => 'd',
      'nim' => 'nim',
      'zig' => 'zig',
      'v' => 'v',
      'cr' => 'crystal',
      'dart' => 'dart',
      'groovy' => 'groovy',
      'f90' => 'fortran',
      'f95' => 'fortran',
      'lisp' => 'commonlisp',
      'lsp' => 'commonlisp',
      'cob' => 'cobol',
      'tcl' => 'tcl',
      'raku' => 'raku',
      'pro' => 'prolog',
      'p' => 'prolog',
      '4th' => 'forth',
      'forth' => 'forth',
      'fth' => 'forth'
    }.freeze

    # Get ~/.unsandbox directory path, creating if necessary
    #
    # @return [String] Path to unsandbox config directory
    def unsandbox_dir
      dir = File.join(Dir.home, '.unsandbox')
      FileUtils.mkdir_p(dir, mode: 0o700) unless Dir.exist?(dir)
      dir
    end

    # Load credentials from CSV file (public_key,secret_key per line)
    #
    # @param csv_path [String] Path to CSV file
    # @param account_index [Integer] Account index (0-based)
    # @return [Array<String>, nil] [public_key, secret_key] or nil if not found
    def load_credentials_from_csv(csv_path, account_index = 0)
      return nil unless File.exist?(csv_path)

      current_index = 0
      File.foreach(csv_path) do |line|
        line = line.strip
        next if line.empty? || line.start_with?('#')

        if current_index == account_index
          parts = line.split(',')
          return [parts[0].strip, parts[1].strip] if parts.length >= 2
        end
        current_index += 1
      end

      nil
    rescue StandardError
      nil
    end

    # Resolve credentials from 4-tier priority system
    #
    # Priority:
    #   1. Method arguments
    #   2. account_index >= 0 (explicit --account N flag) → CSV row N
    #   3. Environment variables
    #   4. Default CSV lookup (account 0 or UNSANDBOX_ACCOUNT env)
    #
    # @param public_key [String, nil] Explicit public key
    # @param secret_key [String, nil] Explicit secret key
    # @param account_index [Integer, nil] Account index for CSV files (-1 means not specified)
    # @return [Array<String>] [public_key, secret_key]
    # @raise [CredentialsError] If no credentials found
    def resolve_credentials(public_key = nil, secret_key = nil, account_index = nil)
      # Tier 1: Method arguments
      return [public_key, secret_key] if public_key && secret_key

      # Resolve effective account_index: parameter takes precedence, then CLI-set value
      effective_index = account_index
      effective_index = Un.cli_account_index if effective_index.nil?

      # Tier 2: Explicit account_index (--account N) → load from CSV row N before env vars
      if effective_index && effective_index >= 0
        creds = load_credentials_from_csv(File.join(unsandbox_dir, 'accounts.csv'), effective_index)
        return creds if creds

        creds = load_credentials_from_csv('accounts.csv', effective_index)
        return creds if creds
      end

      # Tier 3: Environment variables
      env_pk = ENV['UNSANDBOX_PUBLIC_KEY']
      env_sk = ENV['UNSANDBOX_SECRET_KEY']
      return [env_pk, env_sk] if env_pk && env_sk

      # Tier 4: Default CSV lookup (UNSANDBOX_ACCOUNT env or row 0)
      default_index = ENV.fetch('UNSANDBOX_ACCOUNT', '0').to_i

      creds = load_credentials_from_csv(File.join(unsandbox_dir, 'accounts.csv'), default_index)
      return creds if creds

      creds = load_credentials_from_csv('accounts.csv', default_index)
      return creds if creds

      raise CredentialsError, <<~MSG
        No credentials found. Please provide via:
          1. Method arguments (public_key:, secret_key:)
          2. --account N flag (CSV row N)
          3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
          4. ~/.unsandbox/accounts.csv
          5. ./accounts.csv
      MSG
    end

    # Sign a request using HMAC-SHA256
    #
    # Message format: "timestamp:METHOD:path:body"
    #
    # @param secret_key [String] Secret key for signing
    # @param timestamp [Integer] Unix timestamp
    # @param method [String] HTTP method (GET, POST, DELETE)
    # @param path [String] API path
    # @param body [String, nil] Request body (JSON string)
    # @return [String] 64-character lowercase hex signature
    def sign_request(secret_key, timestamp, method, path, body = nil)
      body_str = body || ''
      message = "#{timestamp}:#{method}:#{path}:#{body_str}"
      OpenSSL::HMAC.hexdigest('SHA256', secret_key, message)
    end

    # Make an authenticated HTTP request to the API
    #
    # @param method [String] HTTP method (GET, POST, DELETE)
    # @param path [String] API path
    # @param public_key [String] API public key
    # @param secret_key [String] API secret key
    # @param data [Hash, nil] Request body data
    # @return [Hash] Parsed JSON response
    # @raise [APIError] If request fails
    def make_request(method, path, public_key, secret_key, data = nil)
      uri = URI.parse("#{API_BASE}#{path}")
      timestamp = Time.now.to_i
      body = data ? JSON.generate(data) : ''

      signature = sign_request(secret_key, timestamp, method, path, data ? body : nil)

      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = true
      http.open_timeout = REQUEST_TIMEOUT
      http.read_timeout = REQUEST_TIMEOUT

      headers = {
        'Authorization' => "Bearer #{public_key}",
        'X-Timestamp' => timestamp.to_s,
        'X-Signature' => signature,
        'Content-Type' => 'application/json'
      }

      response = case method
                 when 'GET'
                   http.get(uri.request_uri, headers)
                 when 'POST'
                   http.post(uri.request_uri, body, headers)
                 when 'PATCH'
                   http.patch(uri.request_uri, body, headers)
                 when 'PUT'
                   http.put(uri.request_uri, body, headers)
                 when 'DELETE'
                   req = Net::HTTP::Delete.new(uri.request_uri, headers)
                   req.body = body if data
                   http.request(req)
                 else
                   raise APIError, "Unsupported HTTP method: #{method}"
                 end

      unless response.is_a?(Net::HTTPSuccess)
        raise APIError.new(
          "API request failed: #{response.code} #{response.message}",
          status_code: response.code.to_i,
          response_body: response.body
        )
      end

      JSON.parse(response.body)
    rescue JSON::ParserError => e
      raise APIError, "Invalid JSON response: #{e.message}"
    rescue Net::OpenTimeout, Net::ReadTimeout => e
      raise APIError, "Request timeout: #{e.message}"
    rescue StandardError => e
      raise APIError, "Request failed: #{e.message}" unless e.is_a?(APIError)

      raise
    end

    # Make an authenticated HTTP request with sudo OTP challenge handling.
    #
    # If the server returns 428 (Precondition Required), prompts for OTP
    # and retries the request with X-Sudo-OTP and X-Sudo-Challenge headers.
    #
    # Used for destructive operations: service destroy/unlock, snapshot delete/unlock,
    # image delete/unlock.
    #
    # @param method [String] HTTP method (GET, POST, DELETE)
    # @param path [String] API path
    # @param public_key [String] API public key
    # @param secret_key [String] API secret key
    # @param data [Hash, nil] Request body data
    # @return [Hash] Parsed JSON response
    # @raise [APIError] If request fails
    def make_request_with_sudo(method, path, public_key, secret_key, data = nil)
      uri = URI.parse("#{API_BASE}#{path}")
      timestamp = Time.now.to_i
      body = data ? JSON.generate(data) : ''

      signature = sign_request(secret_key, timestamp, method, path, data ? body : nil)

      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = true
      http.open_timeout = REQUEST_TIMEOUT
      http.read_timeout = REQUEST_TIMEOUT

      headers = {
        'Authorization' => "Bearer #{public_key}",
        'X-Timestamp' => timestamp.to_s,
        'X-Signature' => signature,
        'Content-Type' => 'application/json'
      }

      response = case method
                 when 'GET'
                   http.get(uri.request_uri, headers)
                 when 'POST'
                   http.post(uri.request_uri, body, headers)
                 when 'PATCH'
                   http.patch(uri.request_uri, body, headers)
                 when 'PUT'
                   http.put(uri.request_uri, body, headers)
                 when 'DELETE'
                   req = Net::HTTP::Delete.new(uri.request_uri, headers)
                   req.body = body if data
                   http.request(req)
                 else
                   raise APIError, "Unsupported HTTP method: #{method}"
                 end

      # Handle 428 sudo OTP challenge
      if response.code.to_i == 428
        challenge_id = ''
        begin
          challenge_data = JSON.parse(response.body)
          challenge_id = challenge_data['challenge_id'] || ''
        rescue JSON::ParserError
          # Ignore JSON parse errors
        end

        $stderr.puts "\e[33mConfirmation required. Check your email for a one-time code.\e[0m"
        $stderr.print 'Enter OTP: '
        otp = $stdin.gets&.strip

        raise APIError, 'Operation cancelled' if otp.nil? || otp.empty?

        # Retry with sudo headers
        retry_timestamp = Time.now.to_i
        retry_signature = sign_request(secret_key, retry_timestamp, method, path, data ? body : nil)

        retry_headers = {
          'Authorization' => "Bearer #{public_key}",
          'X-Timestamp' => retry_timestamp.to_s,
          'X-Signature' => retry_signature,
          'Content-Type' => 'application/json',
          'X-Sudo-OTP' => otp,
          'X-Sudo-Challenge' => challenge_id
        }

        response = case method
                   when 'GET'
                     http.get(uri.request_uri, retry_headers)
                   when 'POST'
                     http.post(uri.request_uri, body, retry_headers)
                   when 'PATCH'
                     http.patch(uri.request_uri, body, retry_headers)
                   when 'PUT'
                     http.put(uri.request_uri, body, retry_headers)
                   when 'DELETE'
                     req = Net::HTTP::Delete.new(uri.request_uri, retry_headers)
                     req.body = body if data
                     http.request(req)
                   else
                     raise APIError, "Unsupported HTTP method: #{method}"
                   end
      end

      unless response.is_a?(Net::HTTPSuccess)
        raise APIError.new(
          "API request failed: #{response.code} #{response.message}",
          status_code: response.code.to_i,
          response_body: response.body
        )
      end

      JSON.parse(response.body)
    rescue JSON::ParserError => e
      raise APIError, "Invalid JSON response: #{e.message}"
    rescue Net::OpenTimeout, Net::ReadTimeout => e
      raise APIError, "Request timeout: #{e.message}"
    rescue StandardError => e
      raise APIError, "Request failed: #{e.message}" unless e.is_a?(APIError)

      raise
    end

    # Make an authenticated HTTP request with text/plain content type
    #
    # @param method [String] HTTP method (PUT)
    # @param path [String] API path
    # @param public_key [String] API public key
    # @param secret_key [String] API secret key
    # @param body [String] Plain text request body
    # @return [Hash] Parsed JSON response
    # @raise [APIError] If request fails
    def make_request_text(method, path, public_key, secret_key, body)
      uri = URI.parse("#{API_BASE}#{path}")
      timestamp = Time.now.to_i

      signature = sign_request(secret_key, timestamp, method, path, body)

      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = true
      http.open_timeout = REQUEST_TIMEOUT
      http.read_timeout = REQUEST_TIMEOUT

      headers = {
        'Authorization' => "Bearer #{public_key}",
        'X-Timestamp' => timestamp.to_s,
        'X-Signature' => signature,
        'Content-Type' => 'text/plain'
      }

      response = case method
                 when 'PUT'
                   http.put(uri.request_uri, body, headers)
                 else
                   raise APIError, "Unsupported HTTP method for text: #{method}"
                 end

      unless response.is_a?(Net::HTTPSuccess)
        raise APIError.new(
          "API request failed: #{response.code} #{response.message}",
          status_code: response.code.to_i,
          response_body: response.body
        )
      end

      JSON.parse(response.body)
    rescue JSON::ParserError => e
      raise APIError, "Invalid JSON response: #{e.message}"
    rescue Net::OpenTimeout, Net::ReadTimeout => e
      raise APIError, "Request timeout: #{e.message}"
    rescue StandardError => e
      raise APIError, "Request failed: #{e.message}" unless e.is_a?(APIError)

      raise
    end

    # Get path to languages cache file
    #
    # @return [String] Path to languages.json
    def languages_cache_path
      File.join(unsandbox_dir, 'languages.json')
    end

    # Load languages from cache if valid (< 1 hour old)
    #
    # @return [Array<String>, nil] Cached languages or nil if cache invalid/missing
    def load_languages_cache
      cache_path = languages_cache_path
      return nil unless File.exist?(cache_path)

      # Check if cache is fresh
      age_seconds = Time.now - File.mtime(cache_path)
      return nil if age_seconds >= LANGUAGES_CACHE_TTL

      data = JSON.parse(File.read(cache_path))
      data['languages']
    rescue StandardError
      nil
    end

    # Save languages to cache
    #
    # @param languages [Array<String>] Languages to cache
    # @return [void]
    def save_languages_cache(languages)
      cache_path = languages_cache_path
      File.write(cache_path, JSON.generate({
                                             languages: languages,
                                             timestamp: Time.now.to_i
                                           }))
    rescue StandardError
      # Cache failures are non-fatal
      nil
    end
  end

  # ============================================================================
  # CLI Implementation
  # ============================================================================

  # Exit codes
  EXIT_SUCCESS = 0
  EXIT_ERROR = 1
  EXIT_INVALID_ARGS = 2
  EXIT_AUTH_ERROR = 3
  EXIT_API_ERROR = 4
  EXIT_TIMEOUT = 5

  class << self
    # Main CLI entry point
    def cli_main
      # Global options
      options = {
        shell: nil,
        env: [],
        files: [],
        file_paths: [],
        public_key: nil,
        secret_key: nil,
        account_index: -1,
        network: 'zerotrust',
        vcpu: 1,
        yes: false,
        artifacts: false,
        output: nil
      }

      # Pre-scan for --account N before subcommand dispatch so it works in any position.
      # The flag will also be consumed by parse_global_options inside each sub-handler.
      ARGV.each_with_index do |arg, i|
        if arg == '--account' && ARGV[i + 1] =~ /\A-?\d+\z/
          options[:account_index] = ARGV[i + 1].to_i
          break
        elsif arg =~ /\A--account=(-?\d+)\z/
          options[:account_index] = Regexp.last_match(1).to_i
          break
        end
      end
      Un.cli_account_index = options[:account_index]

      # Check for subcommands first
      if ARGV.empty?
        cli_show_help
        exit(EXIT_INVALID_ARGS)
      end

      case ARGV[0]
      when 'session'
        ARGV.shift
        cli_session(options)
      when 'service'
        ARGV.shift
        cli_service(options)
      when 'snapshot'
        ARGV.shift
        cli_snapshot(options)
      when 'image'
        ARGV.shift
        cli_image(options)
      when 'key'
        ARGV.shift
        cli_key(options)
      when 'languages'
        ARGV.shift
        cli_languages(options)
      when '-h', '--help', 'help'
        cli_show_help
        exit(EXIT_SUCCESS)
      else
        cli_execute(options)
      end
    rescue CredentialsError => e
      $stderr.puts "Error: #{e.message}"
      exit(EXIT_AUTH_ERROR)
    rescue APIError => e
      $stderr.puts "Error: #{e.message}"
      exit(EXIT_API_ERROR)
    rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
      $stderr.puts "Error: #{e.message}"
      exit(EXIT_INVALID_ARGS)
    rescue Interrupt
      $stderr.puts "\nInterrupted"
      exit(EXIT_ERROR)
    end

    private

    # Show main help
    def cli_show_help
      puts <<~HELP
        unsandbox.com Ruby SDK - Secure Code Execution

        Usage:
          ruby un.rb [options] <source_file>      Execute code file
          ruby un.rb [options] -s LANG 'code'     Execute inline code
          ruby un.rb session [options]            Manage sessions
          ruby un.rb service [options]            Manage services
          ruby un.rb snapshot [options]           Manage snapshots
          ruby un.rb key                          Check API key
          ruby un.rb languages [--json]           List available languages

        Global Options:
          -s, --shell LANG       Language for inline code
          -e, --env KEY=VAL      Set environment variable (can repeat)
          -f, --file FILE        Add input file to /tmp/
          -F, --file-path FILE   Add input file with path preserved
          -a, --artifacts        Return compiled artifacts
          -o, --output DIR       Output directory for artifacts
          -p, --public-key KEY   API public key
          -k, --secret-key KEY   API secret key
              --account N        Use accounts.csv row N (overrides env vars)
          -n, --network MODE     Network mode: zerotrust or semitrusted
          -v, --vcpu N           vCPU count (1-8)
          -y, --yes              Skip confirmation prompts
          -h, --help             Show this help

        Examples:
          ruby un.rb script.py
          ruby un.rb -s python 'print("hello")'
          ruby un.rb -n semitrusted crawler.py
          ruby un.rb session --list
          ruby un.rb service --name web --ports 80 --bootstrap "python -m http.server 80"
      HELP
    end

    # Parse global options from ARGV
    def parse_global_options(options)
      OptionParser.new do |opts|
        opts.on('-s', '--shell LANG', 'Language for inline code') do |v|
          options[:shell] = v
        end
        opts.on('-e', '--env KEY=VAL', 'Set environment variable') do |v|
          options[:env] << v
        end
        opts.on('-f', '--file FILE', 'Add input file to /tmp/') do |v|
          options[:files] << v
        end
        opts.on('-F', '--file-path FILE', 'Add input file with path preserved') do |v|
          options[:file_paths] << v
        end
        opts.on('-a', '--artifacts', 'Return compiled artifacts') do
          options[:artifacts] = true
        end
        opts.on('-o', '--output DIR', 'Output directory for artifacts') do |v|
          options[:output] = v
        end
        opts.on('-p', '--public-key KEY', 'API public key') do |v|
          options[:public_key] = v
        end
        opts.on('-k', '--secret-key KEY', 'API secret key') do |v|
          options[:secret_key] = v
        end
        opts.on('--account N', Integer, 'Use credentials from accounts.csv row N') do |v|
          options[:account_index] = v
        end
        opts.on('-n', '--network MODE', 'Network mode') do |v|
          options[:network] = v
        end
        opts.on('-v', '--vcpu N', Integer, 'vCPU count (1-8)') do |v|
          options[:vcpu] = v
        end
        opts.on('-y', '--yes', 'Skip confirmation prompts') do
          options[:yes] = true
        end
        opts.on('-h', '--help', 'Show help') do
          yield if block_given?
          exit(EXIT_SUCCESS)
        end
      end
    end

    # Execute code command
    def cli_execute(options)
      parser = parse_global_options(options) do
        cli_show_help
      end
      parser.parse!(ARGV)

      if ARGV.empty?
        $stderr.puts 'Error: No source file or code provided'
        exit(EXIT_INVALID_ARGS)
      end

      code = nil
      language = nil

      if options[:shell]
        # Inline code mode: -s LANG 'code'
        language = options[:shell]
        code = ARGV.join(' ')
      else
        # File mode: script.py
        filename = ARGV[0]
        unless File.exist?(filename)
          $stderr.puts "Error: File not found: #{filename}"
          exit(EXIT_INVALID_ARGS)
        end
        code = File.read(filename)
        language = detect_language(filename)
        unless language
          $stderr.puts "Error: Cannot detect language for: #{filename}"
          $stderr.puts 'Use -s/--shell to specify language'
          exit(EXIT_INVALID_ARGS)
        end
      end

      # Execute the code
      result = execute_code(
        language,
        code,
        public_key: options[:public_key],
        secret_key: options[:secret_key]
      )

      # Output results
      cli_print_execute_result(result)
    end

    # Print execution result
    def cli_print_execute_result(result)
      puts result['stdout'] if result['stdout'] && !result['stdout'].empty?
      $stderr.puts result['stderr'] if result['stderr'] && !result['stderr'].empty?
      puts '---'
      puts "Exit code: #{result['exit_code'] || 0}"
      if result['execution_time_ms']
        puts "Execution time: #{result['execution_time_ms']}ms"
      end
    end

    # Session subcommand
    def cli_session(options)
      session_opts = {
        list: false,
        attach: nil,
        kill: nil,
        freeze: nil,
        unfreeze: nil,
        boost: nil,
        unboost: nil,
        snapshot: nil,
        snapshot_name: nil,
        hot: false,
        tmux: false,
        screen: false,
        shell: 'bash',
        audit: false
      }

      parser = parse_global_options(options) do
        cli_session_help
      end

      parser.on('-l', '--list', 'List active sessions') do
        session_opts[:list] = true
      end
      parser.on('--attach ID', 'Reconnect to existing session') do |v|
        session_opts[:attach] = v
      end
      parser.on('--kill ID', 'Terminate a session') do |v|
        session_opts[:kill] = v
      end
      parser.on('--freeze ID', 'Pause session') do |v|
        session_opts[:freeze] = v
      end
      parser.on('--unfreeze ID', 'Resume session') do |v|
        session_opts[:unfreeze] = v
      end
      parser.on('--boost ID', 'Add vCPUs/RAM') do |v|
        session_opts[:boost] = v
      end
      parser.on('--unboost ID', 'Remove boost') do |v|
        session_opts[:unboost] = v
      end
      parser.on('--snapshot ID', 'Create snapshot') do |v|
        session_opts[:snapshot] = v
      end
      parser.on('--snapshot-name NAME', 'Name for snapshot') do |v|
        session_opts[:snapshot_name] = v
      end
      parser.on('--hot', 'Live snapshot (no freeze)') do
        session_opts[:hot] = true
      end
      parser.on('--tmux', 'Enable persistence with tmux') do
        session_opts[:tmux] = true
      end
      parser.on('--screen', 'Enable persistence with screen') do
        session_opts[:screen] = true
      end
      parser.on('--shell SHELL', 'Shell/REPL to use') do |v|
        session_opts[:shell] = v
      end
      parser.on('--audit', 'Record session') do
        session_opts[:audit] = true
      end

      parser.parse!(ARGV)

      creds = { public_key: options[:public_key], secret_key: options[:secret_key] }

      if session_opts[:list]
        sessions = list_sessions(**creds)
        cli_print_sessions_table(sessions)
      elsif session_opts[:kill]
        result = delete_session(session_opts[:kill], **creds)
        puts "Session #{session_opts[:kill]} terminated"
      elsif session_opts[:freeze]
        result = freeze_session(session_opts[:freeze], **creds)
        puts "Session #{session_opts[:freeze]} frozen"
      elsif session_opts[:unfreeze]
        result = unfreeze_session(session_opts[:unfreeze], **creds)
        puts "Session #{session_opts[:unfreeze]} unfrozen"
      elsif session_opts[:boost]
        result = boost_session(session_opts[:boost], **creds)
        puts "Session #{session_opts[:boost]} boosted"
      elsif session_opts[:unboost]
        result = unboost_session(session_opts[:unboost], **creds)
        puts "Session #{session_opts[:unboost]} unboosted"
      elsif session_opts[:snapshot]
        snapshot_id = session_snapshot(
          session_opts[:snapshot],
          name: session_opts[:snapshot_name],
          ephemeral: session_opts[:hot],
          **creds
        )
        puts "Snapshot created: #{snapshot_id}"
      elsif session_opts[:attach]
        # Attach to existing session - show info
        session = get_session(session_opts[:attach], **creds)
        puts "Session: #{session['id']}"
        puts "Status: #{session['status']}"
        puts "WebSocket URL: #{session['websocket_url']}" if session['websocket_url']
        puts "\nNote: Use a WebSocket client to connect interactively"
      else
        # Create new session
        multiplexer = nil
        multiplexer = 'tmux' if session_opts[:tmux]
        multiplexer = 'screen' if session_opts[:screen]

        result = create_session(
          session_opts[:shell],
          network_mode: options[:network],
          vcpu: options[:vcpu],
          multiplexer: multiplexer,
          **creds
        )
        puts "Session created: #{result['session_id']}"
        puts "Container: #{result['container_name']}" if result['container_name']
        puts "WebSocket URL: #{result['websocket_url']}" if result['websocket_url']
        puts "\nNote: Use a WebSocket client to connect interactively"
      end
    end

    # Print sessions table
    def cli_print_sessions_table(sessions)
      if sessions.empty?
        puts 'No active sessions'
        return
      end

      # Header
      puts format('%-38s %-20s %-10s %-20s', 'ID', 'NAME', 'STATUS', 'CREATED')
      sessions.each do |s|
        puts format('%-38s %-20s %-10s %-20s',
                    s['id'] || s['session_id'] || '-',
                    s['name'] || '-',
                    s['status'] || s['state'] || '-',
                    s['created_at'] || '-')
      end
    end

    # Session help
    def cli_session_help
      puts <<~HELP
        Session Management

        Usage:
          ruby un.rb session [options]

        Options:
          --shell SHELL          Shell/REPL to use (default: bash)
          -l, --list             List active sessions
          --attach ID            Reconnect to existing session
          --kill ID              Terminate a session
          --freeze ID            Pause session
          --unfreeze ID          Resume session
          --boost ID             Add vCPUs/RAM
          --unboost ID           Remove boost
          --tmux                 Enable persistence with tmux
          --screen               Enable persistence with screen
          --snapshot ID          Create snapshot
          --snapshot-name NAME   Name for snapshot
          --hot                  Live snapshot (no freeze)
          --audit                Record session

        Examples:
          ruby un.rb session                          # New bash session
          ruby un.rb session --shell python3          # Python REPL
          ruby un.rb session --tmux                   # Persistent session
          ruby un.rb session --list                   # List sessions
          ruby un.rb session --kill abc123            # Kill session
      HELP
    end

    # Service subcommand
    def cli_service(options)
      service_opts = {
        list: false,
        name: nil,
        ports: nil,
        domains: nil,
        type: nil,
        bootstrap: nil,
        bootstrap_file: nil,
        env_file: nil,
        info: nil,
        logs: nil,
        tail: nil,
        freeze: nil,
        unfreeze: nil,
        destroy: nil,
        lock: nil,
        unlock: nil,
        resize: nil,
        redeploy: nil,
        execute: nil,
        execute_cmd: nil,
        snapshot: nil,
        snapshot_name: nil
      }

      parser = parse_global_options(options) do
        cli_service_help
      end

      parser.on('-l', '--list', 'List all services') do
        service_opts[:list] = true
      end
      parser.on('--name NAME', 'Service name (creates new)') do |v|
        service_opts[:name] = v
      end
      parser.on('--ports PORTS', 'Comma-separated ports') do |v|
        service_opts[:ports] = v.split(',').map(&:to_i)
      end
      parser.on('--domains DOMAINS', 'Custom domains') do |v|
        service_opts[:domains] = v.split(',')
      end
      parser.on('--type TYPE', 'Service type (minecraft, tcp, udp)') do |v|
        service_opts[:type] = v
      end
      parser.on('--bootstrap CMD', 'Bootstrap command') do |v|
        service_opts[:bootstrap] = v
      end
      parser.on('--bootstrap-file FILE', 'Bootstrap from file') do |v|
        service_opts[:bootstrap_file] = v
      end
      parser.on('--env-file FILE', 'Load env from .env file') do |v|
        service_opts[:env_file] = v
      end
      parser.on('--info ID', 'Get service details') do |v|
        service_opts[:info] = v
      end
      parser.on('--logs ID', 'Get all logs') do |v|
        service_opts[:logs] = v
      end
      parser.on('--tail ID', 'Get last 9000 lines') do |v|
        service_opts[:tail] = v
      end
      parser.on('--freeze ID', 'Pause service') do |v|
        service_opts[:freeze] = v
      end
      parser.on('--unfreeze ID', 'Resume service') do |v|
        service_opts[:unfreeze] = v
      end
      parser.on('--destroy ID', 'Delete service') do |v|
        service_opts[:destroy] = v
      end
      parser.on('--lock ID', 'Prevent deletion') do |v|
        service_opts[:lock] = v
      end
      parser.on('--unlock ID', 'Allow deletion') do |v|
        service_opts[:unlock] = v
      end
      parser.on('--resize ID', 'Resize (with --vcpu)') do |v|
        service_opts[:resize] = v
      end
      parser.on('--redeploy ID', 'Re-run bootstrap') do |v|
        service_opts[:redeploy] = v
      end
      parser.on('--execute ID', 'Run command in service') do |v|
        service_opts[:execute] = v
      end
      parser.on('--snapshot ID', 'Create snapshot') do |v|
        service_opts[:snapshot] = v
      end
      parser.on('--snapshot-name NAME', 'Name for snapshot') do |v|
        service_opts[:snapshot_name] = v
      end

      parser.parse!(ARGV)

      # Check for env subcommand
      if ARGV[0] == 'env'
        ARGV.shift
        cli_service_env(options, service_opts)
        return
      end

      # Get command argument for execute
      service_opts[:execute_cmd] = ARGV.join(' ') if service_opts[:execute] && !ARGV.empty?

      creds = { public_key: options[:public_key], secret_key: options[:secret_key] }

      if service_opts[:list]
        services = list_services(**creds)
        cli_print_services_table(services)
      elsif service_opts[:info]
        service = get_service(service_opts[:info], **creds)
        cli_print_service_info(service)
      elsif service_opts[:logs]
        result = get_service_logs(service_opts[:logs], all: true, **creds)
        puts result['log'] || result['logs'] || ''
      elsif service_opts[:tail]
        result = get_service_logs(service_opts[:tail], all: false, **creds)
        puts result['log'] || result['logs'] || ''
      elsif service_opts[:freeze]
        freeze_service(service_opts[:freeze], **creds)
        puts "Service #{service_opts[:freeze]} frozen"
      elsif service_opts[:unfreeze]
        unfreeze_service(service_opts[:unfreeze], **creds)
        puts "Service #{service_opts[:unfreeze]} unfrozen"
      elsif service_opts[:destroy]
        delete_service(service_opts[:destroy], **creds)
        puts "Service #{service_opts[:destroy]} destroyed"
      elsif service_opts[:lock]
        lock_service(service_opts[:lock], **creds)
        puts "Service #{service_opts[:lock]} locked"
      elsif service_opts[:unlock]
        unlock_service(service_opts[:unlock], **creds)
        puts "Service #{service_opts[:unlock]} unlocked"
      elsif service_opts[:resize]
        update_service(service_opts[:resize], vcpu: options[:vcpu], **creds)
        puts "Service #{service_opts[:resize]} resized to #{options[:vcpu]} vCPUs"
      elsif service_opts[:redeploy]
        bootstrap = nil
        if service_opts[:bootstrap_file]
          bootstrap = File.read(service_opts[:bootstrap_file])
        elsif service_opts[:bootstrap]
          bootstrap = service_opts[:bootstrap]
        end
        # Build input_files from -f args
        service_input_files = nil
        unless options[:files].empty?
          service_input_files = options[:files].map do |fpath|
            {
              filename: File.basename(fpath),
              content: Base64.strict_encode64(File.binread(fpath))
            }
          end
        end
        redeploy_service(service_opts[:redeploy], bootstrap: bootstrap, input_files: service_input_files, **creds)
        puts "Service #{service_opts[:redeploy]} redeployed"
      elsif service_opts[:execute]
        cmd = service_opts[:execute_cmd]
        if cmd.nil? || cmd.empty?
          $stderr.puts 'Error: No command provided for --execute'
          exit(EXIT_INVALID_ARGS)
        end
        result = execute_in_service(service_opts[:execute], cmd, **creds)
        cli_print_execute_result(result)
      elsif service_opts[:snapshot]
        snapshot_id = service_snapshot(
          service_opts[:snapshot],
          name: service_opts[:snapshot_name],
          **creds
        )
        puts "Snapshot created: #{snapshot_id}"
      elsif service_opts[:name]
        # Create new service
        bootstrap = service_opts[:bootstrap]
        if service_opts[:bootstrap_file]
          bootstrap = File.read(service_opts[:bootstrap_file])
        end

        unless bootstrap
          $stderr.puts 'Error: --bootstrap or --bootstrap-file required'
          exit(EXIT_INVALID_ARGS)
        end
        unless service_opts[:ports]
          $stderr.puts 'Error: --ports required'
          exit(EXIT_INVALID_ARGS)
        end

        # Build input_files from -f args
        service_input_files = nil
        unless options[:files].empty?
          service_input_files = options[:files].map do |fpath|
            {
              filename: File.basename(fpath),
              content: Base64.strict_encode64(File.binread(fpath))
            }
          end
        end

        result = create_service(
          service_opts[:name],
          service_opts[:ports],
          bootstrap,
          network_mode: options[:network],
          vcpu: options[:vcpu],
          custom_domains: service_opts[:domains],
          service_type: service_opts[:type],
          input_files: service_input_files,
          **creds
        )
        puts "Service created: #{result['service_id']}"
        puts "URL: #{result['url']}" if result['url']
      else
        cli_service_help
        exit(EXIT_INVALID_ARGS)
      end
    end

    # Print services table
    def cli_print_services_table(services)
      if services.empty?
        puts 'No services'
        return
      end

      puts format('%-38s %-20s %-10s %-20s', 'ID', 'NAME', 'STATUS', 'CREATED')
      services.each do |s|
        puts format('%-38s %-20s %-10s %-20s',
                    s['id'] || s['service_id'] || '-',
                    s['name'] || '-',
                    s['status'] || s['state'] || '-',
                    s['created_at'] || '-')
      end
    end

    # Print service info
    def cli_print_service_info(service)
      puts "ID: #{service['id'] || service['service_id']}"
      puts "Name: #{service['name']}"
      puts "Status: #{service['status'] || service['state']}"
      puts "URL: #{service['url']}" if service['url']
      puts "Ports: #{service['ports']&.join(', ')}" if service['ports']
      puts "vCPU: #{service['vcpu']}" if service['vcpu']
      puts "Network: #{service['network_mode']}" if service['network_mode']
      puts "Created: #{service['created_at']}" if service['created_at']
      puts "Locked: #{service['locked']}" if service.key?('locked')
    end

    # Service help
    def cli_service_help
      puts <<~HELP
        Service Management

        Usage:
          ruby un.rb service [options]
          ruby un.rb service env <command> <id>

        Options:
          --name NAME            Service name (creates new)
          --ports PORTS          Comma-separated ports
          --domains DOMAINS      Custom domains
          --type TYPE            Service type (minecraft, tcp, udp)
          --bootstrap CMD        Bootstrap command
          --bootstrap-file FILE  Bootstrap from file
          --env-file FILE        Load env from .env file
          -l, --list             List all services
          --info ID              Get service details
          --logs ID              Get all logs
          --tail ID              Get last 9000 lines
          --freeze ID            Pause service
          --unfreeze ID          Resume service
          --destroy ID           Delete service
          --lock ID              Prevent deletion
          --unlock ID            Allow deletion
          --resize ID            Resize (with --vcpu)
          --redeploy ID          Re-run bootstrap
          --execute ID 'cmd'     Run command in service
          --snapshot ID          Create snapshot

        Env Subcommands:
          ruby un.rb service env status ID    Show vault status
          ruby un.rb service env set ID       Set from --env-file or stdin
          ruby un.rb service env export ID    Export to stdout
          ruby un.rb service env delete ID    Delete vault

        Examples:
          ruby un.rb service --name web --ports 80 --bootstrap "python -m http.server 80"
          ruby un.rb service --list
          ruby un.rb service --logs abc123
          ruby un.rb service --execute abc123 'ls -la'
          ruby un.rb service env status abc123
      HELP
    end

    # Service env subcommand
    def cli_service_env(options, service_opts)
      if ARGV.empty?
        $stderr.puts 'Error: env subcommand requires: status, set, export, or delete'
        exit(EXIT_INVALID_ARGS)
      end

      cmd = ARGV.shift
      service_id = ARGV.shift

      unless service_id
        $stderr.puts 'Error: Service ID required'
        exit(EXIT_INVALID_ARGS)
      end

      creds = { public_key: options[:public_key], secret_key: options[:secret_key] }

      case cmd
      when 'status'
        result = get_service_env(service_id, **creds)
        puts "Has vault: #{result['has_vault'] || false}"
        puts "Variables: #{result['count'] || 0}"
        puts "Updated: #{result['updated_at']}" if result['updated_at']
      when 'set'
        env_content = nil
        if service_opts[:env_file]
          env_content = File.read(service_opts[:env_file])
        elsif !$stdin.tty?
          env_content = $stdin.read
        else
          $stderr.puts 'Error: Provide --env-file or pipe content to stdin'
          exit(EXIT_INVALID_ARGS)
        end
        set_service_env(service_id, env_content, **creds)
        puts "Environment vault updated for #{service_id}"
      when 'export'
        result = export_service_env(service_id, **creds)
        puts result['env'] || ''
      when 'delete'
        delete_service_env(service_id, **creds)
        puts "Environment vault deleted for #{service_id}"
      else
        $stderr.puts "Error: Unknown env command: #{cmd}"
        exit(EXIT_INVALID_ARGS)
      end
    end

    # Snapshot subcommand
    def cli_snapshot(options)
      snapshot_opts = {
        list: false,
        info: nil,
        delete: nil,
        lock: nil,
        unlock: nil,
        clone: nil,
        clone_type: 'session',
        clone_name: nil,
        clone_shell: nil,
        clone_ports: nil
      }

      parser = parse_global_options(options) do
        cli_snapshot_help
      end

      parser.on('-l', '--list', 'List all snapshots') do
        snapshot_opts[:list] = true
      end
      parser.on('--info ID', 'Get snapshot details') do |v|
        snapshot_opts[:info] = v
      end
      parser.on('--delete ID', 'Delete snapshot') do |v|
        snapshot_opts[:delete] = v
      end
      parser.on('--lock ID', 'Prevent deletion') do |v|
        snapshot_opts[:lock] = v
      end
      parser.on('--unlock ID', 'Allow deletion') do |v|
        snapshot_opts[:unlock] = v
      end
      parser.on('--clone ID', 'Clone snapshot') do |v|
        snapshot_opts[:clone] = v
      end
      parser.on('--type TYPE', 'Clone type: session or service') do |v|
        snapshot_opts[:clone_type] = v
      end
      parser.on('--name NAME', 'Name for cloned resource') do |v|
        snapshot_opts[:clone_name] = v
      end
      parser.on('--shell SHELL', 'Shell for cloned session') do |v|
        snapshot_opts[:clone_shell] = v
      end
      parser.on('--ports PORTS', 'Ports for cloned service') do |v|
        snapshot_opts[:clone_ports] = v.split(',').map(&:to_i)
      end

      parser.parse!(ARGV)

      creds = { public_key: options[:public_key], secret_key: options[:secret_key] }

      if snapshot_opts[:list]
        snapshots = list_snapshots(**creds)
        cli_print_snapshots_table(snapshots)
      elsif snapshot_opts[:info]
        # Get snapshot details via restore endpoint or list
        snapshots = list_snapshots(**creds)
        snapshot = snapshots.find { |s| s['snapshot_id'] == snapshot_opts[:info] || s['id'] == snapshot_opts[:info] }
        if snapshot
          cli_print_snapshot_info(snapshot)
        else
          $stderr.puts "Error: Snapshot not found: #{snapshot_opts[:info]}"
          exit(EXIT_ERROR)
        end
      elsif snapshot_opts[:delete]
        delete_snapshot(snapshot_opts[:delete], **creds)
        puts "Snapshot #{snapshot_opts[:delete]} deleted"
      elsif snapshot_opts[:lock]
        lock_snapshot(snapshot_opts[:lock], **creds)
        puts "Snapshot #{snapshot_opts[:lock]} locked"
      elsif snapshot_opts[:unlock]
        unlock_snapshot(snapshot_opts[:unlock], **creds)
        puts "Snapshot #{snapshot_opts[:unlock]} unlocked"
      elsif snapshot_opts[:clone]
        result = clone_snapshot(
          snapshot_opts[:clone],
          type: snapshot_opts[:clone_type],
          name: snapshot_opts[:clone_name],
          shell: snapshot_opts[:clone_shell],
          ports: snapshot_opts[:clone_ports],
          **creds
        )
        if result['session_id']
          puts "Session created: #{result['session_id']}"
        elsif result['service_id']
          puts "Service created: #{result['service_id']}"
        else
          puts 'Clone completed'
          puts JSON.pretty_generate(result)
        end
      else
        cli_snapshot_help
        exit(EXIT_INVALID_ARGS)
      end
    end

    # Print snapshots table
    def cli_print_snapshots_table(snapshots)
      if snapshots.empty?
        puts 'No snapshots'
        return
      end

      puts format('%-38s %-20s %-10s %-20s', 'ID', 'NAME', 'TYPE', 'CREATED')
      snapshots.each do |s|
        puts format('%-38s %-20s %-10s %-20s',
                    s['snapshot_id'] || s['id'] || '-',
                    s['name'] || '-',
                    s['type'] || s['source_type'] || '-',
                    s['created_at'] || '-')
      end
    end

    # Print snapshot info
    def cli_print_snapshot_info(snapshot)
      puts "ID: #{snapshot['snapshot_id'] || snapshot['id']}"
      puts "Name: #{snapshot['name']}" if snapshot['name']
      puts "Type: #{snapshot['type'] || snapshot['source_type']}"
      puts "Source ID: #{snapshot['source_id']}" if snapshot['source_id']
      puts "Size: #{snapshot['size']}" if snapshot['size']
      puts "Locked: #{snapshot['locked']}" if snapshot.key?('locked')
      puts "Created: #{snapshot['created_at']}" if snapshot['created_at']
    end

    # Snapshot help
    def cli_snapshot_help
      puts <<~HELP
        Snapshot Management

        Usage:
          ruby un.rb snapshot [options]

        Options:
          -l, --list             List all snapshots
          --info ID              Get snapshot details
          --delete ID            Delete snapshot
          --lock ID              Prevent deletion
          --unlock ID            Allow deletion
          --clone ID             Clone snapshot
          --type TYPE            Clone type: session or service
          --name NAME            Name for cloned resource
          --shell SHELL          Shell for cloned session
          --ports PORTS          Ports for cloned service

        Examples:
          ruby un.rb snapshot --list
          ruby un.rb snapshot --clone abc123 --type service --name myapp --ports 80
      HELP
    end

    # Image subcommand
    def cli_image(options)
      image_opts = {
        list: false,
        info: nil,
        delete: nil,
        lock: nil,
        unlock: nil,
        publish: nil,
        source_type: nil,
        visibility: nil,
        visibility_mode: nil,
        spawn: nil,
        clone: nil,
        name: nil,
        ports: nil
      }

      parser = parse_global_options(options) do
        cli_image_help
      end

      parser.on('-l', '--list', 'List all images') do
        image_opts[:list] = true
      end
      parser.on('--info ID', 'Get image details') do |v|
        image_opts[:info] = v
      end
      parser.on('--delete ID', 'Delete image') do |v|
        image_opts[:delete] = v
      end
      parser.on('--lock ID', 'Prevent deletion') do |v|
        image_opts[:lock] = v
      end
      parser.on('--unlock ID', 'Allow deletion') do |v|
        image_opts[:unlock] = v
      end
      parser.on('--publish ID', 'Publish image from service/snapshot') do |v|
        image_opts[:publish] = v
      end
      parser.on('--source-type TYPE', 'Source type: service or snapshot') do |v|
        image_opts[:source_type] = v
      end
      parser.on('--visibility ID MODE', 'Set visibility (private, unlisted, public)') do |v|
        image_opts[:visibility] = v
      end
      parser.on('--spawn ID', 'Spawn new service from image') do |v|
        image_opts[:spawn] = v
      end
      parser.on('--clone ID', 'Clone an image') do |v|
        image_opts[:clone] = v
      end
      parser.on('--name NAME', 'Name for spawned service or cloned image') do |v|
        image_opts[:name] = v
      end
      parser.on('--ports PORTS', 'Ports for spawned service') do |v|
        image_opts[:ports] = v.split(',').map(&:to_i)
      end

      parser.parse!(ARGV)

      # Get visibility mode from remaining args if --visibility was used
      if image_opts[:visibility] && ARGV.length > 0 && !ARGV[0].start_with?('-')
        image_opts[:visibility_mode] = ARGV.shift
      end

      creds = { public_key: options[:public_key], secret_key: options[:secret_key] }

      if image_opts[:list]
        images = list_images(**creds)
        cli_print_images_table(images)
      elsif image_opts[:info]
        image = get_image(image_opts[:info], **creds)
        cli_print_image_info(image)
      elsif image_opts[:delete]
        delete_image(image_opts[:delete], **creds)
        puts "Image #{image_opts[:delete]} deleted"
      elsif image_opts[:lock]
        lock_image(image_opts[:lock], **creds)
        puts "Image #{image_opts[:lock]} locked"
      elsif image_opts[:unlock]
        unlock_image(image_opts[:unlock], **creds)
        puts "Image #{image_opts[:unlock]} unlocked"
      elsif image_opts[:publish]
        unless image_opts[:source_type]
          $stderr.puts 'Error: --source-type required for --publish'
          exit(EXIT_INVALID_ARGS)
        end
        result = image_publish(
          image_opts[:source_type],
          image_opts[:publish],
          name: image_opts[:name],
          **creds
        )
        image_id = result['image_id'] || result['id']
        puts "Image published: #{image_id}"
      elsif image_opts[:visibility] && image_opts[:visibility_mode]
        unless %w[private unlisted public].include?(image_opts[:visibility_mode])
          $stderr.puts 'Error: visibility must be private, unlisted, or public'
          exit(EXIT_INVALID_ARGS)
        end
        set_image_visibility(image_opts[:visibility], image_opts[:visibility_mode], **creds)
        puts "Image #{image_opts[:visibility]} visibility set to #{image_opts[:visibility_mode]}"
      elsif image_opts[:spawn]
        unless image_opts[:name]
          $stderr.puts 'Error: --name required for --spawn'
          exit(EXIT_INVALID_ARGS)
        end
        result = spawn_from_image(
          image_opts[:spawn],
          name: image_opts[:name],
          ports: image_opts[:ports],
          **creds
        )
        service_id = result['service_id'] || result['id']
        puts "Service spawned: #{service_id}"
      elsif image_opts[:clone]
        result = clone_image(
          image_opts[:clone],
          name: image_opts[:name],
          **creds
        )
        image_id = result['image_id'] || result['id']
        puts "Image cloned: #{image_id}"
      else
        cli_image_help
        exit(EXIT_INVALID_ARGS)
      end
    end

    # Print images table
    def cli_print_images_table(images)
      if images.empty?
        puts 'No images'
        return
      end

      puts format('%-38s %-20s %-10s %-10s %-20s', 'ID', 'NAME', 'VISIBILITY', 'SOURCE', 'CREATED')
      images.each do |img|
        puts format('%-38s %-20s %-10s %-10s %-20s',
                    img['image_id'] || img['id'] || '-',
                    (img['name'] || '-')[0..19],
                    img['visibility'] || 'private',
                    (img['source_type'] || '-')[0..9],
                    img['created_at'] || '-')
      end
    end

    # Print image info
    def cli_print_image_info(image)
      puts "ID: #{image['image_id'] || image['id']}"
      puts "Name: #{image['name']}" if image['name']
      puts "Visibility: #{image['visibility']}" if image['visibility']
      puts "Source Type: #{image['source_type']}" if image['source_type']
      puts "Source ID: #{image['source_id']}" if image['source_id']
      puts "Size: #{image['size']}" if image['size']
      puts "Locked: #{image['locked']}" if image.key?('locked')
      puts "Created: #{image['created_at']}" if image['created_at']
    end

    # Image help
    def cli_image_help
      puts <<~HELP
        Image Management

        Usage:
          ruby un.rb image [options]

        Options:
          -l, --list             List all images
          --info ID              Get image details
          --delete ID            Delete image
          --lock ID              Prevent deletion
          --unlock ID            Allow deletion
          --publish ID           Publish image (requires --source-type)
          --source-type TYPE     Source type: service or snapshot
          --visibility ID MODE   Set visibility (private, unlisted, public)
          --spawn ID             Spawn new service from image
          --clone ID             Clone an image
          --name NAME            Name for spawned service or cloned image
          --ports PORTS          Ports for spawned service

        Examples:
          ruby un.rb image --list
          ruby un.rb image --publish svc123 --source-type service --name myimage
          ruby un.rb image --spawn img123 --name myservice --ports 80,443
          ruby un.rb image --visibility img123 public
      HELP
    end

    # Key command
    def cli_key(options)
      parser = parse_global_options(options) do
        puts 'Usage: ruby un.rb key [-p PUBLIC_KEY] [-k SECRET_KEY]'
        puts
        puts 'Check API key validity and show account info'
      end
      parser.parse!(ARGV)

      creds = { public_key: options[:public_key], secret_key: options[:secret_key] }

      begin
        result = validate_keys(**creds)
        puts "Valid: #{result['valid']}"
        puts "Account: #{result['account'] || result['account_id']}" if result['account'] || result['account_id']
        puts "Email: #{result['email']}" if result['email']
        puts "Plan: #{result['plan']}" if result['plan']
        puts "Credits: #{result['credits']}" if result['credits']
      rescue APIError => e
        if e.status_code == 401 || e.status_code == 403
          puts 'Valid: false'
          puts "Error: #{e.message}"
          exit(EXIT_AUTH_ERROR)
        end
        raise
      end
    end

    # Languages command - list available languages
    def cli_languages(options)
      json_output = false
      parser = parse_global_options(options) do
        puts 'Usage: ruby un.rb languages [--json]'
        puts
        puts 'List available programming languages'
      end
      parser.on('--json', 'Output as JSON array') do
        json_output = true
      end
      parser.parse!(ARGV)

      creds = { public_key: options[:public_key], secret_key: options[:secret_key] }
      languages = get_languages(**creds)

      if json_output
        # Output as JSON array
        require 'json'
        puts JSON.generate(languages)
      else
        # Output one language per line (pipe-friendly)
        languages.each { |lang| puts lang }
      end
    end
  end
end

# CLI entry point
if __FILE__ == $0
  Un.cli_main
end
