# This is free software. See LICENSE and COPYING files for details.
require 'pdf/reader'
require 'pdf/core'
require 'prawn/text'

require_relative '../pdf/core/document_state'
require_relative '../pdf/core/errors'
require_relative '../pdf/core/object_store'
require_relative '../pdf/core/page'
require_relative 'text'
require_relative 'document/internals'

module Prawn
  # @private
  module Templates
    def initialize_first_page(options)
      return super unless options[:template]

      fresh_content_streams(options)
      go_to_page(1)
    end

    ## FIXME: This is going to be terribly brittle because
    # it copy-pastes the start_new_page method. But at least
    # it should only run when templates are used.
    def start_new_page(options = {})
      return super unless options[:template]

      last_page = state.page
      if last_page
        last_page_size = last_page.size
        last_page_layout = last_page.layout
        last_page_margins = last_page.margins.dup
      end

      page_options = {
        size: options[:size] || last_page_size,
        layout: options[:layout] || last_page_layout,
        margins: last_page_margins
      }
      if last_page
        if last_page.graphic_state
          new_graphic_state = last_page.graphic_state.dup
        end

        # erase the color space so that it gets reset on new page for fussy
        # pdf-readers
        new_graphic_state.color_space = {} if new_graphic_state

        page_options[:graphic_state] = new_graphic_state
      end

      merge_template_options(page_options, options)

      state.page = PDF::Core::Page.new(self, page_options)

      apply_margin_options(options)
      generate_margin_box

      # Reset the bounding box if the new page has different size or layout
      if last_page && (last_page.size != state.page.size ||
                       last_page.layout != state.page.layout)
        @bounding_box = @margin_box
      end

      state.page.new_content_stream
      use_graphic_settings(true)
      forget_text_rendering_mode!

      unless options[:orphan]
        state.insert_page(state.page, @page_number)
        @page_number += 1

        if @background
          canvas do
            image(@background, scale: @background_scale, at: bounds.top_left)
          end
        end
        @y = @bounding_box.absolute_top

        float do
          state.on_page_create_action(self)
        end
      end
    end

    def merge_template_options(page_options, options)
      object_id = state.store.import_page(
        options[:template],
        options[:template_page] || 1
      )
      page_options.merge!(object_id: object_id, page_template: true)
    end

    module ObjectStoreExtensions
      # imports all objects required to render a page from another PDF. The
      # objects are added to the current object store, but NOT linked
      # anywhere.
      #
      # The object ID of the root Page object is returned, it's up to the
      # calling code to link that into the document structure somewhere. If
      # this isn't done the imported objects will just be removed when the
      # store is compacted.
      #
      # Imports nothing and returns nil if the requested page number doesn't
      # exist. page_num is 1 indexed, so 1 indicates the first page.
      #
      def import_page(input, page_num)
        @loaded_objects = {}
        template_id = indexed_template(input, page_num)
        return template_id if template_id

        io = if input.respond_to?(:seek) && input.respond_to?(:read)
               input
             elsif File.file?(input.to_s)
               StringIO.new(File.binread(input.to_s))
             else
               raise ArgumentError, 'input must be an IO-like object or a ' \
               'filename'
             end

        hash = indexed_hash(input, io)
        ref = hash.page_references[page_num - 1]

        if ref.nil?
          nil
        else
          index_template(
            input, page_num,
            load_object_graph(hash, ref).identifier
          )
        end
      rescue PDF::Reader::MalformedPDFError,
             PDF::Reader::InvalidObjectError => e
        msg = 'Error reading template file. If you are sure it\'s a valid PDF,'\
              " it may be a bug.\n#{e.message}"
        raise PDF::Core::Errors::TemplateError, msg
      rescue PDF::Reader::UnsupportedFeatureError
        msg = 'Template file contains unsupported PDF features'
        raise PDF::Core::Errors::TemplateError, msg
      end

      private

      # An index for page templates so that their loaded object graph
      # can be reused without multiple loading
      def template_index
        @template_index ||= {}
      end

      # returns the indexed object graph identifier for a template page if
      # it exists
      def indexed_template(input, page_number)
        key = indexing_key(input)
        template_index[key] && template_index[key][page_number]
      end

      # indexes the identifier for a page from a template
      def index_template(input, page_number, id)
        (template_index[indexing_key(input)] ||= {})[page_number] ||= id
      end

      # An index for the read object hash of a pdf template so that the
      # object hash does not need to be parsed multiple times when using
      # different pages of the pdf as page templates
      def hash_index
        @hash_index ||= {}
      end

      # reads and indexes a new IO for a template
      # if the IO has been indexed already then the parsed object hash
      # is returned directly
      def indexed_hash(input, io)
        hash_index[indexing_key(input)] ||= PDF::Reader::ObjectHash.new(io)
      end

      # the index key for the input.
      # uses object_id so that both a string filename or an IO stream can be
      # indexed and reused provided the same object gets used in multiple page
      # template calls.
      def indexing_key(input)
        input.object_id
      end

      # returns a nested array of object IDs for all pages in this object store.
      #
      def get_page_objects(obj)
        if obj.data[:Type] == :Page
          obj.identifier
        elsif obj.data[:Type] == :Pages
          obj.data[:Kids].map { |kid| get_page_objects(kid) }
        end
      end

      # takes a source PDF and uses it as a template for this document.
      #
      def load_file(template)
        unless (template.respond_to?(:seek) && template.respond_to?(:read)) ||
            File.file?(template)
          raise ArgumentError, "#{template} does not exist"
        end

        hash = PDF::Reader::ObjectHash.new(template)
        src_info = hash.trailer[:Info]
        src_root = hash.trailer[:Root]
        @min_version = hash.pdf_version.to_f

        if hash.trailer[:Encrypt]
          msg = 'Template file is an encrypted PDF, it can\'t be used as a '\
              'template'
          raise PDF::Core::Errors::TemplateError, msg
        end

        if src_info
          @info = load_object_graph(hash, src_info).identifier
        end

        if src_root
          @root = load_object_graph(hash, src_root).identifier
        end
      rescue PDF::Reader::MalformedPDFError,
             PDF::Reader::InvalidObjectError => e
        msg = 'Error reading template file. If you are sure it\'s a valid PDF,'\
              " it may be a bug.\n#{e.message}"
        raise PDF::Core::Errors::TemplateError, msg
      rescue PDF::Reader::UnsupportedFeatureError
        msg = 'Template file contains unsupported PDF features'
        raise PDF::Core::Errors::TemplateError, msg
      end

      # recurse down an object graph from a source PDF, importing all the
      # indirect objects we find.
      #
      # hash is the PDF::Reader::ObjectHash to extract objects from, object is
      # the object to extract.
      #
      def load_object_graph(hash, object)
        @loaded_objects ||= {}
        case object
        when ::Hash then
          object.each do |key, value|
            object[key] = load_object_graph(hash, value)
          end
          object
        when Array then
          object.map { |item| load_object_graph(hash, item) }
        when PDF::Reader::Reference then
          unless @loaded_objects.key?(object.id)
            @loaded_objects[object.id] = ref(nil)
            new_obj = load_object_graph(hash, hash[object])
            if new_obj.is_a?(PDF::Reader::Stream)
              stream_dict = load_object_graph(hash, new_obj.hash)
              @loaded_objects[object.id].data = stream_dict
              @loaded_objects[object.id] << new_obj.data
            else
              @loaded_objects[object.id].data = new_obj
            end
          end
          @loaded_objects[object.id]
        when PDF::Reader::Stream
          # Stream is a subclass of string, so this is here to prevent the
          # stream being wrapped in a LiteralString
          object
        when String
          utf8?(object) ? object : PDF::Core::ByteString.new(object)
        else
          object
        end
      end
    end
  end
end

if Prawn::Document::VALID_OPTIONS.frozen?
  Prawn::Document.const_set(
    :VALID_OPTIONS,
    (Prawn::Document.send(
      :remove_const,
      :VALID_OPTIONS
    ).dup << :template).freeze
  )
else
  Prawn::Document::VALID_OPTIONS << :template
end
Prawn::Document.extensions << Prawn::Templates

PDF::Core::ObjectStore.send(:include, Prawn::Templates::ObjectStoreExtensions)
