# frozen_string_literal: true

require 'zip'
require 'open3'

class UnpackJob < ApplicationJob
  include Open3
  include EmbedCodeService

  queue_as :unpack # See HELIO-4952

  discard_on Resque::Job::DontPerform

  before_perform do |job|
    file_set = FileSet.find(job.arguments.first)
    # This is needed because manual triggering of UnpackJob in FeaturedRepresentativesController can happen before...
    # the original_file is available
    raise "No file_set for #{id}" if file_set.nil?
    # according to the Resque docs Resque::Job::DontPerform should not leave a failed job in the queue,...
    # see https://github.com/resque/resque/blob/master/docs/HOOKS.md
    # ...but in the context of ActiveJob the `discard_on` is needed, so really any custom exception would work here.
    raise Resque::Job::DontPerform if file_set.original_file.blank?
  end

  def perform(id, kind) # rubocop:disable Metrics/CyclomaticComplexity,  Metrics/PerceivedComplexity
    file_set = FileSet.find id

    root_path = UnpackService.root_path_from_noid(id, kind)
    FileUtils.mkdir_p File.dirname root_path unless Dir.exist? File.dirname root_path # this should already exist... but with specs it's iffy

    # A rake task run via nightly cron will delete these so we can avoid
    # problems with puma holding open file handles making deletion fail, HELIO-2015
    FileUtils.move(root_path, UnpackService.remove_path_from_noid(id, kind)) if Dir.exist? root_path
    # "Unpacked" PDFs are files, not directories
    FileUtils.move("#{root_path}.pdf", UnpackService.remove_path_from_noid(id, kind)) if File.exist? "#{root_path}.pdf"

    file = Tempfile.new(id)
    file.write(file_set.original_file.content.force_encoding("utf-8"))
    file.close

    case kind
    when 'epub'
      unpack_epub(id, root_path, file)
      insert_embed_codes(file_set.parent.id, root_path)
      create_search_index(root_path)
      cache_epub_toc(id, root_path)
      file_set.parent.update_index if file_set.parent.present? # index the ToC and "Accessibility Claims" metadata to the monograph
      create_epub_chapters(id, root_path) # has to come after cache_epub_toc()
      epub_webgl_bridge(id, root_path, kind)
    when 'webgl'
      unpack_zipped_js_app(id, kind, root_path, file)
      epub_webgl_bridge(id, root_path, kind)
    when 'interactive_application'
      unpack_zipped_js_app(id, kind, root_path, file)
    when 'interactive_map'
      unpack_zipped_js_app(id, kind, root_path, file)
    when 'pdf_ebook'
      pdf = linearize_pdf(root_path, file)
      # we have to create a PDF's chapter files *before* caching the ToC because the latter uses the existence of...
      # each file to cache a `downloadable?` flag for that interval/chapter
      create_pdf_chapters(id, pdf, root_path) if File.exist? pdf
      cache_pdf_toc(id, pdf) if File.exist? pdf
      file_set.parent.update_index if file_set.parent.present? # index the ToC and "Accessibility Claims" metadata to the monograph
    else
      Rails.logger.error("Can't unpack #{kind} for #{id}")
    end
  end

  private

    def cache_pdf_toc(id, pdf)
      EbookTableOfContentsCache.find_by(noid: id)&.destroy
      pdf_ebook = PDFEbook::Publication.from_path_id(pdf, id)
      intervals = pdf_ebook.intervals
      EbookTableOfContentsCache.create(noid: pdf_ebook.id, toc: intervals.map { |i| i.to_h_for_toc }.to_json)
    end

    def cache_epub_toc(id, root_path)
      EbookTableOfContentsCache.find_by(noid: id)&.destroy
      epub = EPub::Publication.from_directory(root_path)
      intervals = epub&.rendition&.intervals
      EbookTableOfContentsCache.create(noid: epub.id, toc: intervals.map { |i| i.to_h_for_toc }.to_json)
    end

    def create_epub_chapters(id, root_path)
      publication = EPub::Publication.from_directory(UnpackService.root_path_from_noid(id, 'epub'))
      epub_presenter = EPubPresenter.new(publication)

      Rails.logger.error("[EPUB CHAPTER FAILURE] The pdf_ebook #{id} will not have intervals") if publication.class == "EPub::PublicationNullObject"
      # don't create an empty directory if there are no downloadable intervals
      return unless epub_presenter.intervals? && epub_presenter.intervals.any? { |interval| interval.downloadable? }

      chapter_dir = prepare_chapter_dir(id, 'epub', root_path)

      # the `toc_only_interval` and `content_interval` stuff are a little weird but stem from the way these things...
      # evolved with epub_presenter.intervals() only needing to provide ToC link for the Monograph catalog page...
      # but it also provides the cfi necessary for the PDF-producing Interval.from_rendition_cfi_title()
      epub_presenter.intervals.each_with_index do |toc_only_interval, index|
        # don't create an empty/broken file if there are no pages in this interval
        next unless toc_only_interval.downloadable?

        content_interval = EPub::Interval.from_rendition_cfi_title(publication.rendition, toc_only_interval.cfi, toc_only_interval.title)
        pdf = EPub::Marshaller::PDF.from_publication_interval(publication, content_interval)
        pdf.document.render_file "#{chapter_dir}/#{index}.pdf"
      end
    end

    def create_pdf_chapters(id, pdf, root_path) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
      return unless system("which qpdf > /dev/null 2>&1")
      return unless File.exist? "#{root_path}.pdf"

      pdf_ebook = PDFEbook::Publication.from_path_id(pdf, id)
      # The above will fail to load due to a plethora of Oragami errors due to malformed PDFs
      # So we'll have a pdf on the file system, in the derivatives directory but there's something wrong
      # with it. The way PDFEbook::Publication handles that is by catching and logging the error and returning a
      # null object, just like epubs do
      # How can we report that there's a problem with the PDF from a Job?
      # The logs are most likely too dense. Should we fail the job here?
      # I don't know.
      # Maybe by not displaying the TOC or and interval downloads in the app it will be obvious there's
      # a problem.
      Rails.logger.error("[PDF CHAPTER FAILURE] The pdf_ebook #{id} will not have intervals") if pdf_ebook.class == "PDFEbook::PublicationNullObject"
      return unless pdf_ebook.intervals&.count&.positive?

      chapters = []
      pdf_ebook.intervals.each do |interval|
        chapters << { level: interval.level,
                      start_page: interval.cfi.gsub('page=', '').to_i } # note ''.to_i == 0, which is where the 0's below come from!
      end

      chapters.each_with_index do |chapter, index|
        # for the purposes of chapter file creation we need to skip any ToC entries that are missing a destination...
        # page, but we still want them in the array so that ToC entry indices match the created chapter files' names.
        next if chapter[:start_page] == 0

        chapters[0...index].map do |chapter_out|
          # if we just moved back up to a "higher" ToC level (lower level number!), then all previous sections on...
          # this ToC level or "below" (their nested children with a higher level number) are terminated.
          if chapter_out[:end_page].blank? && chapter_out[:level] >= chapter[:level]
            # Note we'll always include the first page of the following section, as there are inevitable edge cases...
            # (usually in smaller/lower subsections) where that page is needed, and we have no way to detect that.
            chapter_out[:end_page] = chapter[:start_page]
          end
        end
      end

      file = File.open(pdf)
      chapter_dir = prepare_chapter_dir(id, 'pdf_ebook', root_path)

      chapters.each_with_index do |chapter_out, index|
        # for the purposes of chapter file creation we need to skip any ToC entries that are missing a destination...
        # page, but we still want them in the array so that ToC entry indices match the created chapter files' names.
        next if chapter_out[:start_page] == 0

        # this shouldn't be possible
        raise "Chapter file processing failed! Calculated chapter end page cannot be 0!" if chapter_out[:end_page] == 0
        if chapter_out[:end_page].present?
          run_command("qpdf --empty --pages #{file.path} #{chapter_out[:start_page]}-#{chapter_out[:end_page]} -- #{chapter_dir}/#{index}.pdf")
        else
          run_command("qpdf --empty --pages #{file.path} #{chapter_out[:start_page]}-z -- #{chapter_dir}/#{index}.pdf")
        end
      end
    end

    def prepare_chapter_dir(id, kind_prefix, root_path)
      chapter_dir = File.join(root_path + '_chapters')
      # if there is a pre-existing chapter_dir schedule it for deletion
      FileUtils.move(chapter_dir, UnpackService.remove_path_from_noid(id, "#{kind_prefix}_chapters")) if Dir.exist? chapter_dir
      FileUtils.mkdir_p chapter_dir unless Dir.exist? chapter_dir
      chapter_dir
    end

    def linearize_pdf(root_path, file)
      if system("which qpdf > /dev/null 2>&1")
        # "Linearize" the pdf for x-sendfile and byte ranges, HELIO-3165
        # It's ok to linearize a pdf that's already been linearized
        begin
          run_command("qpdf --linearize #{file.path} #{root_path}.pdf")
        rescue StandardError => e
          # qpdf lineariztion VERY OFTEN will produce an "error" even if linearization succeeds
          # It's really more of a warning... but just rescuing everything is bad.
          # TODO: a better way to handle this and maybe job errors generally
          Rails.logger.error("[PDF LINEARIZE ERROR] #{e.message}")
          # make sure we've pulled the pdf out of fedora even if lineariztion has failed
          FileUtils.copy(file, "#{root_path}.pdf") unless File.exist? "#{root_path}.pdf"
        end
      else
        FileUtils.copy(file, "#{root_path}.pdf")
      end
      # Because `file` was created with Tempfile.new, which defaults to 0600 permissions, the `#{root_path}.pdf` copy's
      # perms are -rw------- too. They need to be -rw-rw-r-- for X-Sendfile to work, so we'll chmod.
      File.chmod 0664, "#{root_path}.pdf"
      "#{root_path}.pdf"
    end

    def run_command(command)
      stdin, stdout, stderr, wait_thr = popen3(command)
      stdin.close
      stdout.binmode
      out = stdout.read
      stdout.close
      err = stderr.read
      stderr.close
      # https://tools.lib.umich.edu/jira/browse/HELIO-3247
      raise "Unable to execute command \"#{command}\"\n#{err}\n#{out}" unless wait_thr.value.success? || err.include?('operation succeeded with warnings')
    end

    def epub_webgl_bridge(id, root_path, kind) # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
      # Edge case for epubs with POI (Point of Interest) to map to CFI for a webgl (gabii)
      # See 1630
      monograph_id = FeaturedRepresentative.where(file_set_id: id)&.first&.work_id
      case kind
      when 'epub'
        if FeaturedRepresentative.where(work_id: monograph_id, kind: 'webgl')&.first.present?
          EPub::BridgeToWebgl.construct_bridge(EPub::Publication.from_directory(root_path))
        end
      when 'webgl'
        epub_id = FeaturedRepresentative.where(work_id: monograph_id, kind: 'epub')&.first&.file_set_id
        if epub_id.present?
          EPub::BridgeToWebgl.construct_bridge(EPub::Publication.from_directory(UnpackService.root_path_from_noid(epub_id, 'epub')))
        end
      end
    end

    def unpack_epub(id, root_path, file)
      Zip::File.open(file.path) do |zip_file|
        zip_file.each do |entry|
          make_path_entry(root_path, entry.name)
          entry.extract(File.join(root_path, entry.name))
        end
      end
    rescue Zip::Error
      raise "EPUB #{id} is corrupt."
    end

    def unpack_zipped_js_app(id, kind, root_path, file)
      Zip::File.open(file.path) do |zip_file|
        zip_file.each do |entry|
          # We don't want to include the root directory, it could be named anything.
          parts = entry.name.split(File::SEPARATOR)
          without_parent = parts.slice(1, parts.length).join(File::SEPARATOR)
          make_path_entry(root_path, without_parent)
          entry.extract(File.join(root_path, without_parent))
        end
      end
    rescue Zip::Error
      raise "#{kind.titleize} #{id} is corrupt."
    end

    def create_search_index(root_path)
      sql_lite = EPub::SqlLite.from_directory(root_path)
      sql_lite.create_table
      sql_lite.load_chapters
    end

    def make_path_entry(root_path, file_entry)
      FileUtils.mkdir_p(root_path) unless Dir.exist?(root_path)
      dir = root_path
      file_entry.split(File::SEPARATOR).each do |sub_dir|
        FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
        dir = File.join(dir, sub_dir)
      end
    end
end
