# -------------------------------------------------------------------------- #
# Copyright 2002-2024, OpenNebula Project, OpenNebula Systems                #
#                                                                            #
# Licensed under the Apache License, Version 2.0 (the "License"); you may    #
# not use this file except in compliance with the License. You may obtain    #
# a copy of the License at                                                   #
#                                                                            #
# http://www.apache.org/licenses/LICENSE-2.0                                 #
#                                                                            #
# Unless required by applicable law or agreed to in writing, software        #
# distributed under the License is distributed on an "AS IS" BASIS,          #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.   #
# See the License for the specific language governing permissions and        #
# limitations under the License.                                             #
#--------------------------------------------------------------------------- #

require 'one_helper'
require 'opennebula'
require 'logger'
require 'fileutils'
require 'shellwords'
require 'socket'
require 'tempfile'
require 'tmpdir'
require 'time'
require 'uri'
require 'webrick'
require 'yaml'
require_relative 'vsphere_client'
require_relative 'esxi_vm'
require_relative 'windows_tuner'
require_relative 'oneswap_logger'

class String

    def black
        "\e[30m#{self}\e[0m"
    end

    def red
        "\e[31m#{self}\e[0m"
    end

    def green
        "\e[32m#{self}\e[0m"
    end

    def brown
        "\e[33m#{self}\e[0m"
    end

    def blue
        "\e[34m#{self}\e[0m"
    end

    def magenta
        "\e[35m#{self}\e[0m"
    end

    def cyan
        "\e[36m#{self}\e[0m"
    end

    def gray
        "\e[37m#{self}\e[0m"
    end

    def bg_black
        "\e[40m#{self}\e[0m"
    end

    def bg_red
        "\e[41m#{self}\e[0m"
    end

    def bg_green
        "\e[42m#{self}\e[0m"
    end

    def bg_brown
        "\e[43m#{self}\e[0m"
    end

    def bg_blue
        "\e[44m#{self}\e[0m"
    end

    def bg_magenta
        "\e[45m#{self}\e[0m"
    end

    def bg_cyan
        "\e[46m#{self}\e[0m"
    end

    def bg_gray
        "\e[47m#{self}\e[0m"
    end

    def bold
        "\e[1m#{self}\e[22m"
    end

    def italic
        "\e[3m#{self}\e[23m"
    end

    def underline
        "\e[4m#{self}\e[24m"
    end

    def blink
        "\e[5m#{self}\e[25m"
    end

    def reverse_color
        "\e[7m#{self}\e[27m"
    end

end

# Ruby 3.x+ deprecated URI.escape, however rbvmomi still relies on it

if RUBY_VERSION.split('.')[0].to_i >= 3
    # Monkey patch the escape functionality
    module URI

        def self.escape(url)
            URI::Parser.new.escape url
        end

    end
end

##############################################################################
# Module OneVcenterHelper
##############################################################################
class ConversionError < StandardError; end

