require 'docx/containers'
require 'docx/elements'
require 'docx/errors'
require 'docx/helpers'
require 'nokogiri'
require 'zip'

module Docx
  # The Document class wraps around a docx file and provides methods to
  # interface with it.
  #
  #   # get a Docx::Document for a docx file in the local directory
  #   doc = Docx::Document.open("test.docx")
  #
  #   # get the text from the document
  #   puts doc.text
  #
  #   # do the same thing in a block
  #   Docx::Document.open("test.docx") do |d|
  #     puts d.text
  #   end
  class Document
    include Docx::SimpleInspect

    attr_reader :xml, :doc, :zip, :styles, :headers, :footers

    def initialize(path_or_io, options = {})
      @replace = {}

      # accept path-like objects (e.g. Pathname, File) by using their path (#101)
      path_or_io = path_or_io.to_path if path_or_io.respond_to?(:to_path)

      # if path-or_io is string && does not contain a null byte
      if (path_or_io.instance_of?(String) && !/\u0000/.match?(path_or_io))
        @zip = Zip::File.open(path_or_io)
      else
        @zip = Zip::File.open_buffer(path_or_io)
      end

      document = @zip.glob('word/document*.xml').first
      raise Errno::ENOENT if document.nil?

      @document_xml = document.get_input_stream.read
      @doc = Nokogiri::XML(@document_xml)
      load_styles
      load_rels
      load_headers
      load_footers
      yield(self) if block_given?
    ensure
      @zip.close unless @zip.nil?
    end

    # This stores the current global document properties, for now
    def document_properties
      {
        font_size: font_size,
        hyperlinks: hyperlinks
      }
    end

    # With no associated block, Docx::Document.open is a synonym for Docx::Document.new. If the optional code block is given, it will be passed the opened +docx+ file as an argument and the Docx::Document oject will automatically be closed when the block terminates. The values of the block will be returned from Docx::Document.open.
    # call-seq:
    #   open(filepath) => file
    #   open(filepath) {|file| block } => obj
    def self.open(path, &block)
      new(path, &block)
    end

    def paragraphs
      @doc.xpath('//w:document//w:body/w:p').map { |p_node| parse_paragraph_from p_node }
    end

    def bookmarks
      bkmrks_hsh = {}
      bkmrks_ary = @doc.xpath('//w:bookmarkStart').map { |b_node| parse_bookmark_from b_node }
      # also scan headers and footers so their bookmarks can be read and edited
      bkmrks_ary += headers.values.flat_map { |h| h.xpath('//w:bookmarkStart').map { |b_node| parse_bookmark_from b_node } }
      bkmrks_ary += footers.values.flat_map { |f| f.xpath('//w:bookmarkStart').map { |b_node| parse_bookmark_from b_node } }
      # auto-generated by office 2010
      bkmrks_ary.reject! { |b| b.name == '_GoBack' }
      bkmrks_ary.each { |b| bkmrks_hsh[b.name] = b }
      bkmrks_hsh
    end

    def to_xml
      Nokogiri::XML(@document_xml)
    end

    def tables
      @doc.xpath('//w:document//w:body//w:tbl').map { |t_node| parse_table_from t_node }
    end

    # Some documents have this set, others don't.
    # Values are returned as half-points, so to get points, that's why it's divided by 2.
    def font_size
      size_value = @styles&.at_xpath('//w:docDefaults//w:rPrDefault//w:rPr//w:sz/@w:val')&.value

      return nil unless size_value

      size_value.to_i / 2
    end

    # Hyperlink targets are extracted from the document.xml.rels file
    def hyperlinks
      hyperlink_relationships.each_with_object({}) do |rel, hash|
        id = rel.attributes['Id']
        target = rel.attributes['Target']
        next unless id && target

        hash[id.value] = target.value
      end
    end

    def hyperlink_relationships
      @rels.xpath("//xmlns:Relationship[contains(@Type,'hyperlink')]")
    end

    ##
    # *Deprecated*
    #
    # Iterates over paragraphs within document
    # call-seq:
    #   each_paragraph => Enumerator
    def each_paragraph
      paragraphs.each { |p| yield(p) }
    end

    # call-seq:
    #   to_s -> string
    def to_s
      paragraphs.map(&:to_s).join("\n")
    end

    # Output entire document as a String HTML fragment
    def to_html
      paragraphs.map(&:to_html).join("\n")
    end

    # Save document to provided path
    # call-seq:
    #   save(filepath) => void
    def save(path)
      with_zip64_disabled do
        update
        Zip::OutputStream.open(path) do |out|
          zip.each do |entry|
            next unless entry.file?

            out.put_next_entry(entry.name)
            value = @replace[entry.name] || zip.read(entry.name)

            out.write(value)
          end

        end
        zip.close
      end
    end

    # Output entire document as a StringIO object
    def stream
      with_zip64_disabled do
        update
        stream = Zip::OutputStream.write_buffer do |out|
          zip.each do |entry|
            next unless entry.file?

            out.put_next_entry(entry.name)

            if @replace[entry.name]
              out.write(@replace[entry.name])
            else
              out.write(zip.read(entry.name))
            end
          end
        end

        stream.rewind
        stream
      end
    end

    alias text to_s

    def replace_entry(entry_path, file_contents)
      @replace[entry_path] = file_contents
    end

    def default_paragraph_style
      @styles&.at_xpath("w:styles/w:style[@w:type='paragraph' and @w:default='1']/w:name/@w:val")&.value
    end

    def style_name_of(style_id)
      styles_configuration.style_of(style_id).name
    end

    def styles_configuration
      @styles_configuration ||= Elements::Containers::StylesConfiguration.new(@styles.dup)
    end

    private

    # rubyzip 3.x enables ZIP64 by default, which breaks readers (e.g. pandoc)
    # that don't support it for small files (issue #168). Disable ZIP64 only
    # while writing, and restore the previous global value afterwards so we
    # don't affect other rubyzip users in the consuming application.
    def with_zip64_disabled
      previous = Zip.write_zip64_support
      Zip.write_zip64_support = false
      yield
    ensure
      Zip.write_zip64_support = previous
    end

    def load_headers
      header_files = @zip.glob("word/header*.xml").map{|h| h.name}
      filename_and_contents_pairs = header_files.map do |file|
        simple_file_name = file.sub(/^word\//, "").sub(/\.xml$/, "")
        [simple_file_name, Nokogiri::XML(@zip.read(file))]
      end
      @headers = Hash[filename_and_contents_pairs]
    end

    def load_footers
      footer_files = @zip.glob("word/footer*.xml").map{|h| h.name}
      filename_and_contents_pairs = footer_files.map do |file|
        simple_file_name = file.sub(/^word\//, "").sub(/\.xml$/, "")
        [simple_file_name, Nokogiri::XML(@zip.read(file))]
      end
      @footers = Hash[filename_and_contents_pairs]
    end

    def load_styles
      @styles_xml = @zip.read('word/styles.xml')
      @styles = Nokogiri::XML(@styles_xml)
    rescue Errno::ENOENT => e
      warn e.message
      nil
    end

    # Loaded independently of styles so that a document without word/styles.xml
    # still initializes @rels (see #158).
    def load_rels
      rels_entry = @zip.glob('word/_rels/document*.xml.rels').first
      raise Errno::ENOENT unless rels_entry

      @rels_xml = rels_entry.get_input_stream.read
      @rels = Nokogiri::XML(@rels_xml)
    rescue Errno::ENOENT => e
      warn e.message
      nil
    end

    #--
    # TODO: Flesh this out to be compatible with other files
    # TODO: Method to set flag on files that have been edited, probably by inserting something at the
    # end of methods that make edits?
    #++
    def update
      replace_entry 'word/document.xml', doc.serialize(save_with: 0)
      replace_entry 'word/styles.xml', styles_configuration.serialize(save_with: 0)
      headers.each do |name, content|
        replace_entry "word/#{name}.xml", content.serialize(save_with: 0)
      end
      footers.each do |name, content|
        replace_entry "word/#{name}.xml", content.serialize(save_with: 0)
      end
    end

    # generate Elements::Containers::Paragraph from paragraph XML node
    def parse_paragraph_from(p_node)
      Elements::Containers::Paragraph.new(p_node, document_properties, self)
    end

    # generate Elements::Bookmark from bookmark XML node
    def parse_bookmark_from(b_node)
      Elements::Bookmark.new(b_node)
    end

    def parse_table_from(t_node)
      Elements::Containers::Table.new(t_node, document_properties, self)
    end
  end
end
