# Copyright (C) 2012-2023  Sutou Kouhei <kou@clear-code.com>
# Copyright (C) 2010  masone (Christian Felder) <ema@rh-productions.ch>
# Copyright (C) 2009  Masao Mutoh
#
# License: Ruby's or LGPL
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

require "gettext/po_format"

module GetText
  class ParseError < StandardError
  end

  # Contains data related to the expression or sentence that
  # is to be translated.
  class POEntry
    class InvalidTypeError < StandardError
    end

    class NoMsgidError < StandardError
    end

    class NoMsgctxtError < StandardError
    end

    class NoMsgidPluralError < StandardError
    end

    PARAMS = {
      :normal => [:msgid, :separator, :msgstr],
      :plural => [:msgid, :msgid_plural, :separator, :msgstr],
      :msgctxt => [:msgctxt, :msgid, :msgstr],
      :msgctxt_plural => [:msgctxt, :msgid, :msgid_plural, :msgstr]
    }

    # Required
    attr_reader :type          # :normal, :plural, :msgctxt, :msgctxt_plural
    attr_accessor :msgid
    attr_accessor :msgstr
    # Options
    attr_accessor :msgid_plural
    attr_accessor :separator
    attr_accessor :msgctxt
    attr_accessor :references    # ["file1:line1", "file2:line2", ...]
    attr_accessor :translator_comment
    attr_accessor :extracted_comment
    # @return [Array<String>] The flags for this PO entry.
    # @since 3.0.4
    attr_accessor :flags
    attr_accessor :previous
    attr_accessor :comment

    # Create the object. +type+ should be :normal, :plural, :msgctxt or :msgctxt_plural.
    def initialize(type)
      self.type = type
      @translator_comment = nil
      @extracted_comment = nil
      @references = []
      @flags = []
      @previous = nil
      @msgctxt = nil
      @msgid = nil
      @msgid_plural = nil
      @msgstr = nil
    end

    # Support for extracted comments. Explanation s.
    # http://www.gnu.org/software/gettext/manual/gettext.html#Names
    # @return [void]
    def add_comment(new_comment)
      if (new_comment and ! new_comment.empty?)
        @extracted_comment ||= String.new
        @extracted_comment << "\n" unless @extracted_comment.empty?
        @extracted_comment << new_comment
      end
    end

    # @return [String, nil] The flag of the PO entry.
    # @deprecated Since 3.0.4. Use {#flags} instead.
    def flag
      @flags.first
    end

    # Set the new flag for the PO entry.
    #
    # @param [String, nil] flag The new flag.
    # @deprecated Since 3.0.4. Use {#flags=} instead.
    def flag=(flag)
      if flag.nil?
        @flags = []
      else
        @flags = [flag]
      end
    end

    # Checks if the self has same attributes as other.
    def ==(other)
      not other.nil? and
        type == other.type and
        msgid == other.msgid and
        msgstr == other.msgstr and
        msgid_plural == other.msgid_plural and
        separator == other.separator and
        msgctxt == other.msgctxt and
        translator_comment == other.translator_comment and
        extracted_comment == other.extracted_comment and
        references == other.references and
        flags == other.flags and
        previous == other.previous and
        comment == other.comment
    end

    def type=(type)
      unless PARAMS.has_key?(type)
        raise(InvalidTypeError, "\"%s\" is invalid type." % type)
      end
      @type = type
      @param_type = PARAMS[@type]
    end

    # Checks if the other translation target is mergeable with
    # the current one. Relevant are msgid and translation context (msgctxt).
    def mergeable?(other)
      other && other.msgid == self.msgid && other.msgctxt == self.msgctxt
    end

    # Merges two translation targets with the same msgid and returns the merged
    # result. If one is declared as plural and the other not, then the one
    # with the plural wins.
    def merge(other)
      return self unless other
      unless mergeable?(other)
        message = "Translation targets do not match: \n" +
          "  self: #{self.inspect}\n  other: '#{other.inspect}'"
        raise ParseError, message
      end
      if other.msgid_plural && !msgid_plural
        res = other
        unless res.references.include?(references[0])
          res.references += references
          res.add_comment(extracted_comment)
        end
      else
        res = self
        unless res.references.include?(other.references[0])
          res.references += other.references
          res.add_comment(other.extracted_comment)
        end
      end
      res
    end

    # Format the po entry in PO format.
    #
    # @param [Hash] options
    # @option options (see Formatter#initialize)
    def to_s(options={})
      raise(NoMsgidError, "msgid is nil.") unless @msgid

      formatter = Formatter.new(self, options)
      formatter.format
    end

    # Returns true if the type is kind of msgctxt.
    def msgctxt?
      [:msgctxt, :msgctxt_plural].include?(@type)
    end

    # Returns true if the type is kind of plural.
    def plural?
      [:plural, :msgctxt_plural].include?(@type)
    end

    # @return true if the entry is header entry, false otherwise.
    #   Header entry is normal type and has empty msgid.
    def header?
      @type == :normal and @msgid == ""
    end

    # @return true if the entry is obsolete entry, false otherwise.
    #   Obsolete entry is normal type and has :last msgid.
    def obsolete?
      @type == :normal and @msgid == :last
    end

    # @return true if the entry is fuzzy entry, false otherwise.
    #   Fuzzy entry has "fuzzy" flag.
    def fuzzy?
      @flags.include?("fuzzy")
    end

    # @return true if the entry is translated entry, false otherwise.
    def translated?
      return false if fuzzy?
      return false if @msgstr.nil? or @msgstr.empty?
      true
    end

    def [](number_or_param)
      __send__(resolve_param(number_or_param))
    end

    def []=(number_or_param, value)
      __send__("#{resolve_param(number_or_param)}=", value)
    end

    private
    def resolve_param(number_or_param)
      case number_or_param
      when Integer
        param = @param_type[number_or_param]
        raise ParseError, 'no more string parameters expected' unless param
        param
      else
        number_or_param
      end
    end

    # sets or extends the value of a translation target params like msgid,
    # msgctxt etc.
    #   param is symbol with the name of param
    #   value - new value
    def set_value(param, value)
      send "#{param}=", (send(param) || '') + value
    end

    class Formatter
      class << self
        def escape(string)
          return "" if string.nil?

          string.gsub(/[\\"\t\r\n]/) do |special_character|
            case special_character
            when "\t"
              "\\t"
            when "\r"
              "\\r"
            when "\n"
              "\\n"
            else
              "\\#{special_character}"
            end
          end
        end
      end

      include POFormat

      DEFAULT_MAX_LINE_WIDTH = 78

      # @param [POEntry] entry The entry to be formatted.
      # @param [Hash] options
      # @option options [Bool] :include_translator_comment (true)
      #   Includes translator comments in formatted string if true.
      # @option options [Bool] :include_extracted_comment (true)
      #   Includes extracted comments in formatted string if true.
      # @option options [Bool] :include_reference_comment (true)
      #   Includes reference comments in formatted string if true.
      # @option options [Bool] :include_flag_comment (true)
      #   Includes flag comments in formatted string if true.
      # @option options [Bool] :include_previous_comment (true)
      #   Includes previous comments in formatted string if true.
      # @option options [Bool] :include_all_comments (true)
      #   Includes all comments in formatted string if true.
      #   Other specific `:include_XXX` options get preference over
      #   this option.
      #   You can remove all comments by specifying this option as
      #   false and omitting other `:include_XXX` options.
      # @option options [Integer] :max_line_width (78)
      #   Wraps long lines that is longer than the `:max_line_width`.
      #   Don't break long lines if `:max_line_width` is less than 0
      #   such as `-1`.
      # @option options [Bool] :use_one_line_per_reference (false)
      #   Whether each reference comment uses one line or not. If this
      #   is `true`, `:max_line_width` is ignored for reference comment.
      # @option options [Encoding] :encoding (nil)
      #   Encodes to the specific encoding.
      def initialize(entry, options={})
        @entry = entry
        @options = normalize_options(options)
      end

      def format
        if @entry.obsolete?
          return format_obsolete_comment(@entry.comment)
        end

        str = format_comments

        # msgctxt, msgid, msgstr
        if @entry.msgctxt?
          if @entry.msgctxt.nil?
            no_msgctxt_message = "This POEntry is a kind of msgctxt " +
                                   "but the msgctxt property is nil. " +
                                   "msgid: #{@entry.msgid}"
            raise(NoMsgctxtError, no_msgctxt_message)
          end
          str << "msgctxt " << format_message(@entry.msgctxt)
        end

        str << "msgid " << format_message(@entry.msgid)
        if @entry.plural?
          if @entry.msgid_plural.nil?
            no_plural_message = "This POEntry is a kind of plural " +
                                  "but the msgid_plural property is nil. " +
                                  "msgid: #{@entry.msgid}"
            raise(NoMsgidPluralError, no_plural_message)
          end

          str << "msgid_plural " << format_message(@entry.msgid_plural)

          if @entry.msgstr.nil?
            str << "msgstr[0] \"\"\n"
            str << "msgstr[1] \"\"\n"
          else
            msgstrs = @entry.msgstr.split("\000", -1)
            msgstrs.each_with_index do |msgstr, index|
              str << "msgstr[#{index}] " << format_message(msgstr)
            end
          end
        else
          str << "msgstr "
          str << format_message(@entry.msgstr)
        end

        encode(str)
      end

      private
      def normalize_options(options)
        options = options.dup
        include_comment_keys = [
          :include_translator_comment,
          :include_extracted_comment,
          :include_reference_comment,
          :include_flag_comment,
          :include_previous_comment,
        ]
        if options[:include_all_comments].nil?
          options[:include_all_comments] = true
        end
        default_include_comment_value = options[:include_all_comments]
        include_comment_keys.each do |key|
          options[key] = default_include_comment_value if options[key].nil?
        end
        options[:max_line_width] ||= DEFAULT_MAX_LINE_WIDTH
        options
      end

      def include_translator_comment?
        @options[:include_translator_comment]
      end

      def include_extracted_comment?
        @options[:include_extracted_comment]
      end

      def include_reference_comment?
        @options[:include_reference_comment]
      end

      def include_flag_comment?
        @options[:include_flag_comment]
      end

      def include_previous_comment?
        @options[:include_previous_comment]
      end

      def format_comments
        formatted_comment = String.new
        if include_translator_comment?
          formatted_comment << format_translator_comment
        end
        if include_extracted_comment?
          formatted_comment << format_extracted_comment
        end
        if include_reference_comment?
          formatted_comment << format_reference_comment
        end
        if include_flag_comment?
          formatted_comment << format_flag_comment
        end
        if include_previous_comment?
          formatted_comment << format_previous_comment
        end
        formatted_comment
      end

      def format_translator_comment
        format_comment("#", @entry.translator_comment)
      end

      def format_extracted_comment
        format_comment(EXTRACTED_COMMENT_MARK, @entry.extracted_comment)
      end

      def format_reference_comment
        formatted_reference = String.new
        if @options[:use_one_line_per_reference]
          @entry.references&.each do |reference|
            formatted_reference << "#{REFERENCE_COMMENT_MARK} #{reference}\n"
          end
        else
          max_line_width = @options[:max_line_width]
          if not @entry.references.nil? and not @entry.references.empty?
            formatted_reference << REFERENCE_COMMENT_MARK
            line_width = 2
            @entry.references.each do |reference|
              if max_line_width > 0 and
                line_width + reference.size > max_line_width
                formatted_reference << "\n"
                formatted_reference <<  "#{REFERENCE_COMMENT_MARK} #{reference}"
                line_width = 3 + reference.size
              else
                formatted_reference << " #{reference}"
                line_width += 1 + reference.size
              end
            end
            formatted_reference << "\n"
          end
        end
        formatted_reference
      end

      def format_flag_comment
        if @entry.flags.empty?
          String.new
        else
          joined_flags = @entry.flags.join(", ")
          format_comment(FLAG_MARK, joined_flags)
        end
      end

      def format_previous_comment
        format_comment(PREVIOUS_COMMENT_MARK, @entry.previous)
      end

      def format_comment(mark, comment)
        return "" if comment.nil?

        formatted_comment = String.new
        comment.each_line do |comment_line|
          if comment_line == "\n"
            formatted_comment << "#{mark}\n"
          else
            formatted_comment << "#{mark} #{comment_line.strip}\n"
          end
        end
        formatted_comment
      end

      def format_obsolete_comment(comment)
        mark = "#~"
        return "" if comment.nil?

        formatted_comment = String.new
        comment.each_line do |comment_line|
          if /\A#[^~]/ =~ comment_line or comment_line.start_with?(mark)
            formatted_comment << "#{comment_line.chomp}\n"
          elsif comment_line == "\n"
            formatted_comment << "\n"
          else
            formatted_comment << "#{mark} #{comment_line.strip}\n"
          end
        end
        formatted_comment
      end

      def format_message(message)
        empty_formatted_message = "\"\"\n"
        return empty_formatted_message if message.nil?

        chunks = wrap_message(message)
        return empty_formatted_message if chunks.empty?

        formatted_message = String.new
        if chunks.size > 1 or chunks.first.end_with?("\n")
          formatted_message << empty_formatted_message
        end
        chunks.each do |chunk|
          formatted_message << "\"#{escape(chunk)}\"\n"
        end
        formatted_message
      end

      def escape(string)
        self.class.escape(string)
      end

      def wrap_message(message)
        return [message] if message.empty?

        max_line_width = @options[:max_line_width]

        chunks = []
        message.each_line do |line|
          if max_line_width <= 0
            chunks << line
          else
            # TODO: use character width instead of the number of characters
            line.scan(/.{1,#{max_line_width}}/m) do |chunk|
              chunks << chunk
            end
          end
        end
        chunks
      end

      def encode(string)
        encoding = @options[:encoding]
        return string if encoding.nil?
        string.encode(encoding)
      end
    end
  end
end