class OneSwapHelper < OpenNebulaHelper::OneHelper
    VSAN_VDDK_REQUIRED_MESSAGE = 'vSAN-backed VMDK conversion requires VDDK. ' \
                                 'Please configure :vddk_path in the OneSwap config file ' \
                                 'or pass --vddk /path/to/vmware-vix-disklib-distrib/.'
    VSAN_DELTA_UNSUPPORTED_MESSAGE = 'Delta mode is not currently supported for vSAN-backed disks. ' \
                                     'Please use full conversion with VDDK.'
    VSAN_HYBRID_UNSUPPORTED_MESSAGE = 'Hybrid mode is not currently supported for vSAN-backed disks ' \
                                      'because it uses vCenter datastore VMDK downloads. ' \
                                      'Please use full conversion with VDDK.'

    include WindowsTuner

    ONE_LOGS = '/var/log/one'
    LOG = "#{ONE_LOGS}/oneswap.log"

    def initialize
        super

        Dir.mkdir(ONE_LOGS) unless Dir.exist?(ONE_LOGS)

        @log_file = File.open(LOG, 'a')
        @log_file.sync = true

        @verbose = OneSwapLogger.verbose?({})
        @logger  = OneSwapLogger.build(@log_file, :verbose => @verbose)
    end

    # Raise log verbosity to DEBUG (and echo diagnostics to the terminal, not
    # just the oneswap log file) when verbose mode is requested through any
    # channel: the -v/--verbose CLI flag, a ":verbose: true" entry in
    # oneswap.yaml, or the ONE_SWAP_DEBUG env var. 
    #
    # @param options [Hash] CLI options, already merged with the config file
    def apply_verbosity(options)
        return if @verbose
        return unless OneSwapLogger.verbose?(options)

        @verbose = true
        @logger  = OneSwapLogger.build(@log_file, :verbose => true)
    end

    @props, @options = []
    @dotskip = false # temporarily skip dots, for progress dots.

    # vCenter importer will divide rbvmomi resources
    # in this group, makes parsing easier.
    module VOBJECT

        VM         = 1
        DATACENTER = 2
        CLUSTER    = 3

    end

    #
    # onevcenter helper main constant
    # This will control everything displayed on STDOUT
    # Resources (above) uses this table
    #
    # struct:   [Array] LIST FORMAT for opennebula cli
    #           related methods: * cli_format
    #
    # columns:  [Hash(column => Integer)] Will be used in the list command,
    #           Integer represent nbytes
    #           related methods: * format_list
    #
    # cli:      [Array] with mandatory args, for example image
    #           listing needs a datastore
    #           related methods: * parse_opts
    #
    # dialogue: [Lambda] Used only for Vobject that require a previous
    #                    dialogue with the user, will be triggered
    #                    on importation process
    #           related methods: * network_dialogue
    #                            * template_dialogue
    #
    TABLE = {
        VOBJECT::VM => {
            :struct  => ['VM_LIST', 'VM'],
            :columns => { :IMID => 8, :NAME => 20, :STATE => 10, :HOST => 20, :CPU => 3, :MEM => 7,
:DISKS => 40, :REF => 35 },
            :cli     => [],
            :dialogue => ->(arg) {}
        },
        VOBJECT::DATACENTER => {
            :struct  => ['DATACENTER_LIST', 'DATACENTER'],
            :columns => { :DATACENTER => 30 },
            :cli     => [],
            :dialogue => ->(arg) {}
        },
        VOBJECT::CLUSTER => {
            :struct  => ['CLUSTER_LIST', 'CLUSTER'],
            :columns => { :NAME => 30, :VMCOUNT => 35 },
            :cli     => [],
            :dialgoue => ->(arg) {}
        }
    }

    ########################
    # In list command you can use this method to print a header
    #
    # @param vcenter_host [String] this text will be displayed
    #
    def show_header(vcenter_host)
        CLIHelper.scr_bold
        CLIHelper.scr_underline
        puts "# vCenter: #{vcenter_host}".ljust(50)
        CLIHelper.scr_restore
        puts
    end

    # Using for parse a String into a VOBJECT
    # We will use VOBJECT instances for handle any operatiion
    #
    # @param type [String] String representing the vCenter resource
    #
    def object_update(type)
        if type.nil?
            type = 'vms'
        else
            type = type.downcase
        end

        case type
        when 'networks'
            @vobject = VOBJECT::NETWORK
        when 'datacenters'
            @vobject = VOBJECT::DATACENTER
        when 'clusters'
            @vobject = VOBJECT::CLUSTER
        when 'vms'
            @vobject = VOBJECT::VM
        else
            raise 'Invalid object type, must be any of: '\
                  '[ networks, datacenters, clusters, vms ]'
        end
    end

    # Handles connection to vCenter.
    #
    # @param options [Hash] options for the connection
    #
    def connection_options(object_name, options)
        if  options[:host].nil? && options[:vcenter].nil? && options[:vuser].nil?
            raise 'vCenter connection parameters are mandatory'\
                  " #{object_name}:\n"\
                  "\t --vcenter vCenter hostname\n"\
                  "\t --vuser username to login in vcenter\n"\
                  "got: #{options}"
        end

        password = options[:vpass] || OpenNebulaHelper::OneHelper.get_password
        {
            :user     => options[:vuser],
            :password => password,
            :host     => options[:vcenter],
            :port     => options[:port],
            :insecure => options[:accept_cert] || false
        }
    end

    def cli_format(hash)
        {
            TABLE[@vobject][:struct].first =>
                {
                    TABLE[@vobject][:struct].last =>
                        hash.values
                }
        }
    end

    # handles :cli section of TABLE
    # used for executing the dialogue in some VOBJECTS
    #
    # @param object_info [Hash] This is the object
    #                           with all the info related to the object
    #                           that will be imported
    #
    def cli_dialogue(object_info)
        TABLE[@vobject][:dialogue].call(object_info)
    end

    # This method iterates over the possible options for certain resources
    # and will raise an error in case of missing mandatory param
    #
    # @param opts [Hash] options object passed to the onecenter tool
    #
    def parse_opts(opts)
        object_update(opts[:object])

        res = {}
        TABLE[@vobject][:cli].each do |arg|
            raise "#{arg} it's mandadory for this op" if opts[arg].nil?

            res[arg] = method(arg).call(opts[arg])
        end

        res[:config] = parse_file(opts[:configuration]) if opts[:configuration]

        res
    end

    # This method will parse a yaml
    # Only used for a feature that adds the posibility
    # of import resources with custom params (bulk)
    #
    # @param path [String] Path of the file
    #
    def parse_file(path)
        begin
            _config = YAML.safe_load(File.read(path))
        rescue StandardError => _e
            str_error = "Unable to read '#{path}'. Invalid YAML syntax:\n"

            raise str_error
        end
    end

    # Use the attributes provided by TABLE
    # with the purpose of build a complete CLI list
    # OpenNebula way
    #
    def format_list
        config = TABLE[@vobject][:columns]
        CLIHelper::ShowTable.new do
            column :DATACENTER, :left, :expand,
                   'DATACENTER', :size => config[:DATACENTER] || 15 do |d|
                d[:datacenter]
            end

            column :IMID, 'OBJECT ID', :size=>config[:IMID] || 4 do |d|
                d[:imid]
            end

            column :REF, 'REF', :left, :adjust, :size=>config[:REF] || 15 do |d|
                d[:ref] || d[:cluster_ref]
            end

            column :NAME, 'NAME', :left, :expand,
                   :size=>config[:NAME] || 20 do |d|
                d[:name] || d[:simple_name]
            end

            column :CLUSTERS, 'CLUSTERS', :left, :expand,
                   :size=>config[:CLUSTERS] || 10 do |d|
                d = d[:clusters] if d[:clusters]
                d[:one_ids] || d[:cluster].to_s
            end

            column :PATH, 'PATH', :left, :expand,
                   :size=>config[:PATH] || 10 do |d|
                d[:path]
            end

            column :VMCOUNT, '# VMS', :left, :expand,
                   :size=>config[:PATH] || 7 do |d|
                d[:vm_count]
            end

            column :STATE, 'STATE', :left, :expand,
                   :size=>config[:STATE] || 10 do |d|
                d[:state]
            end

            column :HOST, 'HOST', :left, :expand,
                   :size=>config[:HOST] || 15 do |d|
                d[:host]
            end

            column :CPU, 'CPU', :left, :expand,
                   :size=>config[:CPU] || 5 do |d|
                d[:cpu]
            end

            column :MEM, 'MEM', :left, :expand,
                   :size=>config[:MEM] || 7 do |d|
                d[:mem]
            end

            column :DISKS, 'DISKS', :left, :expand,
                   :size=>config[:DISKS] || 20 do |d|
                d[:disks]
            end

            default(*config.keys)
        end
    end

    def check_one_connectivity
        user = OpenNebula::User.new_with_id(OpenNebula::User::SELF, @client)
        rc = user.info
        return unless rc.class == OpenNebula::Error

        STDERR.puts rc.message

        user_info = 'Setup the OpenNebula client configuration accordingly'
        user_info << ' https://docs.opennebula.io/7.0/product/operation_references/configuration_references/cli/#shell-environment'

        raise user_info
    end

    def cleanup_passwords
        puts 'Deleting password files.' unless @options[:dry_run]
        File.delete("#{@options[:work_dir]}/vpassfile")
        File.delete("#{@options[:work_dir]}/esxpassfile")
    end

    def cleanup_disks
        return if @options[:dry_run]

        if @options[:delete]
            puts "Deleting vdisks in #{"#{@options[:work_dir]}/conversions"}"
            FileUtils.rm_rf("#{@options[:work_dir]}/conversions")
            if Dir.exist?("#{@options[:work_dir]}/transfers")
                FileUtils.rm_rf("#{@options[:work_dir]}/transfers")
            end
        else
            puts "Delete not enabled, leaving disks in #{"#{@options[:work_dir]}/conversions"}"
        end
    end

    def cleanup_dirs
        return if @options[:dry_run]

        if @options[:delete]
            puts "Deleting everything in and including #{"#{@options[:work_dir]}"}"
            FileUtils.rm_rf("#{@options[:work_dir]}")
        else
            puts "Delete not enabled, leaving #{@options[:work_dir]} alone."
        end
    end

    def cleanup_all
        cleanup_passwords
        cleanup_disks
        cleanup_dirs
    end

    def mem_to_mb(value, unit)
        units = {
            'b' => 1.0 / (1024 * 1024), # bytes to MB
          'bytes' => 1.0 / (1024 * 1024),  # bytes to MB
          'kb' => 1.0 / 1024,              # KB to MB
          'k' => 1.0 / 1024,               # kB (kibibytes) to MB
          'kib' => 1.0 / 1024,             # KiB to MB
          'mb' => 1.0,                     # MB to MB
          'm' => 1.0,                      # MiB to MB
          'mib' => 1.0,                    # MiB to MB
          'gb' => 1024.0,                  # GB to MB
          'g' => 1024.0,                   # GiB to MB
          'gib' => 1024.0,                 # GiB to MB
          'tb' => 1024.0 * 1024,           # TB to MB
          't' => 1024.0 * 1024,            # TiB to MB
          'tib' => 1024.0 * 1024           # TiB to MB
        }

        unit = unit.strip.downcase

        # Check if unit is within the hash keys
        if units.key?(unit)
            value_mb = value.to_f * units[unit]
            return value_mb.to_i
        else
            raise ArgumentError,
                  "unit not valid: '#{unit}'. Valid units: 'b', 'bytes', 'KB', 'k', 'KiB', 'MB', 'M', 'MiB', 'GB', 'G', 'GiB', 'TB', 'T', 'TiB'."
        end
    end

    # This method creates a VM full clone in vCenter
    #
    # @param vi_client [RbVmomi::VIM] The vCenter client
    # @param properties [Array] The properties to retrieve from the VM
    # @param vm [RbVmomi::VIM::VirtualMachine] The VM to clone
    # @param clone_name [String] The name for the cloned VM
    #
    def clone_vm(vi_client, properties, vm, clone_name = nil)
        attr = vm.to_hash
        vm = vm.obj

        target_name = clone_name || "#{attr['name']}-clone"

        puts "\nCloning #{attr['name']} into #{target_name}\n"

        relocate_spec = RbVmomi::VIM.VirtualMachineRelocateSpec

        clone_spec = RbVmomi::VIM.VirtualMachineCloneSpec(
            :location => relocate_spec,
            :powerOn => false,
            :template => false
        )

        clone_task = vm.CloneVM_Task(
            :folder => vm.parent,
            :name => target_name,
            :spec => clone_spec
        )

        clone_task.wait_for_completion

        vm_pool = get_objects(vi_client, 'VirtualMachine', properties)
        cloned_vm = vm_pool.find {|r| r['name'] == "#{target_name}" }
        if cloned_vm.nil?
            raise "Unable to find Cloned VM by name '#{target_name}'"
        end

        puts "VM #{attr['name']} cloned successfully."
        cloned_vm
    end

    # This method deletes a VM in vCenter
    #
    # @param vm [RbVmomi::VIM::VirtualMachine] The VM to delete
    #
    def delete_vm(vm)
        vm = vm.obj
        vm_name = vm.name
        puts "Initiating deletion of virtual machine '#{vm.name}'..."
        destroy_task = vm.Destroy_Task
        destroy_task.wait_for_completion
        puts "Virtual machine '#{vm_name}' deleted successfully."
        return true
    end

    def create_vm_template_from_ova
        # Open XML domain file generated from conversion
        xml_file = Dir.glob("#{@options[:work_dir]}/conversions/*.xml")
        if xml_file.empty?
            puts 'No XML domain file found'
            exit 0
        end

        domain_xml = File.read(xml_file.first)
        xml_template = Nokogiri::XML(domain_xml)

        vm_template_config = {
            'NAME' => xml_template.xpath('//name').text,
            'CPU'  => xml_template.xpath('//vcpu').text,
            'vCPU' => xml_template.xpath('//vcpu').text,
            'MEMORY' => mem_to_mb(xml_template.xpath('//memory').text,
                                  xml_template.xpath('//memory/@unit').text),
            'HYPERVISOR' => 'kvm',
            'CONTEXT' => {
                'NETWORK' => 'YES',
                'SSH_PUBLIC_KEY' => '$USER[SSH_PUBLIC_KEY]'
            }
        }

        local_cpu = xml_template.xpath('//cpu')
        if !local_cpu.empty?
            local_cpu_model = local_cpu.xpath('@mode').text
            if local_cpu_model.eql?('custom')
                vm_template_config['CPU_MODEL'] = { 'MODEL' => "#{local_cpu.xpath('/model').text}" }
            else
                vm_template_config['CPU_MODEL'] = { 'MODEL' => 'host-passthrough' }
            end
        end

        # CPU Features from <features>
        local_features = xml_template.xpath('//features//*')
        if !local_features.empty?
            vm_template_config['FEATURES'] = {}
            local_features.each do |feature|
                vm_template_config['FEATURES']["#{feature.name.upcase}"] = 'YES'
            end
        end

        # If the currentMemory attribute is present, it means memory hotplug is enabled
        # memory indicates maximum allocation of memory at boot time,
        # while currentMemory is the actual allocation of memory
        if !xml_template.xpath('//currentMemory').empty?
            vm_template_config['MEMORY_RESIZE_MODE'] = 'HOTPLUG'
            vm_template_config['MEMORY_MAX'] =
                mem_to_mb(xml_template.xpath('//memory').text,
                          xml_template.xpath('//memory/@unit').text)
            if !xml_template.xpath('//maxMemory').empty?
                vm_template_config['MEMORY_MAX'] =
                    mem_to_mb(xml_template.xpath('//maxMemory').text,
                              xml_template.xpath('//maxMemory/@unit').text)
            end

            if vm_template_config.include?('HOT_RESIZE')
                vm_template_config['HOT_RESIZE']['MEMORY_HOT_ADD_ENABLED'] = 'YES'
            else
                vm_template_config['HOT_RESIZE'] = { 'MEMORY_HOT_ADD_ENABLED' => 'YES' }
            end
        end

        if xml_template.xpath('//vcpus//vcpu/@hotpluggable')=='yes'
            # TODO: Search max vCPU parameter in libvirt domain
            # vm_template_config["VCPU_MAX"] = "#{@props['config'][:hardware][:numCPU]}"
            # if @options[:vcpu_max]
            #     vm_template_config["VCPU_MAX"] = "#{@options[:vcpu_max]}"
            # end

            if vm_template_config.include?('HOT_RESIZE')
                vm_template_config['HOT_RESIZE']['CPU_HOT_ADD_ENABLED'] = 'YES'
            else
                vm_template_config['HOT_RESIZE'] = { 'CPU_HOT_ADD_ENABLED' => 'YES' }
            end
        end

        if !xml_template.xpath('//devices//graphics').empty?
            possible_options = ['spice', 'vnc', 'sdl']

            local_graphics = xml_template.xpath('//devices//graphics')
            if possible_options.include?(local_graphics.xpath('@type').text.downcase)
                vm_template_config['GRAPHICS'] = {}
                vm_template_config['GRAPHICS']['TYPE'] = local_graphics.xpath('@type').text.upcase

                if !local_graphics.xpath('@port').empty?
                    vm_template_config['GRAPHICS']['PORT'] = local_graphics.xpath('@port').text
                end

                if !local_graphics.xpath('@passwd').empty?
                    vm_template_config['GRAPHICS']['PASSWD'] = local_graphics.xpath('@passwd').text
                end

                if !local_graphics.xpath('@keymap').empty?
                    vm_template_config['GRAPHICS']['KEYMAP'] = local_graphics.xpath('@passwd').text
                end

                if !local_graphics.xpath('//listen/@address').empty?
                    vm_template_config['GRAPHICS']['LISTEN'] =
                        local_graphics.xpath('//listen/@address').text
                else
                    vm_template_config['GRAPHICS']['LISTEN'] = '0.0.0.0'
                end

                # TODO: command not an atribute of graphics in libvirt domain
                # if @options[:graphics_command]
                #    vm_template_config["GRAPHICS"]["COMMAND"] = @options[:graphics_command]
                # end
            else
                puts "Invalid graphics type. Please use one of the following: #{possible_options.join(', ')}"
            end
        else
            vm_template_config['GRAPHICS'] = {}
            vm_template_config['GRAPHICS']['TYPE'] = 'VNC'
            vm_template_config['GRAPHICS']['LISTEN'] = '0.0.0.0'
        end

        vmt = OpenNebula::Template.new(OpenNebula::Template.build_xml, @client)
        vmt.add_element('//VMTEMPLATE', vm_template_config)

        # Get network interfaces
        local_interfaces = xml_template.xpath('//devices//interface')
        if !local_interfaces.empty?
            network_ids = @options[:network].to_s.split(',').map(&:strip)

            puts "Adding #{local_interfaces} NICs using Network ID(s) #{network_ids.join(', ')}"

            nic_number = 0
            local_interfaces.each do |_interface|
                network_id = network_ids[nic_number] || network_ids.last # Reuse last ID if not enough

                net_hash = { 'NETWORK_ID' => "#{network_id}" }

                # Check for MAC Address
                if !local_interfaces.xpath('//mac/@address').empty?
                    puts "Adding MAC address to NIC##{nic_number}"
                    net_hash['MAC'] = local_interfaces.xpath('//mac/@address').text
                end

                vmt.add_element('//VMTEMPLATE', { 'NIC' => net_hash })

                nic_number += 1
            end
        end

        # Add UEFI configuration
        # Create @props variable here to call template_firmware function

        local_firmware = xml_template.at_xpath('//os/@firmware')&.text == 'efi' || xml_template.xpath('//os/loader/@type').text == 'pflash' ? 'efi' : 'bios'
        local_secure_boot = xml_template.at_xpath('//os//loader/@secure')&.text == 'yes'

        boot_options = {}
        boot_options[:efiSecureBootEnabled] = 'yes' if local_firmware == 'efi' && local_secure_boot
        boot_options[:loader] =
            xml_template.xpath('//os//loader').text if xml_template.xpath('//os/loader/@type').text == 'pflash'

        @props = {
            'config' => {
                :firmware => local_firmware,
                :bootOptions => boot_options
            }
        }
        vmt.add_element('//VMTEMPLATE', template_firmware)

        vmt
    end

    def create_base_template
        vm_template_config = {
            'NAME' => "#{@options[:name]}",
            'CPU'  => "#{@props['config'][:hardware][:numCPU]}",
            'vCPU' => "#{@props['config'][:hardware][:numCPU]}",
            'MEMORY' => "#{@props['config'][:hardware][:memoryMB]}",
            'HYPERVISOR' => 'kvm'
        }

        if @options[:cpu_model]
            vm_template_config['CPU_MODEL'] = { 'MODEL' => "#{@options[:cpu_model]}" }
        end

        if !@options[:disable_contextualization]
            vm_template_config['CONTEXT'] = {
                'NETWORK' => 'YES',
                'SSH_PUBLIC_KEY' => '$USER[SSH_PUBLIC_KEY]'
            }
        end

        if @options[:cpu]
            vm_template_config['CPU'] = "#{@options[:cpu]}"
        end

        if @options[:vcpu]
            vm_template_config['vCPU'] = "#{@options[:vcpu]}"
        end

        if @props['config'][:memoryHotAddEnabled]
            vm_template_config['MEMORY_RESIZE_MODE'] = 'HOTPLUG'
            vm_template_config['MEMORY_MAX'] = "#{@props['config'][:hardware][:memoryMB]}"
            if @options[:memory_max]
                vm_template_config['MEMORY_MAX'] = "#{@options[:memory_max]}"
            end

            if vm_template_config.include?('HOT_RESIZE')
                vm_template_config['HOT_RESIZE']['MEMORY_HOT_ADD_ENABLED'] = 'YES'
            else
                vm_template_config['HOT_RESIZE'] = { 'MEMORY_HOT_ADD_ENABLED' => 'YES' }
            end
        end

        if @props['config'][:cpuHotAddEnabled]
            vm_template_config['VCPU_MAX'] = "#{@props['config'][:hardware][:numCPU]}"
            if @options[:vcpu_max]
                vm_template_config['VCPU_MAX'] = "#{@options[:vcpu_max]}"
            end

            if vm_template_config.include?('HOT_RESIZE')
                vm_template_config['HOT_RESIZE']['CPU_HOT_ADD_ENABLED'] = 'YES'
            else
                vm_template_config['HOT_RESIZE'] = { 'CPU_HOT_ADD_ENABLED' => 'YES' }
            end
        end

        if @options[:graphics_type]
            possible_options = ['spice', 'vnc', 'sdl']

            if possible_options.include?(@options[:graphics_type].downcase)
                vm_template_config['GRAPHICS'] = {}
                vm_template_config['GRAPHICS']['TYPE'] = @options[:graphics_type].upcase

                if @options[:graphics_port]
                    vm_template_config['GRAPHICS']['PORT'] = @options[:graphics_port]
                end

                if @options[:graphics_password]
                    vm_template_config['GRAPHICS']['PASSWD'] = @options[:graphics_password]
                end

                if @options[:graphics_keymap]
                    vm_template_config['GRAPHICS']['KEYMAP'] = @options[:graphics_keymap]
                end

                if @options[:graphics_listen]
                    vm_template_config['GRAPHICS']['LISTEN'] = @options[:graphics_listen]
                else
                    vm_template_config['GRAPHICS']['LISTEN'] = '0.0.0.0'
                end

                if @options[:graphics_command]
                    vm_template_config['GRAPHICS']['COMMAND'] = @options[:graphics_command]
                end
            else
                puts "Invalid graphics type. Please use one of the following: #{possible_options.join(', ')}"
            end
        else
            vm_template_config['GRAPHICS'] = {}
            vm_template_config['GRAPHICS']['TYPE'] = 'VNC'
            vm_template_config['GRAPHICS']['LISTEN'] = '0.0.0.0'
        end

        vmt = OpenNebula::Template.new(OpenNebula::Template.build_xml, @client)
        vmt.add_element('//VMTEMPLATE', vm_template_config)

        vmt
    end

    def template_firmware
        fw = { 'OS' => { 'FIRMWARE' => 'BIOS' } }

        return fw unless @props['config'][:firmware] == 'efi'

        secure_boot = @props['config'][:bootOptions][:efiSecureBootEnabled]
        os_release = File.exist?('/etc/os-release') ? File.read('/etc/os-release') : ''
        is_ubuntu = os_release.include?('ID=ubuntu')

        local_uefi_path =
            @props['config'][:bootOptions][:loader] ||
            if secure_boot
                @options[:uefi_sec_path] || (is_ubuntu ? '/usr/share/OVMF/OVMF_CODE_4M.secboot.fd' : '/usr/share/edk2/ovmf/OVMF_CODE.secboot.fd')
            else
                @options[:uefi_path] || (is_ubuntu ? '/usr/share/OVMF/OVMF_CODE_4M.fd' : '/usr/share/edk2/ovmf/OVMF_CODE.fd')
            end

        unless local_uefi_path && !local_uefi_path.empty?
            raise 'Unable to determine UEFI firmware path, --uefi_path should be used'
        end

        fw['OS']['FIRMWARE'] = local_uefi_path
        fw['OS']['FIRMWARE_SECURE'] = 'YES' if secure_boot
        fw['OS']['MACHINE'] = 'q35'

        fw
    end

    # SCHED_DS_REQUIREMENTS = "ID=\"110\""
    # SCHED_REQUIREMENTS = "ID=\"0\" | CLUSTER_ID=\"0\""
    def template_scheduling(vmt)
        sched = {}
        if @options[:one_cluster] or @options[:one_host]
            sched['SCHED_REQUIREMENTS'] = ''
            if @options[:one_host]
                sched['SCHED_REQUIREMENTS'] << "ID=\"#{@options[:one_host]}\""
            end
            if @options[:one_host] and @options[:one_cluster]
                sched['SCHED_REQUIREMENTS'] << ' | '
            end
            if @options[:one_cluster]
                sched['SCHED_REQUIREMENTS'] << "CLUSTER_ID=\"#{@options[:one_cluster]}\""
            end
            vmt.add_element('//VMTEMPLATE', sched)
            sched = {}
        end
        if @options[:one_datastore] or @options[:one_datastore_cluster]
            sched['SCHED_DS_REQUIREMENTS'] = ''
            if @options[:one_datastore]
                sched['SCHED_DS_REQUIREMENTS'] << "ID=\"#{@options[:one_datastore]}\""
            end
            if @options[:one_datastore] and @options[:one_datastore_cluster]
                sched['SCHED_DS_REQUIREMENTS'] << ' | '
            end
            if @options[:one_datastore_cluster]
                sched['SCHED_DS_REQUIREMENTS'] << "CLUSTER_ID=\"#{@options[:one_datastore_cluster]}\""
            end
            vmt.add_element('//VMTEMPLATE', sched)
        end
        vmt
    end

    # Translate vCenter Definition to OpenNebula Template
    def create_vm_template
        vmt = create_base_template
        vmt.add_element('//VMTEMPLATE', template_firmware)

        # add any notes as the description
        if @props['config'][:annotation] && !@props['config'][:annotation].empty?
            notes = @props['config'][:annotation]
                    .gsub('\\', '\\\\')
                    .gsub('"', '\\"')
            vmt.add_element('//VMTEMPLATE', { 'DESCRIPTION' => "#{notes}" })
        end

        vmt = template_scheduling(vmt)

        # detect icon
        logo = nil
        case @props['guest.guestFullName']
        when /CentOS/i then     logo = 'images/logos/centos.png'
        when /Debian/i then     logo = 'images/logos/debian.png'
        when /Red Hat/i then    logo = 'images/logos/redhat.png'
        when /Ubuntu/i then     logo = 'images/logos/ubuntu.png'
        when /Windows XP/i then logo = 'images/logos/windowsxp.png'
        when /Windows/i then    logo = 'images/logos/windows8.png'
        when /Linux/i then      logo = 'images/logos/linux.png'
        end
        vmt.add_element('//VMTEMPLATE', { 'LOGO' => logo }) if logo

        vmt
    end

    def ip_version(ip_address)
        ip = IPAddr.new(ip_address) rescue nil
        return unless ip
        return 'IP4' if ip.ipv4?
        return 'IP6' if ip.ipv6?
    end

    # Yoinked from StackOverflow question 10262235
    def show_wait_spinner(fps = 10)
        chars = ['|', '/', '-', '\\']
        delay = 1.0/fps
        iter = 0
        spinner = Thread.new do
            while iter # Keep spinning until told otherwise
                print chars[(iter+=1) % chars.length]
                sleep delay
                print "\b"
            end
        end
        # Use the block's return value as the method's
        yield.tap do # After yielding to the block, save the return value
            iter = false # Tell the thread to exit, cleaning up after itself…
            spinner.join # …and wait for it to do so.
        end
    end

    def next_suffix(suffix)
        return 'a' if suffix.empty?

        chars = suffix.chars
        if chars.last == 'z'
            chars.pop
            return next_suffix(chars.join) + 'a'
        end

        chars[-1] = chars.last.succ
        chars.join
    end

    # Runs a command and reports its execution status and output.
    #
    # @param cmd [String] The command to be executed.
    # @param out [Boolean] (optional) Whether to return the output or not.
    # @param timeout [Integer, nil] (optional) Abort the command if it runs longer than this specified number of seconds, 0 disables timeout.
    # @return [Array] Returns an array containing the stdout and status if out is true.
    def run_cmd_report(cmd, out = false, timeout: nil)
        t0 = Time.now
        stdout, stderr, status = nil
        timed_out = false
        puts "Running: #{cmd}"
        show_wait_spinner do
            if timeout && timeout.to_i > 0
                stdout, stderr, status, timed_out = run_cmd_with_timeout(cmd, timeout.to_i)
            else
                stdout, stderr, status = Open3.capture3(cmd)
            end
        end
        t1 = (Time.now - t0).round(2)
        if timed_out
            puts "Timed out after #{t1}s".red
        else
            puts !status.success? ? "Failed (#{t1}s)".red : "Success (#{t1}s)".green
        end

        @logger.debug(stderr) unless status&.success?

        return stdout, status if out
    end

    # Runs a command, terminating it if exceeds the given amount of seconds
    #
    # @return [Array] [stdout, stderr, status, timed_out]
    def run_cmd_with_timeout(cmd, timeout)
        timed_out = false
        stdout = stderr = ''
        status = nil

        Open3.popen3(cmd, :pgroup => true) do |stdin, out_io, err_io, wait_thr|
            stdin.close
            out_reader = Thread.new { out_io.read }
            err_reader = Thread.new { err_io.read }

            unless wait_thr.join(timeout)
                timed_out = true
                kill_process_group(wait_thr.pid)
            end

            status = wait_thr.value
            stdout = out_reader.value.to_s
            stderr = err_reader.value.to_s
        end

        [stdout, stderr, status, timed_out]
    end

    # Sends TERM then KILL to the process group led by pid
    def kill_process_group(pid)
        Process.kill('TERM', -pid)
        sleep 5
        Process.kill('KILL', -pid)
    rescue Errno::ESRCH, Errno::EPERM
    end

    def detect_distro(disk)
        print 'Inspecting disk...'
        t0 = Time.now
        distro_info = {}
        inspector_cmd = 'virt-inspector -a '\
            "#{disk} "\
            '--no-applications --no-icon'
        disk_xml = nil
        show_wait_spinner do
            stdout, _status = Open3.capture2(inspector_cmd)
            disk_xml = REXML::Document.new(stdout).root.elements
        end
        xprefix = '//operatingsystems/operatingsystem'
        if !disk_xml[xprefix]
            return
        end

        distro_info['distro']  = disk_xml["#{xprefix}/distro"].text
        distro_info['name']    = disk_xml["#{xprefix}/name"].text
        distro_info['os']      = disk_xml["#{xprefix}/osinfo"].text
        if distro_info['distro'] != 'windows'
            distro_info['pkg'] = disk_xml["#{xprefix}/package_format"].text
        end
        distro_info['mounts'] = {}
        mounts = disk_xml["#{xprefix}/mountpoints"].select {|d| d.is_a?(REXML::Element) }
        mounts.each do |mp|
            distro_info['mounts'][mp.text] = mp['dev'] # mountpath is the key, dev is value
        end
        distro_info['product_name'] = disk_xml["#{xprefix}/product_name"].text
        puts "Done (#{(Time.now - t0).round(2)}s)".green
        distro_info
    end

    def detect_context_package(distro)
        case distro
        when 'rhel8'
            c_files = Dir.glob("#{@options[:context]}/one-context*el8*rpm")
        when 'rhel9'
            c_files = Dir.glob("#{@options[:context]}/one-context*el9*rpm")
        when 'fedora'
            c_files = Dir.glob("#{@options[:context]}/one-context*fc*rpm")
        when 'debian'
            c_files = Dir.glob("#{@options[:context]}/one-context*deb")
        when 'alpine'
            c_files = Dir.glob("#{@options[:context]}/one-context*apk")
        when 'alt'
            c_files = Dir.glob("#{@options[:context]}/one-context*alt*rpm")
        when 'opensuse'
            c_files = Dir.glob("#{@options[:context]}/one-context*suse*rpm")
        when 'freebsd'
            c_files = Dir.glob("#{@options[:context]}/one-context*txz")
        when 'windows'
            c_files = Dir.glob("#{@options[:context]}/one-context*msi")
        end

        if c_files.length == 1
            c_files[0]
        elsif c_files.length > 1
            c_files.max_by {|f| Gem::Version.new(f.match(/(\d+\.\d+\.\d+(?:-\d+)?)/)[1]) }

        else
            # download the correct one
            return false
        end
    end

    def inject_dns_resolv_conf
        val = @options[:inject_dns].to_s.strip
        if val == 'host'
            unless File.exist?('/etc/resolv.conf')
                puts 'Warning: --inject-dns host specified but /etc/resolv.conf not found on host, skipping.'.brown
                return nil
            end
            real_path = File.realpath('/etc/resolv.conf')
            tmp_resolv = "#{@options[:work_dir]}/resolv.conf"
            File.open(real_path, 'rb') do |src|
                File.open(tmp_resolv, 'wb') {|dst| IO.copy_stream(src, dst) }
            end
            puts "Injecting host resolv.conf (from #{real_path}) into guest...".green
            return tmp_resolv
        else
            ips = val.split(',').map(&:strip).reject(&:empty?)
            if ips.empty?
                puts 'Warning: --inject-dns value is not valid, skipping DNS injection.'.brown
                return nil
            end
            tmp_resolv = "#{@options[:work_dir]}/resolv.conf"
            File.write(tmp_resolv, ips.map {|ip| "nameserver #{ip}" }.join("\n") + "\n")
            puts "Injecting custom resolv.conf (#{ips.join(', ')}) into guest for DNS resolution...".green
            return tmp_resolv
        end
    end

    def context_command(disk, osinfo)
        base_cmd = "virt-customize -q -a #{disk}"
        cmd = nil
        fallback_cmd = nil
        context_fullpath = nil

        if osinfo['name'] == 'windows'
            context_fullpath = detect_context_package('windows')
            return false unless context_fullpath

            context_basename = File.basename(context_fullpath)
            cmd = base_cmd +
                  ' --mkdir /Temp'\
                  " --copy-in #{context_fullpath}:/Temp"\
                  " --firstboot-command 'msiexec -i c:\\Temp\\#{context_basename} /quiet && del c:\\Temp\\#{context_basename}'"
            return cmd, nil
        end

        if @options[:inject_dns]
            resolv_src = inject_dns_resolv_conf
            if resolv_src
                base_cmd += " --copy-in #{resolv_src}:/etc/"
            end
        end

        # os gives versions, so check that instead of distro
        if osinfo['os'] =~ /^(redhat-based|rhel|fedora|ubuntu|debian)/ # start_with any of these
            os = nil
            opts = []
            fallback_opts = []

            case osinfo['os']
            when /^fedora/
                os = 'fedora'
                opts = [
                    ' --copy-in %<context>s:/tmp',
                    ' --install /tmp/%<basename>s',
                    ' --delete /tmp/%<basename>s',
                    " --run-command 'systemctl enable systemd-networkd'",
                    " --run-command 'systemctl disable systemd-networkd-wait-online'",
                    " --run-command 'sed -i \"s/SELINUX=enforcing/SELINUX=disabled/\" /etc/selinux/config || exit 0'"
                ]
                fallback_opts = [
                    ' --copy-in %<context>s:/tmp',
                    ' --firstboot-install /tmp/%<basename>s',
                    " --run-command 'systemctl enable systemd-networkd'",
                    " --run-command 'systemctl disable systemd-networkd-wait-online'",
                    " --run-command 'sed -i \"s/SELINUX=enforcing/SELINUX=disabled/\" /etc/selinux/config || exit 0'"
                ]
            when /^redhat-based8/, /^rhel8/
                os = 'rhel8'
                opts = [
                    " --run-command 'subscription-manager repos --enable codeready-builder-for-rhel-8-$(arch)-rpms'",
                    " --run-command 'yum -y install https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm'",
                    ' --copy-in %<context>s:/tmp',
                    ' --install /tmp/%<basename>s',
                    ' --delete /tmp/%<basename>s',
                    " --run-command 'systemctl enable NetworkManager.service || exit 0'"
                ]
                fallback_opts = [
                    ' --firstboot-install epel-release',
                    ' --copy-in %<context>s:/tmp',
                    ' --firstboot-install /tmp/%<basename>s',
                    " --run-command 'systemctl enable NetworkManager.service || exit 0'"
                ]
            when /^redhat-based9/, /^rhel9/
                os = 'rhel9'
                opts = [
                    " --run-command 'subscription-manager repos --enable codeready-builder-for-rhel-9-$(arch)-rpms'",
                    " --run-command 'yum -y install https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm'",
                    ' --copy-in %<context>s:/tmp',
                    ' --install /tmp/%<basename>s',
                    ' --delete /tmp/%<basename>s',
                    " --run-command 'systemctl enable NetworkManager.service || exit 0'"
                ]
                fallback_opts = [
                    ' --firstboot-install epel-release',
                    ' --copy-in %<context>s:/tmp',
                    ' --firstboot-install /tmp/%<basename>s',
                    " --run-command 'systemctl enable NetworkManager.service || exit 0'"
                ]
            when /^ubuntu/, /^debian/
                os = 'debian'
                opts = [
                    ' --uninstall cloud-init',
                    ' --copy-in %<context>s:/tmp',
                    ' --install /tmp/%<basename>s',
                    ' --delete /tmp/%<basename>s',
                    " --run-command 'systemctl enable network.service || exit 0'"
                ]
                fallback_opts = [
                    ' --uninstall cloud-init',
                    ' --copy-in %<context>s:/tmp',
                    ' --firstboot-install /tmp/%<basename>s',
                    " --run-command 'systemctl enable network.service || exit 0'"
                ]
            end

            context_fullpath = detect_context_package(os)
            return false unless context_fullpath

            context_basename = File.basename(context_fullpath)
            vars = {
                :context => context_fullpath,
                :basename => context_basename
            }

            cmd = base_cmd + opts.map {|c| c % vars }.join
            fallback_cmd = base_cmd + fallback_opts.map {|c| c % vars }.join

        elsif osinfo['os'].start_with?('alt', 'opensuse', 'sles')
            os = osinfo['os'].start_with?('alt') ? 'alt' : 'opensuse'
            context_fullpath = detect_context_package(os)
            return false unless context_fullpath

            context_basename = File.basename(context_fullpath)
            cmd = base_cmd +
                    " --copy-in #{context_fullpath}:/tmp"\
                    " --install /tmp/#{context_basename}"\
                    " --delete /tmp/#{context_basename}"
            fallback_cmd = base_cmd +
                            " --copy-in #{context_fullpath}:/tmp"\
                            " --firstboot-install /tmp/#{context_basename}"

        elsif osinfo['os'].start_with?('freebsd')
            # may not mount properly sometimes due to internal fs
            context_fullpath = detect_context_package('freebsd')
            return false unless context_fullpath

            context_basename = File.basename(context_fullpath)
            cmd = base_cmd +
                    ' --install curl,bash,sudo,base64,ruby,open-vm-tools-nox11'\
                    " --copy-in #{context_fullpath}:/tmp"\
                    " --install /tmp/#{context_basename}"\
                    " --delete /tmp/#{context_basename}"
            fallback_cmd = base_cmd +
                            ' --firstboot-install curl,bash,sudo,base64,ruby,open-vm-tools-nox11'\
                            " --copy-in #{context_fullpath}:/tmp"\
                            " --firstboot-install /tmp/#{context_basename}"

        elsif osinfo['os'].start_with?('alpine')
            puts 'Alpine is not compatible with offline install, please install context manually.'.brown
            return false
        end
        return false unless context_fullpath

        return cmd, fallback_cmd
    end

    def get_win_controlset(disk)
        cmd = 'virt-win-reg'\
              " #{disk}"\
              " 'HKLM\\SYSTEM\\Select'"
        print 'Checking Windows ControlSet...'
        stdout, _status = run_cmd_report(cmd, true)
        stdout.split("\n").find {|s| s.start_with?('"Current"') }.split(':')[1].to_i
    end

    def win_context_inject(disk, osinfo)
        puts 'win_context_inject'
        cmd = "guestfish <<_EOF_
