# frozen_string_literal: true

require 'net/http'
require 'tempfile'
require 'tmpdir'
require 'uri'

# Generates an iPXE ISO hybrid image
#
# requires syslinux, ipxe/ipxe-bootimgs, genisoimage, isohybrid
module ForemanBootdisk
  class ISOGenerator
    def self.generate_full_host(host, opts = {}, &block)
      raise Foreman::Exception, N_('Host is not in build mode, so the template cannot be rendered') unless host.build?

      tmpl = render_pxelinux_template(host)

      # pxe_files and filename conversion is utterly bizarre
      # aim to convert filenames to something usable under ISO 9660, to match as rendered in the template
      # and then still ensure that the fetch() process stores them under the same name
      files = host.operatingsystem.family.constantize::PXEFILES.keys.each_with_object({}) do |type, hash|
        filename = host.operatingsystem.bootfile(host.medium_provider, type)
        iso_filename = iso9660_filename(filename)
        hash[iso_filename] = host.url_for_boot(type)
      end

      generate(opts.merge(isolinux: tmpl, files: files), &block)
    end

    def self.render_pxelinux_template(host)
      pxelinux_template = host.provisioning_template(kind: :PXELinux)

      raise Foreman::Exception, N_('Unable to generate disk template, PXELinux template not found.') unless pxelinux_template

      template = ForemanBootdisk::Renderer.new.render_template(
        template: pxelinux_template,
        host: host,
        scope_class: ForemanBootdisk::Scope::FullHostBootdisk
      )

      unless template
        err = host.errors.full_messages.to_sentence
        raise ::Foreman::Exception.new(N_('Unable to generate disk PXELinux template: %s'), err)
      end

      template
    end

    def self.generate(opts = {})
      opts[:isolinux] = <<~ISOLINUX if opts[:isolinux].nil? && opts[:ipxe]
        default ipxe
        label ipxe
        kernel /ipxe
        initrd /script
      ISOLINUX

      Dir.mktmpdir('bootdisk') do |wd|
        Dir.mkdir(File.join(wd, 'build'))

        if opts[:isolinux]
          isolinux_source_file = File.join(Setting[:bootdisk_isolinux_dir], 'isolinux.bin')
          raise Foreman::Exception, N_('Please ensure the isolinux/syslinux package(s) are installed.') unless File.exist?(isolinux_source_file)

          FileUtils.cp(isolinux_source_file, File.join(wd, 'build', 'isolinux.bin'))

          ldlinux_source_file = File.join(Setting[:bootdisk_syslinux_dir], 'ldlinux.c32')
          FileUtils.cp(ldlinux_source_file, File.join(wd, 'build', 'ldlinux.c32')) if File.exist?(ldlinux_source_file)

          File.open(File.join(wd, 'build', 'isolinux.cfg'), 'w') do |file|
            file.write(opts[:isolinux])
          end
        end

        if opts[:ipxe]
          ipxe_source_file = File.join(Setting[:bootdisk_ipxe_dir], 'ipxe.lkrn')
          raise Foreman::Exception, N_('Please ensure the ipxe-bootimgs package is installed.') unless File.exist?(ipxe_source_file)

          FileUtils.cp(ipxe_source_file, File.join(wd, 'build', 'ipxe'))
          File.open(File.join(wd, 'build', 'script'), 'w') { |file| file.write(opts[:ipxe]) }
        end

        if opts[:files]
          if opts[:files].respond_to?(:each)
            opts[:files].each do |file, source|
              fetch(File.join(wd, 'build', file), source)
            end
          end
        end

        iso = if opts[:dir]
                Tempfile.new(['bootdisk', '.iso'], opts[:dir]).path
              else
                File.join(wd, 'output.iso')
              end
        raise Foreman::Exception, N_('ISO build failed') unless system(build_mkiso_command(output_file: iso, source_directory: File.join(wd, 'build')))

        # Make the ISO bootable as a HDD/USB disk too
        raise Foreman::Exception, N_('ISO hybrid conversion failed') unless system('isohybrid', iso)

        yield iso
      end
    end

    def self.build_mkiso_command(output_file:, source_directory:)
      arguments = [
        "-o #{output_file}",
        '-iso-level 2',
        '-b isolinux.bin',
        '-c boot.cat',
        '-no-emul-boot',
        '-boot-load-size 4',
        '-boot-info-table'
      ]
      [Setting[:bootdisk_mkiso_command], arguments, source_directory].flatten.join(' ')
    end

    def self.token_expiry(host)
      expiry = host.token.try(:expires)
      return '' if Setting[:token_duration].zero? || expiry.blank?

      '_' + expiry.strftime('%Y%m%d_%H%M')
    end

    def self.fetch(path, uri, limit = 10)
      dir = File.dirname(path)
      FileUtils.mkdir_p(dir) unless File.exist?(dir)

      use_cache = !!Setting[:bootdisk_cache_media]
      write_cache = false
      File.open(path, 'w') do |file|
        file.binmode

        if use_cache && !(contents = Rails.cache.fetch(uri, raw: true)).nil?
          ForemanBootdisk.logger.info("Retrieved #{uri} from local cache (use foreman-rake tmp:cache:clear to empty)")
          file.write(contents)
        else
          ForemanBootdisk.logger.info("Fetching #{uri}")
          write_cache = use_cache
          uri = URI(uri)
          Net::HTTP.start(uri.host, uri.port) do |http|
            request = Net::HTTP::Get.new(uri.request_uri, 'Accept-Encoding' => 'plain')

            http.request(request) do |response|
              case response
              when Net::HTTPSuccess then
                response.read_body do |chunk|
                  file.write chunk
                end
              when Net::HTTPRedirection then
                raise("Too many HTTP redirects when downloading #{uri}") if limit <= 0

                fetch(path, response['location'], limit - 1)
                # prevent multiple writes to the cache
                write_cache = false
              else
                response.error!
              end
            end
          end
        end
      end

      return unless write_cache

      contents = File.read(path)
      return if contents.empty?

      ForemanBootdisk.logger.debug("Caching contents of #{uri}")
      Rails.cache.write(uri, contents, raw: true)
    end

    # isolinux supports up to ISO 9660 level 2 filenames
    def self.iso9660_filename(name)
      dir  = File.dirname(name)
      file = File.basename(name).upcase.tr_s('^A-Z0-9_', '_').last(28)
      dir == '.' ? file : File.join(dir.upcase.tr_s('^A-Z0-9_', '_').last(28), file)
    end
  end
end