add #{disk}
run
mount #{osinfo['mounts']['/']} /
upload #{@options[:virt_tools]}/rhsrvany.exe /rhsrvany.exe
upload #{detect_context_package('windows')} /one-context.msi
_EOF_"
        print 'Uploading context files to Windows disk...'
        puts 'win_context_inject cmd: ' + cmd
        run_cmd_report(cmd)

        ccs = get_win_controlset(disk)
        regfile = File.open("#{@options[:work_dir]}/service.reg", 'w')
        regfile.puts("[HKEY_LOCAL_MACHINE\\SYSTEM\\ControlSet#{'%03d' % ccs}\\services\\RHSrvAnyContext]")
        regfile.puts('"Type"=dword:00000010')
        regfile.puts('"Start"=dword:00000002')
        regfile.puts('"ErrorControl"=dword:00000001')
        regfile.puts('"ImagePath"="c:\\rhsrvany.exe"')
        regfile.puts('"DisplayName"="RHSrvAnyContext"')
        regfile.puts('"ObjectName"="LocalSystem"')
        regfile.puts
        regfile.puts("[HKEY_LOCAL_MACHINE\\SYSTEM\\ControlSet#{'%03d' % ccs}\\services\\RHSrvAnyContext\\Parameters]")
        regfile.puts('"CommandLine"="msiexec -i c:\\one-context.msi"')
        regfile.puts('"PWD"="c:\\Temp"')
        regfile.flush
        regfile.close

        cmd = 'virt-win-reg'\
              ' --merge'\
              " #{disk}"\
              " #{@options[:work_dir]}/service.reg"

        print 'Merging service registry entry to install on boot...'
        run_cmd_report(cmd)
    end

    def win_virtio_command(disk)
        # requires newer version of virt-customize actually
        cmd = 'virt-customize'\
              " -a #{disk}"\
              " --inject-virtio-win #{@options[:virtio_path]}"
        puts 'win_virtio_command cmd: ' + cmd
        cmd
    end

    # Resolve the MSI path from --win-qemu-ga option
    # Allowed formats are:
    #   - /path/to/qemu-ga-x86_64.msi         direct MSI file
    #   - /path/to/mounted-virtio-win/        directory already mounted
    #   - /path/to/virtio-win.iso             ISO file (mounted temporarily)
     def resolve_qemu_ga_msi(path)
        if path.end_with?('.msi') && File.exist?(path)
            path
        elsif File.directory?(path)
            candidate = File.join(path, 'guest-agent', 'qemu-ga-x86_64.msi')
            unless File.exist?(candidate)
                raise "Could not find guest-agent/qemu-ga-x86_64.msi under '#{path}'."
            end
            candidate
        elsif path.end_with?('.iso') && File.exist?(path)
            mnt = Dir.mktmpdir('oneswap-iso-')
            begin
                _out, err, st = Open3.capture3("mount -o loop,ro #{Shellwords.escape(path)} #{Shellwords.escape(mnt)}")
                raise "Failed to mount ISO '#{path}': #{err.strip}" unless st.success?

                candidate = File.join(mnt, 'guest-agent', 'qemu-ga-x86_64.msi')
                unless File.exist?(candidate)
                    raise "Could not find guest-agent/qemu-ga-x86_64.msi in ISO '#{path}'."
                end

                dest = File.join(@options[:work_dir] || Dir.tmpdir, 'qemu-ga-x86_64.msi')
                FileUtils.cp(candidate, dest)
                dest
            ensure
                system("umount #{Shellwords.escape(mnt)} 2>/dev/null")
                Dir.rmdir(mnt) rescue nil
            end
        else
            raise "Could not find qemu-ga-x86_64.msi from '#{path}'. " \
                  'Pass the path to the .msi file, the mounted virtio-win directory or the virtio-win.iso file.'
        end
    end

    def qemu_ga_command(disk)
        msi = resolve_qemu_ga_msi(@options[:qemu_ga_win].to_s)
        msi_filename = File.basename(msi)
        # Copy MSI into the guest and install it silently on first boot then delete it.
        'virt-customize'\
        " -a #{disk}"\
        ' --mkdir /Temp'\
        " --copy-in #{Shellwords.escape(msi)}:/Temp"\
        " --firstboot-command " \
        "'msiexec /i c:\\Temp\\#{msi_filename} /quiet /norestart && del c:\\Temp\\#{msi_filename}'"
    end

    def pkg_install_command(disk, pkg)
        'virt-customize'\
              " -a #{disk}"\
              " --install #{pkg}"
    end

    def install_pkg(disk, pkg, timeout: nil)
        print "Installing #{pkg}..."
        run_cmd_report(pkg_install_command(disk, pkg), false, timeout: timeout)
    end

    # Returns the free space (in MB) on the guest root filesystem, or nil if it cannot be determined
    def guest_root_free_mb(disk, osinfo)
        root_dev = osinfo['mounts'] && osinfo['mounts']['/']
        return nil unless root_dev

        cmd = 'guestfish --ro'\
              " -a #{disk}"\
              " run : mount-ro #{root_dev} / : statvfs /"
        stdout, stderr, status = Open3.capture3(cmd)
        unless status.success?
            @logger.debug("Could not stat guest root filesystem: #{stderr}")
            return nil
        end

        # guestfish prints the statvfs struct as "key: value" lines
        stats = {}
        stdout.each_line do |line|
            key, val = line.split(':', 2).map {|s| s.to_s.strip }
            stats[key] = val.to_i if !key.to_s.empty? && val.to_s =~ /\A\d+\z/
        end

        block_size = stats['frsize'].to_i > 0 ? stats['frsize'].to_i : stats['bsize'].to_i
        avail = stats['bavail']
        return nil unless block_size > 0 && avail

        (avail * block_size) / (1024 * 1024)
    rescue StandardError => e
        @logger.debug("Error checking guest free space: #{e.message}")
        nil
    end

    # Verifies the guest root filesystem has enough free space for context
    # injection. Returns true if injection should proceed, false if it should be
    # skipped (warn mode). Raises ConversionError when --context-fail-on-low-space
    # is set and free space is insufficient.
    def ensure_guest_free_space(disk, osinfo)
        min_free = @options[:context_min_free].to_i
        return true if min_free <= 0

        free_mb = guest_root_free_mb(disk, osinfo)
        if free_mb.nil?
            puts 'Warning: could not determine guest root filesystem free space, proceeding with context injection.'.brown
            return true
        end

        return true if free_mb >= min_free

        msg = "Guest root filesystem has only #{free_mb} MB free, below the #{min_free} MB " \
              'required for context injection.'
        raise ConversionError, msg if @options[:context_fail_on_low_space]

        puts msg.red
        puts 'Skipping context injection to avoid hanging; install one-context manually after boot.'.brown
        false
    end

    def guest_run_cmd(disk, cmd)
        cmd = 'guestfish'\
              " -a #{disk}"\
              ' -i'\
              " #{cmd}"
        puts "Running: #{cmd}"
        _stdout, _stderr, _status = Open3.capture3(cmd)
    end

    def package_injection(disk, osinfo)
        injector_cmd, fallback_cmd = context_command(disk, osinfo)
        ctx_timeout = @options[:context_timeout]
        if !injector_cmd
            if osinfo['name'] == 'windows'
                win_context_inject(disk, osinfo) if ensure_guest_free_space(disk, osinfo)
            else
                puts 'Unsupported guest OS or couldn\'t find context file for context injection. Please install manually.'.brown
            end
        elsif @options[:skip_context]
            print 'Skipping context injection...'
            return
        elsif ensure_guest_free_space(disk, osinfo)
            print 'Injecting one-context...'
            _stdout, status = run_cmd_report(injector_cmd, true, timeout: ctx_timeout)
            if !status.success?
                if fallback_cmd
                    print 'Context injection command appears to have failed. Attempting fallback'.brown
                    _stdout, status = run_cmd_report(fallback_cmd, true, timeout: ctx_timeout)
                end
                if !status.success?
                    puts 'Context injection failed, please install context manually.'.red
                    return
                end
                print 'Context will install on first boot, you may need to boot it twice.'.brown
            end
        end

        if osinfo['name'] == 'windows' && @options[:virtio_path]
            injector_cmd = win_virtio_command(disk)
            print 'Injecting VirtIO to Windows...'
            run_cmd_report(injector_cmd, false, timeout: ctx_timeout)
        end

        if @options[:qemu_ga_win] && osinfo['name'] == 'windows'
            injector_cmd = qemu_ga_command(disk)
            print 'Injecting QEMU Guest Agent...'
            run_cmd_report(injector_cmd, false, timeout: ctx_timeout)
        end

        return unless @options[:qemu_ga_linux] && osinfo['name'] != 'windows'

        install_pkg(disk, 'qemu-guest-agent', timeout: ctx_timeout)
    end

    def get_objects(vim, type, properties, folder = nil)
        pc = vim.serviceInstance.content.propertyCollector
        viewmgr = vim.serviceInstance.content.viewManager
        # determine if we need to look in specific folders
        if folder
            if folder.key?(:datacenter)
                rootFolder = vim.serviceInstance.find_datacenter(folder[:datacenter])
                if !rootFolder
                    raise 'Unable to find Datacenter with name '\
                        "'#{folder[:datacenter]}'."
                end
                if folder.key?(:cluster)
                    clusterFolder = rootFolder.find_compute_resource(folder[:cluster])

                    if !clusterFolder
                        raise 'Unable to find Cluster with name '\
                            "'#{folder[:cluster]}' in Datacenter "\
                            "'#{folder[:datacenter]}'."
                    end
                    rootFolder = clusterFolder
                end
            end
        else
            rootFolder = vim.serviceInstance.content.rootFolder
        end

        view = viewmgr.CreateContainerView({
                                               :container => rootFolder,
                :type => [type],
                :recursive => true
                                           })
        filterSpec = RbVmomi::VIM.PropertyFilterSpec(
            :objectSet => [
                :obj => view,
                :skip => true,
                :selectSet => [
                    RbVmomi::VIM.TraversalSpec(
                        :name => 'traverseEntities',
                        :type => 'ContainerView',
                        :path => 'view',
                        :skip => false
                    )
                ]
            ],
            :propSet => [
                { :type => type, :pathSet => properties }
            ]
        )
        pc.RetrieveProperties(:specSet => [filterSpec])
    end

    def build_v2v_hybrid_cmd(xml_file)
        # virt-v2v
        #   -i disk
        #   '/path/to/local/disk'
        #   -o local
        #   -os /path/to/working/folder
        #   -of [qcow2|raw]
        "#{@options[:v2v_path]} -v --machine-readable"\
                  ' -i libvirtxml'\
                  " #{xml_file}"\
                  ' -o local'\
                  ' --root first'\
                  " -os #{@options[:work_dir]}/conversions/"\
                  " -of #{@options[:format]}"
    end

    def vc_virtual_disks
        @props['config'][:hardware][:device].grep(RbVmomi::VIM::VirtualDisk)
    end

    def vsan_backed_disks
        vc_virtual_disks.select {|disk| vsan_backed_disk?(disk) }
    end

    def vsan_backed_disk?(disk)
        backing = disk.backing
        datastore = backing.respond_to?(:datastore) ? backing.datastore : nil
        type = datastore&.summary&.type.to_s.downcase
        return true if type == 'vsan'

        datastore_name = backing.fileName.to_s[/^\[(.+?)\]/, 1]
        datastore_name.to_s.downcase == 'vsandatastore'
    rescue StandardError
        false
    end

    def validate_vsan_conversion!
        return if vsan_backed_disks.empty?

        raise VSAN_DELTA_UNSUPPORTED_MESSAGE.red if @options[:delta]
        raise VSAN_HYBRID_UNSUPPORTED_MESSAGE.red if @options[:hybrid]
        raise VSAN_VDDK_REQUIRED_MESSAGE.red unless @options[:vddk_path]
    end

    def build_v2v_vc_cmd
        # virt-v2v
        #   -ic 'vpx://UserName@vCenter.Host.FQDN
        #             /Datacenter/Cluster/Host?no_verify=1'
        #   -ip password_file.txt ### Should be a 0600 file with only the password, no newline
        #   'virtual-machine-name'
        #   -o local
        #   -os /path/to/working/folder
        #   -of [qcow2|raw]
        err_msg = 'Unable to find Host, Cluster, and Datacenter of VM'
        dc, cluster, host = nil

        # Fetch the HostSystem directly via get_objects so the returned object has
        # @vi_client properly embedded for lazy parent traversal (nested property
        # objects from VirtualMachine.to_hash may not carry the connection).
        host_ref = @props['runtime.host']._ref
        hs_list = get_objects(@vi_client, 'HostSystem', ['name'])
        hs_entry = hs_list.find {|h| h.obj._ref == host_ref }
        raise err_msg unless hs_entry

        pobj = hs_entry.obj

        while dc.nil? || cluster.nil? || host.nil?
            host    = pobj  if pobj.class == RbVmomi::VIM::HostSystem
            cluster = pobj  if pobj.class == RbVmomi::VIM::ClusterComputeResource
            cluster = false if pobj.class == RbVmomi::VIM::ComputeResource
            dc      = pobj  if pobj.class == RbVmomi::VIM::Datacenter

            begin
                pobj = pobj[:parent]
            rescue StandardError => e
                @logger.error("Error traversing vCenter inventory: #{e.message}")
                raise err_msg
            end

            raise err_msg if pobj.nil?
        end

        no_verify = @options[:accept_cert] ? '?no_verify=1' : ''
        enc = ->(s) { CGI.escape(s.to_s).gsub('+', '%20') }
        if cluster == false
            url = "vpx://#{CGI.escape(@options[:vuser])}@#{@options[:vcenter]}"\
                  "/#{enc.(dc[:name])}/#{enc.(host[:name])}#{no_verify}"
        else
            url = "vpx://#{CGI.escape(@options[:vuser])}@#{@options[:vcenter]}"\
                  "/#{enc.(dc[:name])}/#{enc.(cluster[:name])}/#{enc.(host[:name])}#{no_verify}"
        end

        "#{@options[:v2v_path]} -v --machine-readable"\
                  " -ic '#{url}'"\
                  " -ip #{@options[:work_dir]}/vpassfile"\
                  ' -o local'\
                  " -os #{@options[:work_dir]}/conversions/"\
                  " -of #{@options[:format]}"\
                  " '#{@props['name']}'"
    end

    def build_v2v_esx_cmd
        # virt-v2v
        #   -i vmx
        #   -ic 'ssh://UserName@ESXI.host.fqdn
        #             /vmfs/volumes/datastore/vmpath/vmfile.vmx
        #   -ip password_file.txt ### Should be a 0600 file with only the password, no newline
        #   -o local
        #   -os /path/to/working/folder
        #   -of [qcow2|raw]

        # [datastore1] example-vm/example-vm.vmx
        ds_name, vmx_relpath = @props['config'][:files][:vmPathName].split('] ', 2)
        ds_name.delete!('[')
        # /vmfs/volumes/65de2b62-8488ae37-d55b-3cecefcef5a6
        ds_path = @props['config'][:datastoreUrl].find {|ds| ds[:name] == ds_name }
        vmx_fullpath = "#{ds_path[:url]}/#{vmx_relpath}"

        url = "ssh://#{CGI.escape(@options[:esxi_user])}@#{@options[:esxi_ip]}"\
              "/#{vmx_fullpath}"
        "#{@options[:v2v_path]} -v --machine-readable"\
                  ' -i vmx'\
                  ' -it ssh'\
                  " #{url}"\
                  " -ip #{@options[:work_dir]}/esxpassfile"\
                  ' -o local'\
                  " -os #{@options[:work_dir]}/conversions/"\
                  " -of #{@options[:format]}"
    end

    def build_v2v_vddk_cmd
        # openssl s_client -connect 147.75.45.11:443 </dev/null 2>/dev/null |
        # openssl x509 -in /dev/stdin -fingerprint -sha1 -noout 2>/dev/null

        # virt-v2v
        #   -ic 'vpx://UserName@vCenter.Host.FQDN/Datacenter/Cluster/Host?no_verify=1'
        #   -ip password_file.txt ### Should be a 0600 file with only the password, no newline
        #   -it vddk
        #   -io vddk-libdir=/path/to/vmware-vix-disklib-distrib
        #   -io vddk-thumbprint=xx:xx:xx:xx... # gather this
        #   'virtual-machine-name'
        #   -o local
        #   -os /path/to/working/folder
        #   -of [qcow2|raw]
        err_msg = 'Unable to find Host, Cluster, and Datacenter of VM'
        dc, cluster, host = nil

        host_ref = @props['runtime.host']._ref
        hs_list = get_objects(@vi_client, 'HostSystem', ['name'])
        hs_entry = hs_list.find {|h| h.obj._ref == host_ref }
        raise err_msg unless hs_entry

        pobj = hs_entry.obj
        while dc.nil? || cluster.nil? || host.nil?
            host    = pobj if pobj.class == RbVmomi::VIM::HostSystem
            cluster = pobj if pobj.class == RbVmomi::VIM::ClusterComputeResource
            cluster = false if pobj.class == RbVmomi::VIM::ComputeResource
            dc      = pobj if pobj.class == RbVmomi::VIM::Datacenter
            begin
                pobj = pobj[:parent]
            rescue StandardError => e
                @logger.error("Error traversing vCenter inventory: #{e.message}")
                raise err_msg
            end
            raise err_msg if pobj.nil?
        end

        tcp_client = TCPSocket.new(@options[:vcenter], 443)
        ssl_context = OpenSSL::SSL::SSLContext.new
        ssl_client = OpenSSL::SSL::SSLSocket.new(tcp_client, ssl_context)
        ssl_client.connect

        ssl_client.puts("GET / HTTP/1.0\r\n\r\n")
        ssl_client.read

        ssl_client.sysclose
        tcp_client.close

        cert = ssl_client.peer_cert
        @options[:vddk_thumb] = OpenSSL::Digest::SHA1.new(cert.to_der).to_s.scan(/../).join(':')

        puts "Certificate thumbprint: #{@options[:vddk_thumb]}"

        no_verify = @options[:accept_cert] ? '?no_verify=1' : ''
        enc = ->(s) { CGI.escape(s.to_s).gsub('+', '%20') }
        if cluster == false
            url = "vpx://#{CGI.escape(@options[:vuser])}@#{@options[:vcenter]}"\
                  "/#{enc.(dc[:name])}/#{enc.(host[:name])}#{no_verify}"
        else
            url = "vpx://#{CGI.escape(@options[:vuser])}@#{@options[:vcenter]}"\
                  "/#{enc.(dc[:name])}/#{enc.(cluster[:name])}/#{enc.(host[:name])}#{no_verify}"
        end
        "#{@options[:v2v_path]} -v --machine-readable"\
                  " -ic '#{url}'"\
                  " -ip #{@options[:work_dir]}/vpassfile"\
                  ' -it vddk'\
                  " -io vddk-libdir=#{@options[:vddk_path]}"\
                  " -io vddk-thumbprint=#{@options[:vddk_thumb]}"\
                  ' -o local'\
                  " -os #{@options[:work_dir]}/conversions/"\
                  " -of #{@options[:format]}"\
                  " '#{@props['name']}'"
    end

    def build_v2v_ova
        # virt-v2v
        #   -i VM.ova
        #   -o local
        #   -os /path/to/working/folder
        #   -of [qcow2|raw]
        #   --root=[ask|single|first|/dev/sdX]

        "#{@options[:v2v_path]} -v --machine-readable"\
                  " -i ova #{@options[:ova]}"\
                  ' -o local'\
                  " -os #{@options[:work_dir]}/conversions/"\
                  " -of #{@options[:format]}"\
                  " --root=#{@options[:root]}"
    end

    def v2v_env
        libguestfs_path = @options[:libguestfs_path].to_s.strip
        return {} if libguestfs_path.empty?

        { 'LIBGUESTFS_PATH' => libguestfs_path }
    end

    def warn_unreadable_kernel_for_libguestfs(env)
        return if Process.uid == 0 || env.key?('LIBGUESTFS_PATH')

        kernel = "/boot/vmlinuz-#{`uname -r`.strip}"
        return if File.readable?(kernel)

        STDERR.puts "Warning: #{kernel} is not readable by the current user. "\
                    'libguestfs/supermin may fail while building its appliance. '\
                    'Configure :libguestfs_path: to use a prebuilt libguestfs appliance, '\
                    'or run OneSwap as root.'
    end

    # Create and run the virt-v2v conversion
    # This uses virt-v2v to connect to vCenter/ESXi, create an overlay on the remote disk,
    #   and convert it before using qemu-img convert over nbdkit connection.  Outputs a
    #   converted qcow2/raw image and libvirt compatible xml definition.
    def run_v2v_conversion
        if @options[:hybrid]
            vc_disks = @props['config'][:hardware][:device].grep(RbVmomi::VIM::VirtualDisk).sort_by(&:key)
            local_disks = hybrid_downloader(vc_disks)
            local_xml = build_hybrid_xml(local_disks)
            local_xml_file = @options[:work_dir] + '/local.xml'
            File.open(local_xml_file, 'w') {|f| f.write(local_xml) }
            command = build_v2v_hybrid_cmd(local_xml_file)
        elsif @options[:esxi_ip]
            command = build_v2v_esx_cmd
        elsif @options[:vddk_path]
            command = build_v2v_vddk_cmd
        elsif @options[:ova]
            command = build_v2v_ova
        else
            command = build_v2v_vc_cmd
        end

        puts "Running: #{command}"
        env = v2v_env
        warn_unreadable_kernel_for_libguestfs(env)

        begin
            error_check = nil
            _stdin, stdout, stderr, wait_thr = Open3.popen3(env, command)

            # Handle each std pipe as a separate thread
            stdout_thread = Thread.new do
                begin
                    stdout.each_line {|line| handle_stdout(line) }
                rescue StandardError => e
                    error_check = e
                end
            end

            stderr_thread = Thread.new do
                stderr.each_line {|line| handle_stderr(line) }
            end

            stdout_thread.join
            stderr_thread.join
            exit_status = wait_thr.value
            print "\n"
            # puts "Process exited with status: #{exit_status}"
            if error_check
                raise error_check
            end
            if exit_status != 0
                raise "virt-v2v exited in error code #{exit_status} but did not provide an error"
            end

            disks_on_file = Dir.glob("#{@options[:work_dir]}/conversions/#{@options[:name]}*").reject do |f|
                f.end_with?('.xml')
            end.sort
            puts "#{disks_on_file.length} disks on the local disk for this VM: #{disks_on_file}"
            if disks_on_file.length == 0
                raise 'There are no disks on the local filesystem due to previous failures.'
            end

            create_one_images(disks_on_file)
        rescue ConversionError
            raise
        rescue StandardError => e
            # puts "Error raised: #{e.message}"
            if @props['config'][:guestFullName].include?('Windows')
                puts 'Windows not supported for fallback conversion.'.brown
                raise e
            end
            if @options[:fallback]
                puts 'Error encountered, fallback enabled. Attempting Custom Conversion now.'
                cleanup_disks
                run_custom_conversion
            elsif @options[:hybrid]
                puts 'Hybrid conversion failed, attempting manual conversion.'
                run_custom_conversion
            else
                puts 'Failed. Fallback is not enabled. Raising error.'.red
                raise e
            end
        end
    end

    # Passworldess root access to ESXi host IP required
    def run_delta_conversion
        local_path_image_allocation_preflight!

        client = ESXi::Client.new(get_esxi_host, @logger, esxi_client_options)

        vm_name = @options[:name]
        vm = client.get_vm_by_name(vm_name)

        conversion_dir = if @options[:delta_commit]
                             vm.live2kvm_commit(@options[:work_dir])
                         else
                             vm.live2kvm(@options[:work_dir])
                         end

        raise 'Failed to perform delta migration' unless conversion_dir

        disks_on_file = Dir.children(conversion_dir).map {|disk| File.join(conversion_dir, disk) }

        create_one_images(disks_on_file)
    end

    def local_path_image_allocation_preflight!
        return if @options[:http_transfer]

        puts 'Local PATH mode requires OneSwap to run on the OpenNebula frontend or work_dir to be shared at the same path.'

        endpoint_host = opennebula_endpoint_host
        return if endpoint_host.nil? || local_opennebula_endpoint?(endpoint_host)
        return if @options[:allow_local_path_remote]

        raise "OpenNebula endpoint host '#{endpoint_host}' appears to differ from this OneSwap worker. " \
              'Local PATH image allocation would be resolved on the OpenNebula frontend and may not see worker-local files. ' \
              'Enable http_transfer/http_host/http_port, run OneSwap on the frontend, share work_dir at the same path, ' \
              'or pass --allow-local-path-remote if this path is intentionally shared.'
    end

    def opennebula_endpoint_host
        endpoint = @options[:endpoint] || ENV['ONE_XMLRPC']
        if endpoint.to_s.empty? && @client
            endpoint = @client.endpoint if @client.respond_to?(:endpoint)
            endpoint ||= @client.instance_variable_get(:@endpoint) if @client.instance_variable_defined?(:@endpoint)
        end
        endpoint = 'http://localhost:2633/RPC2' if endpoint.to_s.empty?

        URI.parse(endpoint).host
    rescue StandardError => e
        @logger.warn "Unable to determine OpenNebula endpoint host: #{e.message}"
        nil
    end

    def local_opennebula_endpoint?(host)
        host = host.to_s.downcase
        return true if host.empty?
        return true if ['localhost', '127.0.0.1', '::1'].include?(host)
        return true if host.start_with?('127.')

        local_names = [Socket.gethostname]
        begin
            local_names << Socket.gethostbyname(Socket.gethostname).first
        rescue StandardError
            nil
        end
        local_names.compact!
        local_names.map!(&:downcase)
        return true if local_names.include?(host)

        local_ips = Socket.ip_address_list.map(&:ip_address) rescue []
        local_ips.include?(host)
    end

    def run_delta_prepare
        esxi_host = get_esxi_host
        puts "Checking ESXi SSH/auth for #{@options[:esxi_user] || 'root'}@#{esxi_host}..."
        client = ESXi::Client.new(esxi_host, @logger, esxi_client_options)
        puts 'ESXi SSH/auth check completed.'

        vm_name = @options[:name]
        vm = client.get_vm_by_name(vm_name)

        state = vm.live2kvm_prepare(@options[:work_dir])
        raise 'Failed to prepare delta migration' unless state

        state_file = File.join(state['transfer_dir'], 'oneswap-delta-state.yaml')
        puts "Delta base phase prepared. State file: #{state_file}"
        puts 'Warning: the VM snapshot remains active. Delta size can continue growing until --delta-commit is run.'
    end

    def run_delta_cleanup
        client = ESXi::Client.new(get_esxi_host, @logger, esxi_client_options)

        vm_name = @options[:name]
        vm = client.get_vm_by_name(vm_name)
        raise "Unable to find VM #{vm_name} on ESXi host" unless vm

        vm.live2kvm_cleanup(@options[:work_dir])
    end

    def run_dry_run_estimate
        if @options[:delta]
            return run_delta_dry_run_estimate
        end

        run_source_export_benchmark if @options[:benchmark_export]
        run_opennebula_import_benchmark if @options[:benchmark_import]

        disks = dry_run_disk_metadata
        total_provisioned_mib = disks.sum {|disk| disk[:provisioned_mib] }
        total_data_mib = disks.sum {|disk| disk[:estimated_data_mib] }

        source_info = source_export_estimate
        source_rate = source_info[:rate]
        convert_rate = positive_float_option(:dry_run_qcow2_conversion_mib_s)
        convert_rate_source = :configured_fallback
        convert_rate_label = 'configured fallback, not measured'
        convert_basis = "configured fallback, not measured, #{convert_rate.round(2)} MiB/s"
        import_info = target_import_estimate(:prefer_previous_full => true)
        import_rate = import_info[:rate]

        export_time = total_data_mib / source_rate
        conversion_time = total_data_mib / convert_rate
        import_time = total_data_mib / import_rate
        confidence = regular_dry_run_confidence(source_info[:source],
                                                :configured_fallback,
                                                import_info[:source])

        puts 'OneSwap dry-run estimate'
        puts "VM: #{@options[:name]}"
        puts "Conversion mode: #{dry_run_conversion_mode}"
        puts "Disks: #{disks.length}"
        disks.each_with_index do |disk, index|
            puts "  disk #{index + 1}: provisioned #{format_mib(disk[:provisioned_mib])}, " \
                 "estimated data #{format_mib(disk[:estimated_data_mib])} " \
                 "(#{disk[:data_source]})"
        end
        puts "Total provisioned size: #{format_mib(total_provisioned_mib)}"
        puts "Estimated data size: #{format_mib(total_data_mib)}"
        puts
        puts 'Estimate basis:'
        puts "  source export: #{source_info[:basis]}"
        puts "  conversion: #{convert_basis}"
        puts "  target import: #{import_info[:basis]}"
        puts
        puts 'Rates:'
        puts "  source export: #{source_rate.round(2)} MiB/s (#{source_info[:label]})"
        puts "  conversion: #{convert_rate.round(2)} MiB/s (#{convert_rate_label})"
        puts "  target import: #{import_rate.round(2)} MiB/s (#{import_info[:label]})"
        puts
        puts 'Estimated phase durations:'
        puts "  export: #{format_duration(export_time)}"
        puts "  conversion: #{format_duration(conversion_time)}"
        puts "  import: #{format_duration(import_time)}"
        puts "  total: #{format_duration(export_time + conversion_time + import_time)}"
        puts "  confidence: #{confidence}"

        fallback_phases = []
        fallback_phases << 'source export' if source_info[:source] == :configured_fallback
        fallback_phases << 'conversion' if convert_rate_source == :configured_fallback
        unless fallback_phases.empty?
            puts
            puts "Warning: #{fallback_phases.join(' and ')} #{fallback_phases.length == 1 ? 'uses' : 'use'} configured fallback values unless previous matching regular conversion metrics are available."
        end
    end

    def run_delta_dry_run_estimate
        run_opennebula_import_benchmark if @options[:benchmark_import]

        delta_info = dry_run_delta_info

        puts 'OneSwap dry-run delta estimate'
        puts "VM: #{@options[:name]}"

        if delta_info.nil?
            puts 'Downtime estimate is not available. The remaining delta size is only known after the base phase has been completed.'
            return
        end

        delta_mib = delta_info[:delta_mib]
        source_rate = delta_info[:source_rate]
        apply_rate = delta_info[:apply_rate]
        import_rate = delta_info[:import_rate]
        copy_delta_time = delta_mib / source_rate
        apply_delta_time = delta_mib / apply_rate
        import_time = delta_info[:base_mib] ? delta_info[:base_mib] / import_rate : nil
        os_morph_time = delta_info[:os_morph_seconds]
        customization_time = delta_info[:customization_seconds]
        total_time = copy_delta_time + apply_delta_time + os_morph_time +
            customization_time + import_time.to_f

        puts "Current delta size: #{format_mib(delta_mib)} (#{delta_info[:delta_size_source]})"
        if delta_info[:base_mib]
            puts "Prepared base disk size: #{format_mib(delta_info[:base_mib])}"
        else
            puts 'Prepared base disk size: unavailable'
        end
        if delta_info[:delta_size_warning]
            puts
            puts delta_info[:delta_size_warning]
        end
        puts
        puts 'Estimate basis:'
        puts "  current delta size: #{delta_info[:delta_size_source]}"
        puts "  delta transfer: #{delta_info[:source_basis]}"
        puts "  delta apply: #{delta_info[:apply_basis]}"
        puts "  target import: #{delta_info[:import_basis]}"
        puts "  OS morph: #{delta_info[:os_morph_basis]}"
        puts "  guest customization: #{delta_info[:customization_basis]}"
        puts
        puts 'Rates:'
        puts "  source/delta transfer: #{source_rate.round(2)} MiB/s (#{delta_info[:source_rate_label]})"
        puts "  delta apply/conversion: #{apply_rate.round(2)} MiB/s (#{delta_info[:apply_rate_label]})"
        puts "  target import: #{import_rate.round(2)} MiB/s (#{delta_info[:import_rate_label]})"
        puts
        puts 'Estimated final phase:'
        puts "  copy remaining delta: #{format_duration(copy_delta_time)} (#{delta_info[:source_rate_label]})"
        puts "  apply delta to prepared disk: #{format_duration(apply_delta_time)} (#{delta_info[:apply_rate_label]})"
        puts "  virt-v2v-in-place / OS morph: #{format_duration(os_morph_time)} (#{delta_info[:os_morph_label]})"
        puts "  guest customization: #{format_duration(customization_time)} (#{delta_info[:customization_label]})"
        puts "  OpenNebula image import/copy: #{import_time ? format_duration(import_time) : 'not estimated'} (#{delta_info[:import_rate_label]})"
        puts '  template creation: negligible'
        puts "  total estimated downtime/import-ready time: #{format_duration(total_time)}"
        puts "  confidence: #{delta_info[:confidence]}"
        puts "  note: #{delta_info[:confidence_note]}" if delta_info[:confidence_note]
        puts
        puts 'Note: this is only an estimate. It does not run commit.'
        puts 'The optional import benchmark creates and deletes a temporary OpenNebula image.'
        puts 'Delta size can continue growing while the VM is running.'
        puts 'If you want to continue now, run:'
        puts "  oneswap convert #{@options[:name]} --delta --delta-commit"
        puts 'If you only wanted timing data and will migrate later, run:'
        puts "  oneswap convert #{@options[:name]} --delta --delta-cleanup"
        puts "Then run normal `oneswap convert #{@options[:name]} --delta` during the selected maintenance window."
        puts 'Do not leave the prepared snapshot active indefinitely, because the delta can grow very large.'
    end

    def dry_run_disk_metadata
        devices = @props['config'][:hardware][:device]
        devices.grep(RbVmomi::VIM::VirtualDisk).sort_by(&:key).map do |disk|
            provisioned_mib = virtual_disk_provisioned_mib(disk)
            allocated_mib = virtual_disk_allocated_mib(disk)

            {
                :provisioned_mib => provisioned_mib,
                :estimated_data_mib => allocated_mib || provisioned_mib,
                :data_source => allocated_mib ? 'allocated size from metadata' : 'provisioned size fallback'
            }
        end
    end

    def virtual_disk_provisioned_mib(disk)
        if disk.respond_to?(:capacityInBytes) && disk.capacityInBytes
            disk.capacityInBytes.to_f / (1024 * 1024)
        else
            disk.capacityInKB.to_f / 1024
        end
    end

    def virtual_disk_allocated_mib(disk)
        backing = disk.respond_to?(:backing) ? disk.backing : nil
        return nil unless backing && backing.respond_to?(:committed) && backing.committed

        backing.committed.to_f / (1024 * 1024)
    end

    def dry_run_known_delta_mib
        info = dry_run_delta_info
        info && info[:delta_mib]
    end

    def dry_run_delta_info
        client = ESXi::Client.new(get_esxi_host, @logger, esxi_client_options(:non_interactive => true))
        vm = client.get_vm_by_name(@options[:name])
        return nil unless vm

        state = vm.live2kvm_state(@options[:work_dir]) || {}
        delta_size = vm.live2kvm_current_delta_size(@options[:work_dir])
        if delta_size.nil?
            stored_bytes = state['delta_size_bytes'].to_i
            return nil unless stored_bytes > 0

            delta_size = {
                :bytes => stored_bytes,
                :source => 'stored prepare-time delta size',
                :paths => [],
                :refreshed => false,
                :warning => 'Warning: unable to refresh live snapshot delta size from ESXi; using stored prepare-time value.'
            }
        end

        delta_size[:paths].each do |path|
            @logger.debug "Dry-run delta extent path: #{path}"
        end

        measured_rate = state['base_transfer_mib_s'].to_f
        if measured_rate > 0
            source_rate = measured_rate
            source_rate_source = :prepare_measured
            source_rate_label = 'measured during prepare'
            source_basis = "measured during prepare, #{source_rate.round(2)} MiB/s"
        else
            source_rate = positive_float_option(:dry_run_source_export_mib_s)
            source_rate_source = :configured_fallback
            source_rate_label = 'configured fallback, not measured'
            source_basis = "configured fallback, not measured, #{source_rate.round(2)} MiB/s"
        end

        measured_apply_rate = state['base_convert_mib_s'].to_f
        if measured_apply_rate > 0
            apply_rate = measured_apply_rate
            apply_rate_source = :prepare_measured
            apply_rate_label = 'measured during prepare'
            apply_basis = "measured during prepare, #{apply_rate.round(2)} MiB/s"
        else
            apply_rate = positive_float_option(:dry_run_qcow2_conversion_mib_s)
            apply_rate_source = :configured_fallback
            apply_rate_label = 'configured fallback, not measured'
            apply_basis = "configured fallback, not measured, #{apply_rate.round(2)} MiB/s"
        end

        base_bytes = prepared_base_bytes(state)
        metrics = read_metrics
        import_info = target_import_estimate(:metrics => metrics, :prefer_previous_full => false)
        import_rate = import_info[:rate]
        import_rate_source = import_info[:source]
        import_rate_label = import_info[:label]
        import_basis = import_info[:basis]

        measured_os_morph = positive_metric(metrics['delta_os_morph_seconds'])
        if measured_os_morph
            os_morph_seconds = measured_os_morph
            os_morph_source = :previous_metrics
            os_morph_label = 'previous full run'
            os_morph_basis = "previous full run, #{format_duration(os_morph_seconds)}"
        else
            os_morph_seconds = positive_float_option(:dry_run_delta_os_morph_seconds)
            os_morph_source = :configured_fallback
            os_morph_label = 'configured fallback'
            os_morph_basis = "configured fallback, #{os_morph_seconds.round}s"
        end

        measured_customization = positive_metric(metrics['customization_seconds'])
        if measured_customization
            customization_seconds = measured_customization
            customization_source = :previous_metrics
            customization_label = 'previous full run'
            customization_basis = "previous full run, #{format_duration(customization_seconds)}"
        else
            customization_seconds = positive_float_option(:dry_run_guest_customization_seconds)
            customization_source = :configured_fallback
            customization_label = 'configured fallback'
            customization_basis = "configured fallback, #{customization_seconds.round}s"
        end

        confidence = estimate_confidence(
            source_rate_source,
            apply_rate_source,
            import_rate_source,
            os_morph_source,
            customization_source,
            base_bytes
        )
        confidence = 'low' unless delta_size[:refreshed]
        confidence_note = final_phase_fallback_note(os_morph_source, customization_source)

        {
            :delta_mib => delta_size[:bytes].to_f / (1024 * 1024),
            :delta_size_source => delta_size[:source],
            :delta_size_refreshed => delta_size[:refreshed],
            :delta_size_warning => delta_size[:warning],
            :source_rate => source_rate,
            :source_rate_source => source_rate_source,
            :source_rate_label => source_rate_label,
            :source_basis => source_basis,
            :apply_rate => apply_rate,
            :apply_rate_source => apply_rate_source,
            :apply_rate_label => apply_rate_label,
            :apply_basis => apply_basis,
            :import_rate => import_rate,
            :import_rate_source => import_rate_source,
            :import_rate_label => import_rate_label,
            :import_basis => import_basis,
            :base_mib => base_bytes && base_bytes.to_f / (1024 * 1024),
            :os_morph_seconds => os_morph_seconds,
            :os_morph_source => os_morph_source,
            :os_morph_label => os_morph_label,
            :os_morph_basis => os_morph_basis,
            :customization_seconds => customization_seconds,
            :customization_source => customization_source,
            :customization_label => customization_label,
            :customization_basis => customization_basis,
            :confidence => confidence,
            :confidence_note => confidence_note
        }
    rescue StandardError => e
        @logger.warn "Unable to read prepared delta size: #{e.message}"
        nil
    end

    def positive_metric(value)
        value = value.to_f
        value > 0 ? value : nil
    end

    def source_export_estimate(metrics: read_metrics)
        mode = dry_run_conversion_mode
        previous_rate = metrics['source_export_mib_s'].to_f
        previous_mode = metrics['source_export_mode']
        if previous_rate > 0 && previous_mode == mode
            label = "previous regular conversion: #{previous_rate.round(2)} MiB/s"
            return {
                :rate => previous_rate,
                :source => :previous_metrics,
                :label => label,
                :basis => label
            }
        end

        benchmark_rate = metrics['source_export_benchmark_mib_s'].to_f
        benchmark_mode = metrics['source_export_benchmark_mode']
        if benchmark_rate > 0 && benchmark_mode == mode
            size_mib = metrics['source_export_benchmark_bytes'].to_i.to_f / (1024 * 1024)
            seconds = metrics['source_export_benchmark_seconds'].to_f
            label = source_export_benchmark_label(size_mib, seconds)
            return {
                :rate => benchmark_rate,
                :source => :benchmark,
                :label => label,
                :basis => "#{label}, #{benchmark_rate.round(2)} MiB/s"
            }
        end

        rate = positive_float_option(:dry_run_source_export_mib_s)
        label = 'configured fallback, not measured'
        {
            :rate => rate,
            :source => :configured_fallback,
            :label => label,
            :basis => "#{label}, #{rate.round(2)} MiB/s"
        }
    end

    def target_import_estimate(metrics: read_metrics, prefer_previous_full: true)
        import_mode = opennebula_import_mode
        measured_import_rate = metrics['opennebula_import_mib_s'].to_f
        benchmark_import_rate = metrics['opennebula_import_benchmark_mib_s'].to_f
        measured_import_mode = metrics['opennebula_import_mode']
        benchmark_import_mode = metrics['opennebula_import_benchmark_mode']
        measured_import_mib = metrics['opennebula_import_bytes'].to_i.to_f / (1024 * 1024)
        measured_import_seconds = metrics['opennebula_import_seconds'].to_f
        benchmark_import_mib = metrics['opennebula_import_benchmark_bytes'].to_i.to_f / (1024 * 1024)
        benchmark_import_seconds = metrics['opennebula_import_benchmark_seconds'].to_f
        measured_import_matches = measured_import_rate > 0 &&
            measured_import_mode == import_mode &&
            measured_import_mode != 'http'
        benchmark_import_matches = benchmark_import_rate > 0 && benchmark_import_mode == import_mode

        if measured_import_matches && (prefer_previous_full || !benchmark_import_matches || benchmark_import_mib < 1024)
            label = previous_import_label(measured_import_mib, measured_import_seconds, measured_import_rate)
            return {
                :rate => measured_import_rate,
                :source => :previous_full_import,
                :label => label,
                :basis => "#{label}, #{measured_import_rate.round(2)} MiB/s"
            }
        end

        if benchmark_import_matches
            source = benchmark_import_mib >= 4096 ? :large_benchmark : :small_benchmark
            label = benchmark_import_label(benchmark_import_mib, benchmark_import_seconds, benchmark_import_rate)
            return {
                :rate => benchmark_import_rate,
                :source => source,
                :label => label,
                :basis => "#{label}, #{benchmark_import_rate.round(2)} MiB/s"
            }
        end

        if measured_import_matches
            label = previous_import_label(measured_import_mib, measured_import_seconds, measured_import_rate)
            return {
                :rate => measured_import_rate,
                :source => :previous_full_import,
                :label => label,
                :basis => "#{label}, #{measured_import_rate.round(2)} MiB/s"
            }
        end

        rate = positive_float_option(:dry_run_target_import_mib_s)
        label = "configured fallback, not measured for #{import_mode} mode"
        {
            :rate => rate,
            :source => :configured_fallback,
            :label => label,
            :basis => "#{label}, #{rate.round(2)} MiB/s"
        }
    end

    def regular_dry_run_confidence(source_rate_source, convert_rate_source, import_rate_source)
        source_measured = [:previous_metrics, :benchmark].include?(source_rate_source)
        convert_measured = convert_rate_source == :previous_metrics
        import_measured = [:previous_full_import, :large_benchmark, :small_benchmark].include?(import_rate_source)

        return 'high' if source_measured && convert_measured && import_measured
        return 'medium' if [source_measured, convert_measured, import_measured].any?

        'low'
    end

    def estimate_confidence(source_rate_source, apply_rate_source, import_rate_source,
                            os_morph_source, customization_source, base_bytes)
        prepare_measured = source_rate_source == :prepare_measured &&
            apply_rate_source == :prepare_measured
        import_strong = [:previous_full_import, :large_benchmark].include?(import_rate_source)
        import_usable = import_strong ||
            [:configured_fallback, :small_benchmark].include?(import_rate_source)
        final_phase_measured = os_morph_source == :previous_metrics &&
            customization_source == :previous_metrics
        final_phase_accounted = [:previous_metrics, :configured_fallback].include?(os_morph_source) &&
            [:previous_metrics, :configured_fallback].include?(customization_source) &&
            !base_bytes.nil?

        return 'high' if prepare_measured && import_strong && final_phase_measured && final_phase_accounted
        return 'medium' if prepare_measured && import_usable && final_phase_accounted

        'low'
    end

    def final_phase_fallback_note(os_morph_source, customization_source)
        fallback_phases = []
        fallback_phases << 'OS morph' if os_morph_source == :configured_fallback
        fallback_phases << 'guest customization' if customization_source == :configured_fallback
        return nil if fallback_phases.empty?

        "#{fallback_phases.join(' and ')} #{fallback_phases.size == 1 ? 'is' : 'are'} fallback estimates until a successful commit records real timings."
    end

    def previous_import_label(size_mib, seconds, rate)
        if size_mib > 0 && seconds > 0
            "previous full import: #{format_mib(size_mib)} in #{format_duration(seconds)}"
        else
            "previous full import: #{rate.round(2)} MiB/s"
        end
    end

    def benchmark_import_label(size_mib, seconds, rate)
        label = if size_mib > 0
                    "measured benchmark: #{format_mib(size_mib)}"
                else
                    "measured benchmark: #{rate.round(2)} MiB/s"
                end
        label += " in #{format_duration(seconds)}" if seconds > 0
        label
    end

    def source_export_benchmark_label(size_mib, seconds)
        label = if size_mib > 0
                    "measured VMware datastore download benchmark: #{format_mib(size_mib)}"
                else
                    'measured VMware datastore download benchmark'
                end
        label += " in #{format_duration(seconds)}" if seconds > 0
        label
    end

    def prepared_base_bytes(state)
        bytes = state['base_convert_bytes'].to_i
        return bytes if bytes > 0

        disks = state['disks']
        return nil unless disks.is_a?(Array)

        total = 0
        disks.each do |disk|
            path = disk['converted_raw_base_path']
            return nil unless path && File.exist?(path)

            total += File.size(path)
        end

        total > 0 ? total : nil
    end

    def metrics_file
        File.join(@options[:work_dir], 'oneswap-metrics.yaml')
    end

    def delta_state_file
        File.join(@options[:work_dir], "esxi_client-#{@options[:name]}", 'oneswap-delta-state.yaml')
    end

    def read_metrics
        return {} unless File.exist?(metrics_file)

        YAML.load_file(metrics_file) || {}
    rescue StandardError => e
        @logger.warn "Unable to read metrics file #{metrics_file}: #{e.message}"
        {}
    end

    def write_metrics(metrics, replace: false)
        data = replace ? metrics : read_metrics.merge(metrics)
        File.write(metrics_file, data.to_yaml)
    rescue StandardError => e
        @logger.warn "Unable to write metrics file #{metrics_file}: #{e.message}"
    end

    def persist_opennebula_import_metrics(import_metrics)
        return if import_metrics.empty?

        customization_seconds = import_metrics.sum {|m| m['customization_seconds'].to_f }
        metrics = { 'customization_seconds' => customization_seconds }

        reusable_import_metrics = import_metrics.select {|m| m['opennebula_import_seconds'].to_f > 0 }
        unless reusable_import_metrics.empty?
            bytes = reusable_import_metrics.sum {|m| m['opennebula_import_bytes'].to_i }
            seconds = reusable_import_metrics.sum {|m| m['opennebula_import_seconds'].to_f }
            metrics.merge!({
                               'opennebula_imports' => reusable_import_metrics,
                               'opennebula_import_bytes' => bytes,
                               'opennebula_import_seconds' => seconds,
                               'opennebula_import_mib_s' => mib_per_second(bytes, seconds),
                               'opennebula_import_mode' => opennebula_import_mode
                           })
        else
            current = read_metrics
            [
                'opennebula_imports',
                'opennebula_import_bytes',
                'opennebula_import_seconds',
                'opennebula_import_mib_s',
                'opennebula_import_mode'
            ].each {|key| current.delete(key) }

            write_metrics(current.merge(metrics), :replace => true)
            return
        end

        write_metrics(metrics)
    end

    def opennebula_import_mode
        @options[:http_transfer] ? 'http' : 'local-path'
    end

    def run_opennebula_import_benchmark
        if @options[:delta] && !File.exist?(delta_state_file)
            puts "Warning: import benchmark skipped because prepared delta state was not found at #{delta_state_file}."
            return
        end

        unless @options[:http_transfer]
            puts 'Warning: import benchmark skipped because http_transfer is not enabled.'
            puts 'HTTP benchmark metrics will not be reused for local-path mode.'
            puts 'The dry-run estimate will use matching local-path import metrics or dry_run_target_import_mib_s.'
            return
        end

        size_mib = positive_float_option(:dry_run_import_benchmark_size_mib)
        benchmark_dir = File.join(@options[:work_dir], 'dry-run-import-benchmark')
        FileUtils.mkdir_p(benchmark_dir)
        test_file = File.join(benchmark_dir, "#{@options[:name]}-import-benchmark.raw")
        bytes = (size_mib * 1024 * 1024).to_i

        puts "Running OpenNebula import benchmark with #{format_mib(size_mib)} temporary raw file."
        if size_mib < 1024
            puts 'Warning: benchmark size is below 1024 MiB. OpenNebula fixed allocation/datastore/polling overhead can dominate small imports and make the result pessimistic.'
        end
        puts 'Writing non-sparse benchmark file with real zero-filled data.'
        write_benchmark_file(test_file, bytes)

        server_thread = nil
        img = nil
        begin
            server_thread = start_http_transfer_server(benchmark_dir)
            path = "http://#{@options[:http_host]}:#{@options[:http_port]}/#{File.basename(test_file)}"
            img = OpenNebula::Image.new(OpenNebula::Image.build_xml, @client)
            img.add_element('//IMAGE', {
                                'NAME' => "#{@options[:name]}_dry_run_import_benchmark_#{Time.now.to_i}",
                'TYPE' => 'DATABLOCK',
                'PATH' => path,
                'PERSISTENT' => 'NO'
                            })

            ds_id = @options[:datastore].to_s.split(',').map(&:strip).map(&:to_i).first
            import_t0 = Time.now
            rc = img.allocate(img.to_xml, ds_id)
            if rc.class == OpenNebula::Error
                raise "OpenNebula image allocation failed: #{rc.message}"
            end

            puts 'Waiting for benchmark image to be ready.'
            img_wait_sec = @options[:img_wait] || 120
            unless img.wait_state('READY', img_wait_sec)
                raise "benchmark image did not become READY before #{img_wait_sec}s timeout"
            end

            seconds = Time.now - import_t0
            mib_s = mib_per_second(bytes, seconds)
            write_metrics({
                              'opennebula_import_benchmark_bytes' => bytes,
                              'opennebula_import_benchmark_size_mib' => size_mib,
                              'opennebula_import_benchmark_seconds' => seconds,
                              'opennebula_import_benchmark_mib_s' => mib_s,
                              'opennebula_import_benchmark_mode' => opennebula_import_mode,
                              'opennebula_import_benchmark_at' => Time.now.utc.iso8601,
                              'opennebula_import_benchmark_datastore' => ds_id
                          })

            puts "OpenNebula import benchmark: #{format_mib(size_mib)} in " \
                 "#{format_duration(seconds)} (#{mib_s.round(2)} MiB/s)"
        rescue StandardError => e
            puts "Warning: import benchmark failed: #{e.message}"
            puts 'The dry-run estimate will use previous import metrics or dry_run_target_import_mib_s.'
        ensure
            begin
                img.delete if img && img.id
            rescue StandardError => e
                puts "Warning: unable to delete benchmark image #{img.id}: #{e.message}" if img && img.id
            end
            if server_thread
                server_thread.kill
                server_thread.join
            end
            FileUtils.rm_f(test_file)
        end
    end

    def run_source_export_benchmark
        datastore_info = source_benchmark_datastore
        unless datastore_info
            puts 'Warning: source export benchmark skipped because no VM source datastore was found.'
            puts 'The dry-run estimate will use previous matching source metrics or dry_run_source_export_mib_s.'
            return
        end

        size_mib = positive_float_option(:dry_run_source_benchmark_size_mib).to_i
        remote_file = ".oneswap-source-benchmark-#{safe_benchmark_name(@options[:name])}-#{Time.now.to_i}.bin"
        remote_path = "#{ESXi::Client::DATASTORES_PATH}/#{datastore_info[:name]}/#{remote_file}"
        local_dir = File.join(@options[:work_dir], 'dry-run-source-benchmark')
        FileUtils.mkdir_p(local_dir)
        local_file = File.join(local_dir, remote_file)
        esxi_client = nil

        puts "Running source export benchmark for #{dry_run_conversion_mode} with #{format_mib(size_mib.to_f)} VMware datastore file."
        begin
            esxi_client = ESXi::Client.new(get_esxi_host, @logger, esxi_client_options(:non_interactive => true))
            unless esxi_client.create_zero_file(remote_path, size_mib)
                raise "unable to create temporary benchmark file on datastore #{datastore_info[:name]}"
            end

            t0 = Time.now
            datastore_info[:object].download(remote_file, local_file)
            seconds = Time.now - t0
            bytes = File.exist?(local_file) ? File.size(local_file) : 0
            mib_s = mib_per_second(bytes, seconds)
            raise 'source export benchmark downloaded no data' unless mib_s

            write_metrics({
                              'source_export_benchmark_bytes' => bytes,
                              'source_export_benchmark_seconds' => seconds,
                              'source_export_benchmark_mib_s' => mib_s,
                              'source_export_benchmark_mode' => dry_run_conversion_mode,
                              'source_export_benchmark_at' => Time.now.utc.iso8601,
                              'source_export_benchmark_datastore' => datastore_info[:name]
                          })

            puts "Source export benchmark: #{format_mib(bytes.to_f / (1024 * 1024))} in " \
                 "#{format_duration(seconds)} (#{mib_s.round(2)} MiB/s)"
        rescue StandardError => e
            puts "Warning: source export benchmark failed: #{e.message}"
            puts 'The dry-run estimate will use previous matching source metrics or dry_run_source_export_mib_s.'
        ensure
            FileUtils.rm_f(local_file)
            begin
                esxi_client.remove_files(remote_path) if esxi_client
            rescue StandardError => e
                @logger.warn "Unable to remove source benchmark file #{remote_path}: #{e.message}"
            end
        end
    end

    def source_benchmark_datastore
        disk = @props['config'][:hardware][:device].grep(RbVmomi::VIM::VirtualDisk).sort_by(&:key).first
        return nil unless disk && disk.respond_to?(:backing)

        datastore_name = disk.backing.fileName.to_s[/^\[(.+?)\]/, 1]
        datastore_obj = disk.backing.respond_to?(:datastore) ? disk.backing.datastore : nil
        return nil unless datastore_name && datastore_obj

        {
            :name => datastore_name,
            :object => datastore_obj
        }
    end

    def safe_benchmark_name(name)
        name.to_s.gsub(/[^A-Za-z0-9_.-]/, '_')
    end

    def start_http_transfer_server(document_root)
        Thread.new do
            server = WEBrick::HTTPServer.new({
                                                 :Port => @options[:http_port],
                :DocumentRoot => document_root,
                :RequestCallback => lambda {|_req, res|
                    res['Cache-Control'] = 'public, max-age=3600'
                },
                :MaxThreads => 8
                                             })

            trap('INT') { server.shutdown }
            server.start
        end.tap { sleep 0.2 }
    end

    def write_benchmark_file(path, bytes)
        chunk = "\0" * (1024 * 1024)
        remaining = bytes

        File.open(path, 'wb') do |file|
            while remaining > 0
                write_size = [remaining, chunk.bytesize].min
                file.write(write_size == chunk.bytesize ? chunk : chunk.byteslice(0, write_size))
                remaining -= write_size
            end
        end
    end

    def esxi_client_options(extra = {})
        {
            :user => @options[:esxi_user] || 'root',
            :password => @options[:esxi_pass]
        }.merge(extra)
    end

    def dry_run_conversion_mode
        return 'delta' if @options[:delta]
        return 'custom' if @options[:custom_convert]
        return 'hybrid' if @options[:hybrid]
        return 'ESXi virt-v2v' if @options[:esxi_ip]
        return 'vCenter VDDK virt-v2v' if @options[:vddk_path]

        'vCenter API virt-v2v'
    end

    def positive_float_option(name)
        value = @options[name].to_f
        raise "#{name} must be greater than zero" if value <= 0

        value
    end

    def format_mib(mib)
        if mib >= 1024
            "#{(mib / 1024).round(2)} GiB"
        else
            "#{mib.round(2)} MiB"
        end
    end

    def format_duration(seconds)
        if seconds < 60
            rounded = seconds.round(1)
            return rounded == rounded.to_i ? "#{rounded.to_i}s" : "#{rounded}s"
        end

        seconds = seconds.round
        hours = seconds / 3600
        minutes = (seconds % 3600) / 60
        secs = seconds % 60

        if hours > 0
            format('%dh %dm %ds', hours, minutes, secs)
        elsif minutes > 0
            format('%dm %ds', minutes, secs)
        else
            format('%ds', secs)
        end
    end

    def mib_per_second(bytes, seconds)
        return nil if bytes.to_i <= 0 || seconds.to_f <= 0

        (bytes.to_f / (1024 * 1024)) / seconds.to_f
    end

    # Create and run the qemu-img vmdk conversion
    # This uses qemu-img to convert an vmdk image to desired format (Default: qcow2).
    # Outputs a converted image location if success
    def convert_vmdk(vmdk_path, output_format: 'qcow2')
        raise "Input file not found: #{vmdk_path}" unless File.exist?(vmdk_path)

        puts "Converting disk #{vmdk_path} to #{output_format}..."
        base_name = File.basename(vmdk_path, '.vmdk')
        output_path = File.join(@options[:work_dir], 'conversions', "#{base_name}.#{output_format}")
        command = "qemu-img convert -O #{output_format} -p -S 4k -W #{vmdk_path} #{output_path}"
        t0 = Time.now
        success = system(command)
        duration = (Time.now - t0).round(2)
        raise "Failed to convert #{vmdk_path} to #{output_format}" unless success

        puts "Disk converted successfully in #{duration} seconds."
        return output_path
    end

    #
    # Return the ESXi hostname where the VM is placed based
    #
    # @return [String] ESXi hostname
    #
    def get_esxi_host
        key = 'runtime.host'
        if !@props.key?(key)
            @logger.error("Missing key #{key} on VM properties")
            return false
        end

        host_obj = @props[key]
        host_obj.name
        # ip = host_obj.config.network.vnic.map { |vnic| vnic.spec.ip.ipAddress }
    end

    def handle_v2v_error(line)
        @logger.debug(line)
        pass_list = [
            'unable to rebuild initrd',
            'unable to find any valid modprobe configuration file',
            'only Xen kernels are installed in this guest.',
            'not enough available inodes for conversion on',
            'not enough free space for conversion on filesystem',
            'could not write to the guest filesystem'
        ]
        error_list = [
            {
                :text  => 'inspection could not detect the source guest',
                :error => 'Could not find the guest OS inside the target machine.',
                :windows_hint => "For Windows guests this failure is usually caused by:\n" \
                    "  - NTFS system compression (CompactOS). Check inside the guest with\n" \
                    "    'compact /compactos:query'. Fix it with 'compact /compactos:never' and\n" \
                    "    a clean shutdown, or run\n" \
                    "    /usr/lib/one/oneswap/scripts/setup_ntfs_wof.sh\n" \
                    "    once on this conversion host.\n" \
                    "  - BitLocker encryption on the system volume.\n" \
                    '  - Hibernation / Fast Startup leaving the NTFS volume dirty.'
            },
            {
                :text  => 'nbdkit-vddk-plugin is not installed',
                :error => "nbdkit on this conversion host is missing the VDDK plugin\n" \
                          "(Debian/Ubuntu packages do not ship it). Run once on this host:\n" \
                          '  /usr/lib/one/oneswap/scripts/setup_nbdkit_vddk.sh'
            },
            {
                :text  => 'virt-v2v is unable to convert this guest type',
                :error => 'Unable to process this guest type.'
            },
            {
                :text  => 'unable to mount the disk image for writing',
                :error => 'Disk did not mount properly, try disabling Windows Hibernation or Fast Restart in this guest.'
            },
            {
                :text  => 'filesystem was mounted read-only, even though',
                :error => 'Disk mounted read-only, try disabling Windows Hibernation or Fast Restart in this guest and cleanly shut it down.'
            },
            {
                :text  => 'multi-boot operating systems are not supported',
                :error => 'Multi-boot Operating Systems are not currently supported.'
            },
            {
                :text  => 'libguestfs thinks this is not an installed operating',
                :error => 'Guest does not appear to be an installed OS, but rather a LiveCD or installer disk'
            },
            {
                :text  => 'inspection of the package database failed for this Linux',
                :error => 'Failed to inspect package database for this guest.'
            },
            {
                :text  => 'no installed kernel packages were found.',
                :error => 'Can\'t find kernel packages inside VM, please ensure kernel header packages are installed in this guest'
            }
        ]

        err = pass_list.detect {|e| line['message'].start_with?(e) }
        raise(err.red) if err

        err = error_list.detect {|e| line['message'].start_with?(e[:text]) }
        puts "DEBUG INFO: #{line['message']}".red
        raise("Unknown error occurred: #{line['message']}".bg_red) unless err

        msg = err[:error]
        # 'config' is a RbVmomi object on the vCenter path and a Hash on the
        # OVA path; both support [], neither is guaranteed to have #dig.
        config = @props.to_h['config']
        if err[:windows_hint] && config && config[:guestFullName].to_s.include?('Windows')
            msg = "#{msg}\n#{err[:windows_hint]}"
        end
        raise(msg.red)
    end

    def handle_stdout(line)
        @logger.debug(line)
        begin
            line = JSON.parse(line)
        rescue JSON::ParserError
            if line.start_with?(%r{^[0-9]+/[0-9]+$})
                print line
                return
            end
            return
        end
        print "\n"
        STDOUT.flush
        case line['type']
        when 'error'
            print "DEBUG INFO: #{line['message']}".bg_red
            handle_v2v_error(line)
        when 'warning'
            print "#{line['message']}".brown
        when 'message'
            print "#{line['message']}".green
            if line['message'].start_with?('Converting')
                print ', this may take a long time'.green
            elsif line['message'].start_with?('Copying disk')
                print ', this may take a long time'.green
                print "\n"
                @dotskip = true
            end
        when 'info'
            print line['message']
        when 'progress'
            print line['message'].bg_green
        else
            print "#{line}".bg_cyan
        end
        STDOUT.flush
    end

    def handle_stderr(line)
        return if line.start_with?('nbdkit: debug:')
        return if line.start_with?('nbdkit: curl[4]: debug:')

        prefixes = {
            'guestfsd: <= list_filesystems' => 'Inspecting filesystems, this can take several minutes'.green,
            'guestfsd: <= inspect_os' => 'Inspecting guest OS'.green,
            'mpstats:' => 'Gathering mountpoint stats and converting guest'.green,
            'commandrvg: /usr/sbin/update-initramfs' => 'Generating initramfs, this can take several minutes(20+) on many systems. Please be patient and do not interrupt the process.'.brown,
            'chroot: /sysroot: running \'librpm\'' => 'Querying RPMs with librpm, this can take a while'.green,
            'dracut: *** Creating image file' => 'Creating boot image with dracut'.green
        }

        line_prefix = prefixes.keys.detect {|e| line.start_with?(e) }

        if line_prefix
            print "\n#{prefixes[line_prefix]}"
            STDOUT.flush
        else
            if !@dotskip
                @last_dot_time ||= Time.now
                if Time.now - @last_dot_time >= 0.1 # Check if 100ms have elapsed since the last dot
                    print '.'
                    @last_dot_time = Time.now
                end
            end
            @logger.debug(line)
        end
    end

    def create_one_images(disks)
        puts 'Creating Images in OpenNebula'
        img_ids = []
        persistent_image = @options[:persistent_img] ? 'YES' : 'NO'
        t0 = Time.now
        import_metrics = []

        # Normalize datastore input to an array of integers
        datastores = @options[:datastore].to_s.split(',').map(&:strip).map(&:to_i)

        disks.each_with_index do |d, i|
            img = OpenNebula::Image.new(OpenNebula::Image.build_xml, @client)
            customization_t0 = Time.now
            guest_info = detect_distro(d)
            os_name = false
            begin
                if guest_info
                    package_injection(d, guest_info)
                    remove_vmtools_injection(d, guest_info)
                    os_name = guest_info['name']
                end
            rescue ConversionError
                raise
            rescue Exception => e
                if guest_info['name'] == 'windows'
                    puts 'Error with package injection. Conversion failed. Check that you have virtio and context packages in place.'.red
                    exit(-1)
                else
                    puts 'Error with package injection, converted VM may fail to boot'.red
                    puts "#{e.message}"
                end
            end
            customization_seconds = Time.now - customization_t0

            if @options[:http_transfer]
                path = "http://#{@options[:http_host]}:#{@options[:http_port]}/#{File.basename(d)}"
                puts "Using HTTP transfer for image #{i}: #{path}"
                server_thread = Thread.new do
                    server = WEBrick::HTTPServer.new({
                                                         :Port => @options[:http_port],
                        :DocumentRoot => File.dirname(d),
                        :RequestCallback => lambda {|_req, res|
                            res['Cache-Control'] = 'public, max-age=3600'
                        },
                        :MaxThreads => 8
                                                     })

                    trap('INT') { server.shutdown }

                    server.start
                end
            else
                path = d
            end

            img_name = "#{@options[:name]}_#{i}"
            img.add_element('//IMAGE', {
                                'NAME' => img_name,
                'TYPE' => guest_info ? 'OS' : 'DATABLOCK',
                'PATH' => path,
                'PERSISTENT' => persistent_image
                            })

            puts "Allocating image #{i} in OpenNebula"

            # Pick datastore index if available, else default to the first one
            ds_id = datastores[i] || datastores.first
            import_t0 = Time.now
            rc = img.allocate(img.to_xml, ds_id)

            if OpenNebula.is_error?(rc)
                raise "Failed to create OpenNebula image '#{img_name}':\n#{rc.message}"
            end

            chown_one_object(img, *resolve_one_ownership)

            img_wait_sec = @options[:img_wait] || 120
            puts 'Waiting for image to be ready. Timeout: ' + img_wait_sec.to_s + ' seconds.'

            img_wait_return = img.wait_state('READY', img_wait_sec)
            if img_wait_return == false
                puts 'Image did not become ready in time.'
                puts 'Image Short State: ' + img.short_state_str
            end

            ready_wait_seconds = Time.now - import_t0
            puts "OpenNebula image #{i} READY wait: #{format_duration(ready_wait_seconds)}"
            import_bytes = File.exist?(d) ? File.size(d) : 0

            if @options[:http_transfer]
                server_thread.kill
                server_thread.join
            end

            import_seconds = Time.now - import_t0
            import_mib_s = mib_per_second(import_bytes, import_seconds)
            if @options[:http_transfer]
                puts "HTTP transfer/server completion for image #{i}: #{format_duration(import_seconds)}"
                puts "Observed HTTP import timing for image #{i} is not persisted as a reusable metric; use --dry-run --benchmark-import for trusted HTTP import metrics."
            end
            if import_mib_s
                puts "OpenNebula import for image #{i}: #{format_mib(import_bytes.to_f / (1024 * 1024))} " \
                     "in #{format_duration(import_seconds)} (#{import_mib_s.round(2)} MiB/s)"
            end
            if @options[:http_transfer]
                import_metrics << {
                    'disk' => d,
                    'customization_seconds' => customization_seconds
                }
            else
                import_metrics << {
                    'disk' => d,
                    'opennebula_import_bytes' => import_bytes,
                    'opennebula_import_seconds' => import_seconds,
                    'opennebula_import_ready_seconds' => ready_wait_seconds,
                    'opennebula_import_mib_s' => import_mib_s,
                    'opennebula_import_mode' => opennebula_import_mode,
                    'customization_seconds' => customization_seconds
                }
            end

            img_ids.append({ :id => img.id, :os => os_name })
        end

        persist_opennebula_import_metrics(import_metrics)
        puts "Allocated images in OpenNebula in (#{(Time.now - t0).round(2)}s)".green

        img_ids
    end

    def hybrid_downloader(vc_disks)
        local_disks = []
        puts 'Downloading disks from vCenter storage to local disk'
        t0 = Time.now
        vc_disks.each_with_index do |d, i|
            # download each disk to the work dir
            remote_file = d[:backing][:fileName].sub(/^\[.+?\] /, '')
            remote_file = remote_file.sub(/\.vmdk$/, '-flat.vmdk')

            if remote_file.nil? or remote_file.empty?
                raise "Unable to determine remote path for disk #{i}.".red
            end

            local_file = @options[:work_dir] + '/transfers/' + @props['name'] + "-disk#{i}.vmdk"
            hybrid_datastore_download(d[:backing][:datastore], remote_file, local_file)
            local_disks.append(local_file)
        end

        if local_disks.empty?
            raise 'Unable to download any disks from vCenter storage.'.red
        end

        puts "Downloaded disks from vCenter storage to local disk in (#{(Time.now - t0).round(2)}s)".green

        local_disks
    end

    def hybrid_datastore_download(datastore, remote_file, local_file)
        stripes = (@options[:download_stripes] || 1).to_i
        return datastore.download(remote_file, local_file) if stripes <= 1

        http = datastore._connection.http
        scheme = http.use_ssl? ? 'https' : 'http'
        url = "#{scheme}://#{http.address}:#{http.port}" \
              "#{datastore.send(:mkuripath, remote_file)}"
        cookie = datastore._connection.cookie.to_s
        headers_file = Tempfile.new('oneswap-range-headers')
        probe_file = Tempfile.new('oneswap-range-probe')
        headers_file.close
        probe_file.close

        probe_ok = system('curl', '-k', '--noproxy', '*', '-f',
                          '-r', '0-1048575', '-D', headers_file.path,
                          '--max-filesize', '1048576',
                          '-o', probe_file.path, '-b', cookie, url)
        content_range = File.read(headers_file.path).scan(
            /^content-range:\s*bytes\s+0-(\d+)\/(\d+)\s*$/i
        ).last

        probe_end = content_range && content_range[0].to_i
        remote_size = content_range && content_range[1].to_i
        probe_size = File.size(probe_file.path)
        valid_range = remote_size && remote_size > 0 &&
                      probe_end == [1048575, remote_size - 1].min &&
                      probe_size == [1048576, remote_size].min

        unless probe_ok && valid_range
            puts 'HTTP Range unsupported; using single-stream hybrid download'
            return datastore.download(remote_file, local_file)
        end

        if stripes > remote_size
            raise "Unable to split #{remote_size} bytes into #{stripes} download stripes."
        end

        ranges = stripes.times.map do |i|
            first = (remote_size * i) / stripes
            last = ((remote_size * (i + 1)) / stripes) - 1
            [first, last]
        end
        part_files = ranges.each_index.map {|i| "#{local_file}.part#{i}" }

        puts "Using striped hybrid download (#{stripes} stripes)"
        ranges.each_with_index do |range, i|
            puts "Stripe #{i}: bytes #{range[0]}-#{range[1]}"
        end

        pids = []
        begin
            ranges.each_with_index do |range, i|
                pids << Process.spawn('curl', '-k', '--noproxy', '*', '-f',
                                      '-r', "#{range[0]}-#{range[1]}",
                                      '-o', part_files[i], '-b', cookie, url)
            end
            failed = []
            pids.each_with_index do |pid, i|
                _waited_pid, status = Process.wait2(pid)
                failed << i unless status.success?
            end

            unless failed.empty?
                raise "Striped hybrid download failed for stripe(s): #{failed.join(', ')}."
            end

            # TODO: Write ranges into a preallocated output file by offset to
            # avoid temporary double disk usage.
            File.open(local_file, 'wb') do |output|
                part_files.each do |part_file|
                    File.open(part_file, 'rb') do |part|
                        IO.copy_stream(part, output)
                    end
                end
            end

            assembled_size = File.size(local_file)
            if assembled_size != remote_size
                FileUtils.rm_f(local_file)
                raise "Striped hybrid download size mismatch: expected #{remote_size}, " \
                      "got #{assembled_size}."
            end
        ensure
            pids.each do |pid|
                Process.wait(pid)
            rescue Errno::ECHILD
                nil
            end
            FileUtils.rm_f(part_files)
        end
        puts "Assembled striped hybrid download (#{assembled_size} bytes)"
    ensure
        headers_file&.unlink
        probe_file&.unlink
    end

    def build_hybrid_xml(disks)
        domain_xml = <<-XML
          <domain type='kvm'>
            <name>#{@options[:name]}</name>
            <memory unit='KiB'>1048576</memory>
            <vcpu>2</vcpu>
            <os>
              <type>hvm</type>
              <boot dev='hd'/>
            </os>
            <features>
              <acpi/>
              <apic/>
              <pae/>
            </features>
            <devices>
        XML
        suffix = 'a'
        disks.each_with_index do |d, _i|
            disk_xml = <<-XML
              <disk type='file' device='disk'>
                <source file='#{d}'/>
                <target dev='sd#{suffix}' bus='scsi'/>
              </disk>
            XML
            domain_xml << disk_xml
            suffix = next_suffix(suffix)
        end
        domain_xml << '</devices></domain>'
        domain_xml
    end

    def run_custom_conversion
        if !@options[:hybrid]
            vmdks = []
            vc_disks = @props['config'][:hardware][:device].grep(RbVmomi::VIM::VirtualDisk).sort_by(&:key)
            puts 'Downloading disks from vCenter storage to local disk'
            vc_disks.each_with_index do |d, i|
                # download each disk to the work dir
                remote_file = d[:backing][:fileName].sub(/^\[.+?\] /, '').sub(/\.vmdk$/,
                                                                              '-flat.vmdk')
                local_file = @options[:work_dir] + '/conversions/' + @props['name'] + "-disk#{i}"
                d[:backing][:datastore].download(remote_file, local_file + '.vmdk')
                vmdks.append(local_file)
                puts "Downloaded disk ##{i}."
            end
        else
            vmdks = Dir.glob("#{@options[:work_dir]}/transfers/*.vmdk").map {|f| f.chomp('.vmdk') }
        end

        puts 'Converting disks locally'
        local_disks = []
        vmdks.each_with_index do |d, i|
            # -p to show Progress, -S 4k to set Sparse Cluster Size to 4kb, -W for out of order writes
            t0 = Time.now
            command = 'qemu-img convert'\
                      " -O #{@options[:format]}"\
                      ' -p -S 4k -W'\
                      " #{d}.vmdk #{d}.#{@options[:format]}"
            system(command) # don't need to interact or anything, just display the output straight.
            puts "Disk #{i} converted in #{(Time.now - t0).round(2)} seconds. Deleting vmdk file".green
            # puts "that's a lie, not deleting the vmdk".bg_blue
            command = "rm -f #{d}.vmdk"
            system(command)
            local_disks.append("#{d}.#{@options[:format]}")
        end

        create_one_images(local_disks)
    end

    def get_vcenter_nic_info
        con_ops = connection_options('vm', @options)
        vi_client = RbVmomi::VIM.connect(con_ops)
        properties = [
            'name',
            'key'
        ]
        distributed_networks = get_objects(vi_client, 'DistributedVirtualPortgroup', properties)

        if distributed_networks.empty?
            @logger.warn 'Could not find DistributedVirtualPortgroup objects in vCenter. '\
                         'NICs backed by standard vSwitch will still be discovered.'
        end

        network_types = [
            RbVmomi::VIM::VirtualVmxnet,
            RbVmomi::VIM::VirtualVmxnet2,
            RbVmomi::VIM::VirtualVmxnet3
        ]
        # Get and sort the network interfaces
        vc_nics = @props['config'][:hardware][:device].select do |d|
            network_types.any? {|nt| d.is_a?(nt) }
        end.sort_by(&:key)

        nic_backing = vc_nics.map do |n|
            if n[:backing].is_a? RbVmomi::VIM::VirtualEthernetCardDistributedVirtualPortBackingInfo
                ds_network = distributed_networks.find do |dn|
                    dn.to_hash['key'] == n[:backing][:port][:portgroupKey]
                end
                [n[:key], ds_network ? ds_network.to_hash['name'] : nil]
            else
                [n[:key], n[:backing][:network][:name]]
            end
        end.compact.to_h

        return vc_nics, nic_backing
    end

    def add_one_nics(vm_template, vc_nics, vc_nic_backing)
        netmap = {}
        one_networks = OpenNebula::VirtualNetworkPool.new(@client)
        one_networks.info
        one_networks.each do |n|
            n.info
            next unless n['//VNET/TEMPLATE/VCENTER_NETWORK_MATCH']

            netmap[n['//VNET/TEMPLATE/VCENTER_NETWORK_MATCH']] = n['//VNET/ID']
        end

        if @options[:network] && vc_nics.length > 0
            networks = @options[:network].to_s.split(',').map(&:strip).map(&:to_i)
            if networks.length == 1
                networks = Array.new(vc_nics.length, networks.first)
            elsif networks.length != vc_nics.length
                raise "Error: Number of networks (#{networks.length}) must match number of NICs (#{vc_nics.length})"
            end

            puts "Adding #{vc_nics.length} NICs, assigning networks from #{@options[:network]}"
            nic_number = 0
            vc_nics.each_with_index do |n, index|
                guest_network = @props['guest.net'].find {|gn| gn[:deviceConfigId] == n[:key] }
                assigned_network = networks[index]
                net_templ = { 'NIC' => { 'NETWORK_ID' => "#{assigned_network}" } }

                if !@options[:skip_mac]
                    puts "Adding MAC address to NIC##{nic_number}"
                    net_templ['NIC']['MAC'] = n[:macAddress]
                else
                    puts "Skipping MAC address for NIC##{nic_number}"
                end

                if netmap.has_key?(vc_nic_backing[n[:key]])
                    # VCENTER_NETWORK_MATCH attribute in OpenNebula network maps this vCenter network
                    net_templ['NIC']['NETWORK_ID'] = netmap[vc_nic_backing[n[:key]]]
                    net_templ['NIC']['NETWORK'] = vc_nic_backing[n[:key]]
                elsif @options[:network]
                    # User explicitly specified --network as fallback; use it directly
                    puts "No VCENTER_NETWORK_MATCH found for '#{vc_nic_backing[n[:key]]}'. Using assigned network: #{assigned_network}"
                    net_templ['NIC']['NETWORK_ID'] = "#{assigned_network}"
                else
                    # No explicit fallback: try matching by OpenNebula network name
                    network_info_found = false
                    one_networks.each do |on_network|
                        network_info = on_network.to_hash
                        next unless network_info.include?('VNET') && network_info['VNET']['NAME'] == vc_nic_backing[n[:key]]

                        network_info_found = true
                        net_templ['NIC']['NETWORK_ID'] = network_info['VNET']['ID']
                        net_templ['NIC']['NETWORK'] = vc_nic_backing[n[:key]]
                        break
                    end
                    unless network_info_found
                        puts "Could not find OpenNebula network matching '#{vc_nic_backing[n[:key]]}'. NIC will be added without a network assignment.".brown
                    end
                end

                if !guest_network
                    puts "Found network '#{vc_nic_backing[n[:key]]}' but no guest network information. Adding blank NIC."
                    vm_template.add_element('//VMTEMPLATE', net_templ)
                    next
                end

                if @options[:skip_ip] || !guest_network[:ipConfig]
                    vm_template.add_element('//VMTEMPLATE', net_templ)
                    puts "Added NIC##{nic_number} to network #{net_templ['NIC']['NETWORK_ID']}"
                else
                    netkey = 'NIC_ALIAS'
                    guest_network[:ipConfig][:ipAddress].each do |ip|
                        net_templ = { netkey => @options[:skip_mac] ? {} : { 'MAC' => n[:macAddress] } }
                        net_templ[netkey]['NETWORK_ID'] =
                            netmap[guest_network[:network]] || "#{assigned_network}"

                        if ip[:ipAddress]
                            ipv = "#{ip_version(ip[:ipAddress])}"
                        else
                            puts 'Skipping address range creation'
                            next
                        end
                        net_templ[netkey]['PARENT'] =
                            "NIC#{nic_number}" if netkey == 'NETWORK_ALIAS'
                        ar_template = "AR=[TYPE=#{ipv},SIZE=1,IP=#{ip[:ipAddress]}]"
                        print "Creating AR for IP #{ip[:ipAddress]}..."
                        xml = OpenNebula::VirtualNetwork.build_xml(net_templ[netkey]['NETWORK_ID'])
                        vn  = OpenNebula::VirtualNetwork.new(xml, @client)
                        rc = vn.add_ar(ar_template)
                        if rc.nil?
                            puts 'Success'.green
                            net_templ[netkey]['IP'] = ip[:ipAddress]
                            vm_template.add_element('//VMTEMPLATE', net_templ)
                            puts "Added NIC##{nic_number} to network #{net_templ['NIC']['NETWORK_ID']}"
                            netkey = 'NETWORK_ALIAS'
                        else
                            puts "Failed. rc = #{rc.message}".red
                        end
                    end
                end
                nic_number += 1
            end
        elsif vc_nics.length > 0
            puts 'You will need to create NICs and assign networks in the VM Template in OpenNebula manually.'
        end
        vm_template
    end

    # Disable VMware Tools services in the offline Windows registry so they
    # never start during the first boot (which trigger the error dialog
    # before the firstboot cleanup script runs).
    VMTOOLS_SERVICES_TO_DISABLE = %w[VMTools VGAuthService].freeze
    VMTOOLS_CONTROL_SETS = %w[ControlSet001 ControlSet002].freeze

    def windows_control_sets_for_disk(disk)
        cmd = "virt-win-reg #{disk} 'HKLM\\SYSTEM\\Select'"
        stdout, stderr, status = Open3.capture3(cmd)

        unless status.success?
            @logger.warn("Could not read HKLM\\SYSTEM\\Select, using fallback control sets: #{stderr.to_s.strip}")
            return VMTOOLS_CONTROL_SETS
        end

        sets = stdout.each_line.filter_map do |line|
            match = line.match(/^"(Current|Default|LastKnownGood)"=dword:([0-9a-fA-F]+)/)
            next unless match

            index = match[2].to_i(16)
            next if index <= 0

            format('ControlSet%03d', index)
        end

        sets = (sets + ['ControlSet001']).uniq
        @logger.info("Detected control sets for offline service disable: #{sets.join(', ')}")
        sets
    end

    def disable_vmtools_services_offline(disk)
        puts 'Disabling VMware Tools services in offline registry...'
        @logger.info('Attempting to disable VMware Tools services offline')

        # Try virt-win-reg first
        @logger.info('Trying virt-win-reg method...')
        if disable_vmtools_via_virt_win_reg(disk)
            @logger.info('Successfully disabled services via virt-win-reg')
            puts 'VMware Tools services disabled (virt-win-reg)'.green
            return
        end

        # Fall back to guestfish
        @logger.info('virt-win-reg failed or not available, falling back to guestfish')
        puts 'virt-win-reg failed, trying guestfish...'.brown
        unless disable_vmtools_via_guestfish(disk)
            @logger.warn('Both virt-win-reg and guestfish failed to disable services')
            puts 'Warning: could not disable VMware Tools services offline. ' \
                 'The error dialog may still appear on first boot.'.brown
            return false
        end
        @logger.info('Successfully disabled services via guestfish')
        puts 'VMware Tools services disabled (guestfish)'.green
    end

    def disable_vmtools_via_virt_win_reg(disk)
        control_sets = windows_control_sets_for_disk(disk)
        applied_sets = []

        control_sets.each do |control_set|
            reg_entries = VMTOOLS_SERVICES_TO_DISABLE.map do |svc|
                "[HKEY_LOCAL_MACHINE\\SYSTEM\\#{control_set}\\Services\\#{svc}]\n" \
                "\"Start\"=dword:00000004\n"
            end.join("\n")

            reg_content = "Windows Registry Editor Version 5.00\n\n#{reg_entries}"

            Tempfile.create(['disable_vmtools', '.reg']) do |f|
                f.write(reg_content)
                f.flush

                @logger.debug("Registry file content for #{control_set}:\n#{reg_content}")
                @logger.info("Running virt-win-reg for #{control_set}: #{VMTOOLS_SERVICES_TO_DISABLE.join(', ')}")

                cmd = "virt-win-reg --merge #{disk} #{Shellwords.escape(f.path)}"
                @logger.debug("virt-win-reg command: #{cmd}")

                stdout, stderr, status = Open3.capture3(cmd)

                @logger.debug("virt-win-reg stdout (#{control_set}): #{stdout}")
                @logger.debug("virt-win-reg stderr (#{control_set}): #{stderr}")
                @logger.debug("virt-win-reg exit code (#{control_set}): #{status.exitstatus}")

                if status.success?
                    applied_sets << control_set
                else
                    error_msg = stderr.to_s.strip.empty? ? stdout.to_s.strip : stderr.to_s.strip
                    @logger.warn("virt-win-reg failed for #{control_set} with exit code #{status.exitstatus}: #{error_msg}")
                    puts "  virt-win-reg error for #{control_set} (exit #{status.exitstatus}): #{error_msg}".brown if error_msg
                end
            end
        end

        if applied_sets.empty?
            false
        else
            @logger.info("VMware Tools services disabled via virt-win-reg for: #{applied_sets.join(', ')}")
            true
        end
    end

    def disable_vmtools_via_guestfish(disk)
        @logger.info("Attempting to disable services via guestfish + hivexregedit")
        control_sets = windows_control_sets_for_disk(disk)
        
        guestfish_script = <<-GUESTFISH
add #{disk}
run
GUESTFISH

        control_sets.product(VMTOOLS_SERVICES_TO_DISABLE).each do |control_set, svc|
            guestfish_script += "download /Windows/System32/config/SYSTEM /tmp/SYSTEM_orig\n"
            guestfish_script += "! hivexregedit --merge /tmp/SYSTEM_orig HKEY_LOCAL_MACHINE\\\\SYSTEM\\\\#{control_set}\\\\Services\\\\#{svc} Start dword 0x00000004\n"
            guestfish_script += "upload /tmp/SYSTEM_orig /Windows/System32/config/SYSTEM\n"
        end

        @logger.debug("Guestfish script:\n#{guestfish_script}")

        Tempfile.create(['disable_vmtools', '.gf']) do |f|
            f.write(guestfish_script)
            f.flush
            
            cmd = "guestfish -f #{Shellwords.escape(f.path)}"
            @logger.info("Running guestfish + hivexregedit to disable services: #{VMTOOLS_SERVICES_TO_DISABLE.join(', ')}")
            @logger.debug("guestfish command: #{cmd}")
            
            stdout, stderr, status = Open3.capture3(cmd)
            
            @logger.debug("guestfish output: #{stdout}")
            @logger.debug("guestfish stderr: #{stderr}")
            @logger.debug("guestfish exit code: #{status.exitstatus}")
            
            if status.success?
                @logger.info('VMware Tools services disabled via guestfish + hivexregedit')
                return true
            else
                error_msg = stderr.to_s.strip.empty? ? stdout.to_s.strip : stderr.to_s.strip
                error_msg = 'Unknown guestfish error' if error_msg.to_s.strip.empty?
                @logger.warn("guestfish failed with exit code #{status.exitstatus}: #{error_msg}")
                puts "  guestfish error (exit #{status.exitstatus}): #{error_msg}".brown if error_msg
                return false
            end
        end
    end

    # Remove VMWare Tools injection from the VM
    def remove_vmtools_command(disk, osinfo, script_path)
        if osinfo['name'] == 'windows'
            script_name = File.basename(script_path)
            real_script_path = File.realpath(script_path)
            # Disable VMware services on first boot (before they start)
            # Prevent error dialog box from appearing
            disable_svc_cmd = 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ' \
                '"sc config VMTools start=disabled; sc config VGAuthService start=disabled; sc stop VMTools; sc stop VGAuthService" 2>$null'
            
            'virt-customize -q -a ' + disk +
            ' --mkdir /Temp' +
            " --copy-in #{Shellwords.escape(real_script_path)}:/Temp" +
            " --firstboot-command '#{disable_svc_cmd}'" +
            " --firstboot-command 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\Temp\\#{script_name}'"
        else
            "virt-customize -q -a #{disk} --firstboot '#{script_path}'"
        end
    end

    def remove_vmtools_injection(disk, osinfo)
        return unless @options[:remove_vmtools]

        puts 'Starting VMWare Tools Removal script injection...'
        if osinfo['name'] == 'windows'
            default_path = "#{SCRIPTS_LOCATION}/vmware_tools_removal.ps1"
        else
            default_path = "#{SCRIPTS_LOCATION}/vmware_tools_removal.sh"
        end
        script_path = File.exist?(default_path) ? default_path : nil
        unless script_path && File.exist?(script_path)
            puts 'Unable to find vmware_tools_removal script, please remove VMWare Tools manually.'
            return
        end
        cmd = remove_vmtools_command(disk, osinfo, script_path)
        _stdout, status = run_cmd_report(cmd, true)
        if !status.success?
            puts 'Remove VMWare tools injection failed somehow, please remove VMWare Tools manually.'
            return
        end
        disable_vmtools_services_offline(disk) if osinfo['name'] == 'windows'
        puts 'VMware Tools removal script staged for first boot. Check the guest log if removal fails.'.green
    end

    # Warn when the conversion host cannot read CompactOS/WOF-compressed NTFS
    #
    # @param plugin_globs  [Array<String>] globs for the ntfs-3g plugin .so
    # @param overlay_globs [Array<String>] globs for the supermin.d overlay
    # @param env           [Hash] environment (injectable for tests)
    # @return [Boolean] true when a warning was printed
    def warn_if_wof_support_missing(
        plugin_globs: ['/usr/lib/*/ntfs-3g/ntfs-plugin-80000017.so',
                       '/usr/lib64/ntfs-3g/ntfs-plugin-80000017.so'],
        overlay_globs: ['/usr/lib/*/guestfs/supermin.d/zz-ntfs-wof.tar.gz'],
        env: ENV
    )
        return false unless env['LIBGUESTFS_PATH'].to_s.empty?
        return false if plugin_globs.any? {|g| !Dir.glob(g).empty? }
        return false if overlay_globs.any? {|g| !Dir.glob(g).empty? }

        puts 'Warning: this host cannot read CompactOS-compressed NTFS. Windows'.brown
        puts 'guests using CompactOS will fail inspection. Run once on this host:'.brown
        puts '  /usr/lib/one/oneswap/scripts/setup_ntfs_wof.sh'.brown
        true
    end

    # Fail fast when --vddk is requested but nbdkit lacks the VDDK plugin.
    #
    # @param plugin_globs [Array<String>] globs for nbdkit-vddk-plugin.so
    # @return [void]
    def check_nbdkit_vddk_plugin!(
        plugin_globs: ['/usr/lib/*/nbdkit/plugins/nbdkit-vddk-plugin.so',
                       '/usr/lib64/nbdkit/plugins/nbdkit-vddk-plugin.so',
                       '/usr/local/lib/nbdkit/plugins/nbdkit-vddk-plugin.so']
    )
        return if plugin_globs.any? {|g| !Dir.glob(g).empty? }

        msg = "nbdkit on this conversion host is missing the VDDK plugin, which is\n" \
              "required for --vddk transfers (Debian/Ubuntu packages do not ship it).\n" \
              "Run once on this host:\n" \
              "  /usr/lib/one/oneswap/scripts/setup_nbdkit_vddk.sh\n" \
              'or pass --skip-prechecks to bypass this check.'
        raise(msg.red)
    end

    def effective_dev_prefix(os_name)
        if @options[:dev_prefix]
            @options[:dev_prefix].to_s
        elsif os_name == 'windows'
            'vd'
        end
    end

    def virtio_path_configured?
        return false unless @options[:virtio_path]

        !@options[:virtio_path].to_s.strip.empty?
    end

    def warn_if_windows_virtio_iso_missing(os_name)
        return false unless os_name == 'windows'
        return false unless effective_dev_prefix(os_name) == 'vd'
        return false if virtio_path_configured?

        puts 'Warning: Windows guest will use VirtIO block devices, but no VirtIO driver ISO is configured.'.brown
        puts 'The converted VM may fail to boot. Configure virtio_path in oneswap.yaml or provide the existing VirtIO CLI option.'.brown
        true
    end

    # Run a OpenNebula system prechecks:
    #
    # - Check if there is enough space in the datastore
    # - Check if the Image name already exists
    # - Check if the VM Template name already exists
    # - Check if the OpenNebula network exists
    #
    def one_prechecks(vm)
        puts 'Running OpenNebula prechecks...'
        config = @props.to_h['config']
        if config && config[:guestFullName].to_s.include?('Windows')
            warn_if_wof_support_missing
            warn_if_windows_virtio_iso_missing('windows')
        end
        check_nbdkit_vddk_plugin! if @options[:vddk_path]
        ## Precheck datastore space (check if it will be enough space for the image)
        vm_obj = vm.obj
        vm_size_kb = 0

        vm_obj.config.hardware.device.each do |device|
            if device.is_a?(RbVmomi::VIM::VirtualDisk)
                vm_size_kb += device.capacityInKB
            end
        end
        vm_size_gb = vm_size_kb.to_f / (1024 * 1024)

        ds_target_id = @options[:datastore].to_i
        one_datastores = OpenNebula::DatastorePool.new(@client)
        one_datastores.info
        one_datastores.each do |ds|
            next unless ds.id == ds_target_id

            ds.info
            available_mb = ds['FREE_MB'].to_i
            available_kb = available_mb * 1024
            available_gb = available_mb.to_f / 1024
            if vm_size_kb > available_kb
                raise "Not enough space in the datastore. Available: #{available_gb.round(2)} GB, VM Size: #{vm_size_gb.round(2)} GB".red
            end
        end

        ## Precheck image name (check if it exists)
        one_images = OpenNebula::ImagePool.new(@client)
        one_images.info
        one_images.each do |i|
            if i.name =~ /^#{Regexp.escape(@options[:name])}_\d+$/ && ['OS',
                                                                       'DATABLOCK'].include?(i.type_str)
                raise "Image with name #{@options[:name]}_{id} already exists.".red
            end
        end

        ## Precheck VM Template name (check if it exists)
        one_templates = OpenNebula::TemplatePool.new(@client)
        one_templates.info
        one_templates.each do |t|
            if t.name == @options[:name]
                raise "VM Template with name #{@options[:name]} already exists.".red
            end
        end

        ## Precheck OpenNebula Networks (check if exists)
        return unless @options[:network]

        one_networks = OpenNebula::VirtualNetworkPool.new(@client)
        one_networks.info
        return if one_networks.any? {|n| n.id == @options[:network].to_i }

        raise "Network with ID #{@options[:network]} does not exist.".red
    end

    # General method to list vCenter objects
    #
    # @param options [Hash] User CLI options
    # @param object  [Hash] Object Type
    def list(options)
        apply_verbosity(options)
        case options[:object]
        when 'datacenters'
            list_datacenters(options)
        when 'clusters'
            list_clusters(options)
        when 'vms'
            list_vms(options)
        else
            raise 'Invalid object type for listing'.brown
        end
    end

    # General wrapper to fix options and handle fatal errors
    #
    # @param name    [Hash] Object Name
    # @param options [Hash] User CLI options
    def convert(name, options)
        apply_verbosity(options)
        check_one_connectivity
        if !Dir.exist?(options[:work_dir])
            raise 'Provided working directory '\
                  "#{options[:work_dir]} doesn't exist"
        end
        options[:name] = name
        options[:work_dir] << "/#{name}"
        @options = options
        conv_path = "#{@options[:work_dir]}/conversions/"
        tran_path = "#{@options[:work_dir]}/transfers/"
        begin
            Dir.mkdir(@options[:work_dir]) unless Dir.exist?(@options[:work_dir])
            Dir.mkdir(conv_path) unless Dir.exist?(conv_path)
            Dir.mkdir(tran_path) unless Dir.exist?(tran_path)

            password_file = File.open("#{@options[:work_dir]}/vpassfile", 'w')
            password_file.print @options[:vpass]
            password_file.chmod(0o600)
            password_file.close

            password_file = File.open("#{@options[:work_dir]}/esxpassfile", 'w')
            password_file.print @options[:esxi_pass]
            password_file.chmod(0o600)
            password_file.close

            convert_vm
        rescue StandardError => e
            STDERR.puts "Error: #{e.message}".red
            @logger.error "Conversion of '#{name}' failed: #{e.message}"
            raise ConversionError, e.message
        ensure
            password_file.close unless password_file.nil? or password_file.closed?
            cleanup_all
        end
    end

    # Read and parse a VM list file for batch conversion.
    # Empty lines and lines starting with # are ignored.
    #
    # @param path [String] Path to the VM list file
    # @return [Array<String>] VM names
    def read_vm_list(path)
        raise "VM list file not found: #{path}" unless File.exist?(path)

        File.readlines(path, chomp: true)
            .map(&:strip)
            .reject {|l| l.empty? || l.start_with?('#') }
    end

    # Core sequential batch loop over an array of VM names.
    # Called by both convert_batch (--vm-list) and directly for --vms.
    #
    # @param vm_names [Array<String>] VM names to convert
    # @param options  [Hash]          CLI options shared across all VMs
    # @return [Integer] 0 if all VMs succeeded, 1 if any failed
    def convert_vm_list(vm_names, options)
        total   = vm_names.size
        success = []
        failed  = []

        puts "\nBatch conversion: #{total} VM(s) queued.\n"

        vm_names.each_with_index do |name, idx|
            puts "\n[#{idx + 1}/#{total}] Converting: #{name}".bold
            begin
                vm_opts = options.dup
                vm_opts[:work_dir] = options[:work_dir].dup
                convert(name, vm_opts)
                success << name
            rescue StandardError => e
                if e.is_a?(ConversionError)
                    STDERR.puts "  [#{idx + 1}/#{total}] FAILED #{name}".red
                else
                    STDERR.puts "  [#{idx + 1}/#{total}] FAILED #{name}: #{e.message}".red
                end
                failed << name
            end
        end

        puts "\n#{'─' * 50}"
        puts 'Batch conversion summary:'.bold
        puts "  Total VMs requested:   #{total}"
        puts "  Successful conversions: #{success.size}".green
        if failed.empty?
            puts '  Failed conversions:  0'.green
        else
            puts "  Failed conversions:  #{failed.size}".red
            puts '  Failed VM names:'.red
            failed.each {|n| puts "    - #{n}".red }
        end
        puts '─' * 50

        failed.empty? ? 0 : 1
    end

    # Convert a batch of VMs listed in a file (--vm-list).
    #
    # @param list_file [String] Path to the VM list file
    # @param options   [Hash]   CLI options shared across all VMs
    # @return [Integer] 0 if all VMs succeeded, 1 if any failed
    def convert_batch(list_file, options)
        vm_names = read_vm_list(list_file)

        if vm_names.empty?
            raise "VM list file contains no valid entries: #{list_file}"
        end

        convert_vm_list(vm_names, options)
    end

    # Import a VM from file (OVA, folder with files)
    #
    # @param name    [Hash] Object Name
    # @param options [Hash] User CLI options
    def import(options)
        apply_verbosity(options)
        source = options[:ova] || options[:vmdk]
        name = File.basename(source, File.extname(source))
        check_one_connectivity
        if !Dir.exist?(options[:work_dir])
            raise 'Provided working directory '\
                  "#{options[:work_dir]} doesn't exist"
        end
        options[:name] = name
        options[:work_dir] << "/#{name}"
        @options = options
        conv_path = "#{@options[:work_dir]}/conversions/"
        tran_path = "#{@options[:work_dir]}/transfers/"
        begin
            Dir.mkdir(@options[:work_dir]) unless Dir.exist?(@options[:work_dir])
            Dir.mkdir(conv_path) unless Dir.exist?(conv_path)
            Dir.mkdir(tran_path) unless Dir.exist?(tran_path)

            if options[:vmdk]
                import_image
            else
                import_vm
            end
        ensure
            cleanup_all
        end
    end

    # List all vms
    #
    # @param options [Hash] User CLI options
    def list_vms(options)
        con_ops   = connection_options('vms', options)
        vi_client = RbVmomi::VIM.connect(con_ops)
        properties = [
            'name',
            'config.template',
            'config.uuid',
            'summary.runtime.powerState',
            'runtime.host',
            'config.hardware.numCPU',
            'config.hardware.memoryMB',
            'config.hardware.device',
            'config.memoryHotAddEnabled',
            'config.cpuHotAddEnabled'
        ]

        if options.key?(:datacenter)
            filter = {}
            filter[:datacenter] = options[:datacenter]
            if options.key?(:cluster)
                filter[:cluster] = options[:cluster]
            end
            vms = get_objects(vi_client, 'VirtualMachine', properties, filter)
        elsif options.key?(:cluster)
            raise 'Filtering by cluster requires a datacenter.'
        else
            vms = get_objects(vi_client, 'VirtualMachine', properties)
        end

        list = []
        available_filters = [:name, :state]
        filters = nil
        if (available_filters & options.keys).any?
            filters = options.select {|key, _| available_filters.include?(key) }
        end
        vms.each do |vm|
            props = vm.to_hash
            next if props['config.template']

            if filters
                if filters[:name] && !props['name'].include?(filters[:name])
                    next
                end
                if filters[:state] && !props['summary.runtime.powerState'].include?(filters[:state])
                    next
                end
            end
            v = {}

            v[:imid]  = vm.obj._ref
            v[:name]  = props['name']
            v[:state] = props['summary.runtime.powerState']
            v[:host]  = props['runtime.host']
            v[:ref]   = props['config.uuid']
            v[:cpu]   = props['config.hardware.numCPU']
            mem_mb = props['config.hardware.memoryMB'].to_i
            mem_gb = mem_mb / 1024.0
            v[:mem]   = mem_gb == mem_gb.floor ? "#{mem_gb.to_i}G" : "#{mem_gb.round(1)}G"
            disks = Array(props['config.hardware.device'])
                        .select { |d| d.is_a?(RbVmomi::VIM::VirtualDisk) }
            v[:disks] = disks.map { |d|
                gb = d.capacityInKB.to_f / (1024 * 1024)
                gb == gb.floor ? "#{gb.to_i}G" : "#{gb.round(1)}G"
            }.join(',')
            list << v
        end
        format_list.show(list, options)
    end

    # List datacenters
    #
    # @param options [Hash] User CLI options
    def list_datacenters(options)
        con_ops = connection_options('datacenters', options)
        vi_client = RbVmomi::VIM.connect(con_ops)

        list  = []
        dcs = get_objects(vi_client, 'Datacenter', ['name'])

        dcs.each do |dc|
            v = {}
            conf = dc.to_hash['name']
            v[:datacenter] = conf.to_s
            list << v
        end
        format_list.show(list, options)
    end

    # List Clusters
    #
    # @param options [Hash] User CLI options
    def list_clusters(options)
        con_ops = connection_options('clusters', options)
        vi_client = RbVmomi::VIM.connect(con_ops)
        filter = options.key?(:datacenter) ? { :datacenter => options[:datacenter] } : nil

        begin
            list = list_clusters_by_resource_pool(vi_client, filter)
        rescue RbVmomi::VIM::InvalidProperty
            list = list_clusters_by_usage_summary(vi_client, filter)
        end

        format_list.show(list, options)
    end

    def list_clusters_by_resource_pool(vi_client, filter)
        properties = ['resourcePool', 'name']
        cs = filter ? get_objects(vi_client, 'ComputeResource', properties, filter)
                    : get_objects(vi_client, 'ComputeResource', properties)

        cs.map do |c|
            conf = c.to_hash
            {
                :name => conf['name'].to_s,
                :vm_count => resource_pool_vm_count(conf['resourcePool'])
            }
        end
    end

    def list_clusters_by_usage_summary(vi_client, filter)
        properties = ['summary.usageSummary.totalVmCount', 'name']
        cs = filter ? get_objects(vi_client, 'ComputeResource', properties, filter)
                    : get_objects(vi_client, 'ComputeResource', properties)

        cs.map do |c|
            conf = c.to_hash
            {
                :name => conf['name'].to_s,
                :vm_count => conf['summary.usageSummary.totalVmCount']
            }
        end
    end

    def resource_pool_vm_count(resource_pool)
        return 0 if resource_pool.nil?

        vms = Array(resource_pool[:vm])
        child_pools = Array(resource_pool[:resourcePool])

        vms.length + child_pools.sum {|child| resource_pool_vm_count(child) }
    end

    # Import VM
    #
    # @param options [Hash] User CLI Options
    def import_vm
        # Convert OVA to KVM compatible, getting disks and XML file for the VM
        img_ids = run_v2v_conversion
        puts img_ids.nil? ? "No Images ID's reported being created".red : "Created images: #{img_ids}".green

        raise 'No images were allocated in OpenNebula. Aborting template creation.' if img_ids.nil? || img_ids.empty?

        # Create base template using XML from OVA conversion
        vm_template = create_vm_template_from_ova

        img_ids.each do |i|
            img_hash = { 'IMAGE_ID' => "#{i[:id]}" }
            dev_prefix = effective_dev_prefix(i[:os])
            unless dev_prefix.nil?
                img_hash['DEV_PREFIX'] = dev_prefix
            end
            vm_template.add_element('//VMTEMPLATE', { 'DISK' => img_hash })
        end

        # Optimize template for Windows VMs
        if img_ids[0][:os] == 'windows'
            vm_template = tune_windows_tmpl(vm_template, @options)
        end

        print 'Allocating the VM template...'

        rc = vm_template.allocate(vm_template.to_xml)

        if OpenNebula.is_error?(rc)
            raise "Failed to create OpenNebula VM template '#{@options[:name]}':\n#{rc.message}"
        end

        puts 'Success'.green
        puts "VM Template ID: #{vm_template.id}\n"
        chown_one_object(vm_template, *resolve_one_ownership)
    end

    # Import Image
    #
    # @param options [Hash] User CLI Options
    #
    def import_image
        if @options[:inject_dns]
            raise 'virt-customize not found. Please install it: apt install libguestfs-tools / dnf install guestfs-tools'.red \
                unless system('which virt-customize > /dev/null 2>&1')
        end

        # Convert VMDK to QCOW2 compatible
        print 'Converting the Image => '
        img_loc = convert_vmdk(@options[:vmdk])
        puts img_loc.nil? ? 'No Image reported being converted'.red : "Converted image: #{img_loc}".green

        # Import Image to OpenNebula
        print 'Allocating the Image => '
        img_ids = create_one_images([img_loc])
        puts img_ids.nil? || img_ids.first[:id].nil? ? 'No Image reported being created'.red : "Created image: #{img_ids.first[:id]}".green
    end

    # Convert VM
    #
    # @param options [Hash] User CLI Options
    def convert_vm
        if @options[:inject_dns]
            raise 'virt-customize not found. Please install it: apt install libguestfs-tools / dnf install guestfs-tools'.red \
                unless system('which virt-customize > /dev/null 2>&1')
        end

        con_ops = connection_options('vm', @options)
        @vi_client = RbVmomi::VIM.connect(con_ops)
        properties = [
            'config',
            'datastore',
            'guest.net',
            'guest.guestFullName',
            'name',
            'parent',
            'snapshot',
            'summary.runtime.powerState',
            'resourcePool',
            'runtime.host'
        ]

        vm_pool = get_objects(@vi_client, 'VirtualMachine', properties)
        vm = vm_pool.find {|r| r['name'] == @options[:name] }
        if vm.nil?
            raise "Unable to find Virtual Machine by name '#{@options[:name]}'"
        end

        @props = vm.to_hash
        validate_vsan_conversion!

        if @options[:dry_run]
            if @props['config'][:guestFullName].include?('Windows') && @options[:custom_convert]
                raise "Windows is not supported in OpenNebula's Custom Conversion process".red
            end

            # OpenNebula system prechecks
            if !@options[:skip_prechecks]
                one_prechecks(vm)
            end

            run_dry_run_estimate
            return
        end

        # If clone option is set, clone the VM and override VM properties
        if @options[:clone]
            begin
                cloned_vm = clone_vm(@vi_client, properties, vm)
                @props = cloned_vm.to_hash
            rescue RbVmomi::Fault => e
                raise "Failed to clone VM #{@options[:name]}: #{e.message}"
            end
        end

        if !@options[:delta]
            # Some basic preliminary checks
            if @props['summary.runtime.powerState'] != 'poweredOff'
                raise "Virtual Machine #{@options[:name]} is not Powered Off.".red
            end
            if !@props['snapshot'].nil?
                raise "Virtual Machine #{@options[:name]} cannot have existing snapshots.".red
            end
        end

        if @props['config'][:guestFullName].include?('Windows') && @options[:custom_convert]
            raise "Windows is not supported in OpenNebula's Custom Conversion process".red
        end

        # OpenNebula system prechecks
        if !@options[:skip_prechecks]
            one_prechecks(vm)
        end

        if @options[:delta_prepare]
            run_delta_prepare
            return
        end

        if @options[:delta_cleanup]
            run_delta_cleanup
            return
        end

        # Gather NIC backing early because it makes a call to vCenter,
        # which may not be authenticated after X hours of converting disks
        vc_nics, vc_nic_backing = get_vcenter_nic_info

        vm_template = create_vm_template

        img_ids = if @options[:custom_convert]
                      run_custom_conversion
                  elsif @options[:delta]
                      run_delta_conversion
                  else
                      run_v2v_conversion
                  end

        puts img_ids.nil? ? "No Images ID's reported being created".red : "Created images: #{img_ids}".green

        raise 'No images were allocated in OpenNebula. Aborting template creation.' if img_ids.nil? || img_ids.empty?

        img_ids.each do |i|
            img_hash = { 'IMAGE_ID' => "#{i[:id]}" }
            dev_prefix = effective_dev_prefix(i[:os])
            unless dev_prefix.nil?
                img_hash['DEV_PREFIX'] = dev_prefix
            end
            vm_template.add_element('//VMTEMPLATE', { 'DISK' => img_hash })
        end

        # If clone option is set, delete clean up the cloned VM
        if @options[:clone]
            puts 'Cleaning up cloned VM...'
            begin
                delete_vm(cloned_vm)
            rescue RbVmomi::Fault => e
                puts "Failed to delete cloned VM #{@options[:name]}: #{e.message}, remove the cloned VM manually."
            end
        end

        # Add the NIC's now, after any conversion stuff has happened since it creates OpenNebula objects
        vm_template = add_one_nics(vm_template, vc_nics, vc_nic_backing)

        if img_ids[0][:os] == 'windows'
            vm_template = tune_windows_tmpl(vm_template, @options)
        end

        new_vsphere_client

        vm = @vsphere_client.get_vm(@options[:name])
        vm_id = vm['vm']
        tags = @vsphere_client.get_vm_tags(vm_id)

        if !tags.empty?
            tag_template = { 'VCENTER_TAGS' => tags.join(',') }
            vm_template.add_element('//VMTEMPLATE', tag_template)
        end

        print 'Allocating the VM template...'

        rc = vm_template.allocate(vm_template.to_xml)

        if OpenNebula.is_error?(rc)
            raise "Failed to create OpenNebula VM template '#{@options[:name]}':\n#{rc.message}"
        end

        puts 'Success'.green
        puts "VM Template ID: #{vm_template.id}\n"
        chown_one_object(vm_template, *resolve_one_ownership)

        set_sunstone_labels(tags, vm_template.id) unless tags.empty?
    end

    def new_vsphere_client
        args = [@options[:vcenter], @options[:vuser], @options[:vpass], @logger,
                @options[:accept_cert] || false]

        @vsphere_client = VSphereClient.new(*args)
    end

    #
    # Applies FireEdge Sunstone labels to a given VM Template ID. The labels
    # will be created if they don't exist. Nested under VCENTER_TAGS label.
    #
    # @param [Array] vcenter_tags list of label names to apply
    # @param [Int] one_vm_template_id ID of the VM Tempalte to assign the label to
    #
    # @return [nil/OpenNebula::Error] Nil if success or error
    #
    def set_sunstone_labels(vcenter_tags, one_vm_template_id)
        # Labels exist as part of the User Template with a vm_template reference
        user = OpenNebula::User.new_with_id(OpenNebula::User::SELF, @client)
        user.info

        labels = user['TEMPLATE/LABELS']
        labels = if labels
                     JSON.parse(JSON.parse(labels))
                 else
                     {}
                 end

        id = one_vm_template_id.to_s

        vcenter_tags.each do |tag|
            labels[tag] = {} unless labels.key?(tag)
            labels[tag]['vm-template'] = [] unless labels[tag].key?('vm-template')
            labels[tag]['vm-template'] << id unless labels[tag]['vm-template'].include? id
        end

        update_str = "<ROOT><LABELS>#{labels.to_json.to_json}</LABELS></ROOT>"
        user.update(update_str, true)
    end

    private

    # Resolves :one_user and :one_group options to OpenNebula numeric IDs.
    # Accepts either a numeric string/integer (used directly) or a name
    # (looked up via the UserPool / GroupPool). Returns [-1, -1] when neither
    # option is set, which tells chown to leave the current owner unchanged.
    # Result is memoised so pool lookups only happen once per invocation.
    #
    # @return [Array(Integer, Integer)] [uid, gid]
    def resolve_one_ownership
        @resolved_ownership ||= begin
            uid = -1
            gid = -1

            if @options[:one_user]
                val = @options[:one_user].to_s
                if val =~ /^\d+$/
                    uid = val.to_i
                else
                    pool = OpenNebula::UserPool.new(@client)
                    rc   = pool.info_all
                    if rc.class == OpenNebula::Error
                        puts "Failed to fetch user pool: #{rc.message}".red
                    else
                        found = pool.find {|u| u['NAME'] == val }
                        if found
                            uid = found['ID'].to_i
                        else
                            puts "OpenNebula user '#{val}' not found, skipping user ownership assignment.".brown
                        end
                    end
                end
            end

            if @options[:one_group]
                val = @options[:one_group].to_s
                if val =~ /^\d+$/
                    gid = val.to_i
                else
                    pool = OpenNebula::GroupPool.new(@client)
                    rc   = pool.info_all
                    if rc.class == OpenNebula::Error
                        puts "Failed to fetch group pool: #{rc.message}".red
                    else
                        found = pool.find {|g| g['NAME'] == val }
                        if found
                            gid = found['ID'].to_i
                        else
                            puts "OpenNebula group '#{val}' not found, skipping group ownership assignment.".brown
                        end
                    end
                end
            end

            [uid, gid]
        end
    end

    # Changes ownership of an OpenNebula object.
    # Pass uid/gid as -1 to leave the corresponding value unchanged.
    #
    # @param obj [OpenNebula::Image, OpenNebula::Template] object to chown
    # @param uid [Integer] target user ID  (-1 = keep current)
    # @param gid [Integer] target group ID (-1 = keep current)
    def chown_one_object(obj, uid, gid)
        return if uid == -1 && gid == -1

        rc = obj.chown(uid, gid)
        obj_type = obj.class.name.split('::').last
        if rc.class == OpenNebula::Error
            puts "Failed to change ownership of #{obj_type} #{obj.id}: #{rc.message}".red
        else
            owner_str = uid != -1 ? "user #{@options[:one_user]}(#{uid})" : nil
            group_str = gid != -1 ? "group #{@options[:one_group]}(#{gid})" : nil
            label = [owner_str, group_str].compact.join(' and ')
            puts "#{obj_type} #{obj.id} assigned to #{label}".green
            @logger.info "Changed ownership of #{obj_type} #{obj.id} to uid #{uid} gid #{gid}"
        end
    end

    def execute_command(cmd)
        @logger.info "Running command #{cmd}"

        o, e, s = Open3.capture3(cmd)

        if !s.success?
            @logger.info "Failed to run command\n#{o}"
            @logger.error e
        end

        [o, e, s.success?]
    end

end
