# Copyright (C) 2012-2023  Sutou Kouhei <kou@clear-code.com>
#
# 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 "pathname"
require "rake"
require "rake/clean"

require "gettext/tools"

module GetText
  module Tools
    class Task
      class Error < StandardError
      end

      class ValidationError < Error
        attr_reader :reasons
        def initialize(reasons)
          @reasons = reasons
          lines = []
          lines << "invalid configurations:"
          @reasons.each do |variable, reason|
            lines << "#{variable}: #{reason}"
          end
          super(lines.join("\n"))
        end
      end

      include GetText
      include Rake::DSL

      class << self
        # Define gettext related Rake tasks. Normally, use this method
        # to define tasks because this method is a convenient API.
        #
        # See accessor APIs how to configure this task.
        #
        # See {#define} for what task is defined.
        #
        # @example Recommended usage
        #   require "gettext/tools/task"
        #   # Recommended usage
        #   GetText::Tools::Task.define do |task|
        #     task.spec = spec
        #     # ...
        #   end
        #   # Low level API
        #   task = GetText::Tools::Task.new
        #   task.spec = spec
        #   # ...
        #   task.define
        #
        # @yield [task] Gives the newely created task to the block.
        # @yieldparam [GetText::Tools::Task] task The task that should be
        #   configured.
        # @see #define
        # @return [void]
        def define
          task = new
          yield(task)
          task.define
        end
      end

      # @return [Gem::Specification, nil] Package information associated
      #   with the task.
      attr_reader :spec

      # @return [String, nil] Package name for messages.
      attr_accessor :package_name

      # @return [String, nil] Package version for messages.
      attr_accessor :package_version

      # It is a required parameter.
      #
      # @return [Array<String>] Supported locales. It is filled from
      #   {#po_base_directory} by default.
      attr_accessor :locales
      attr_accessor :po_base_directory
      # @return [String] Base directory that has generated MOs. MOs
      #   are generated into
      #   `#{mo_base_directory}/#{locale}/LC_MESSAGES/#{domain}.mo`.
      attr_accessor :mo_base_directory
      # It is a required parameter.
      #
      # @return [Array<String>] Files that have messages.
      attr_accessor :files
      # It is a required parameter.
      #
      # @return [String] Text domain
      attr_accessor :domain

      # It is useful when you have multiple domains. You can define tasks
      # for each domains by using different namespace prefix.
      #
      # It is `nil` by default. It means that tasks are defined at top
      # level.
      #
      # TODO: example
      #
      # @return [String] Namespace prefix for tasks defined by this class.
      attr_accessor :namespace_prefix

      # @return [Array<String>] Command line options for extracting messages
      #   from sources.
      # @see GetText::Tools::XGetText
      # @see `rxgettext --help`
      attr_accessor :xgettext_options

      # @return [Array<String>] Command line options for creating PO from POT.
      # @see GetText::Tools::MsgInit
      # @see `rmsginit --help`
      attr_accessor :msginit_options

      # @return [Array<String>] Command line options for merging PO with the
      #   latest POT.
      # @see GetText::Tools::MsgMerge
      # @see `rmsgmerge --help`
      attr_accessor :msgmerge_options

      # @return [Array<String>] Command line options for filtering PO.
      # @see GetText::Tools::MsgCat
      # @see `rmsgcat --help`
      # @since 3.1.3
      attr_accessor :msgcat_options

      # @return [Bool]
      # @see #enable_description? For details.
      attr_writer :enable_description

      # @return [Bool]
      # @see #enable_po? For details.
      attr_writer :enable_po

      # It is used to custom how to create POT file. The object must have
      # `call` method. The method must accept one argument. The argument
      #  is a `Pathname` object that represents POT file path.
      #
      # @example
      #
      #   GetText::Tools::Task.define do |task|
      #     task.pot_creator = lambda do |pot_file_path|
      #       pot_file_path.open("w") do |pot_file|
      #         pot_file << <<-POT
      #   msgid "Hello"
      #   msgstr ""
      #         POT
      #       end
      #     end
      #   end
      #
      #
      # @return [Proc]
      attr_accessor :pot_creator

      # @param [Gem::Specification, nil] spec Package information associated
      #   with the task. Some information are extracted from the spec.
      # @see #spec= What information are extracted from the spec.
      def initialize(spec=nil)
        initialize_variables
        self.spec = spec
        if spec
          yield(self) if block_given?
          warn("Use #{self.class.name}.define instead of #{self.class.name}.new(spec).")
          define
        end
      end

      # Sets package infromation by Gem::Specification. Here is a list
      # for information extracted from the spec:
      #
      #   * {#package_name}
      #   * {#package_version}
      #   * {#domain}
      #   * {#files}
      #
      # @param [Gem::Specification] spec package information for the
      #   i18n application.
      def spec=(spec)
        @spec = spec
        return if @spec.nil?

        @package_name = spec.name
        @package_version = spec.version.to_s
        @domain ||= spec.name
        @files += target_files
      end

      # Define tasks from configured parameters.
      #
      # TODO: List defined Rake tasks.
      def define
        ensure_variables
        validate

        define_file_tasks
        if namespace_prefix
          namespace_recursive namespace_prefix do
            define_named_tasks
          end
        else
          define_named_tasks
        end
      end

      # If it is true, each task has description. Otherwise, all tasks
      # doesn't have description.
      #
      # @return [Bool]
      # @since 3.0.1
      def enable_description?
        @enable_description
      end

      # If it is true, PO related tasks are defined. Otherwise, they
      # are not defined.
      #
      # This parameter is useful to manage PO written by hand.
      #
      # @return [Bool]
      # @since 3.0.1
      def enable_po?
        @enable_po
      end

      private
      def initialize_variables
        @spec = nil
        @package_name = nil
        @package_version = nil
        @locales = []
        @po_base_directory = "po"
        @mo_base_directory = "locale"
        @files = []
        @domain = nil
        @namespace_prefix = nil
        @xgettext_options = []
        @msginit_options = []
        @msgmerge_options = []
        @msgcat_options = []
        @enable_description = true
        @enable_po = true
        @pot_creator = nil
      end

      def ensure_variables
        @locales = detect_locales if @locales.empty?
      end

      def validate
        reasons = {}
        if @locales.empty?
          reasons["locales"] = "must set one or more locales"
        end
        if @enable_po and @files.empty?
          reasons["files"] = "must set one or more files"
        end
        if @domain.nil?
          reasons["domain"] = "must set domain"
        end
        raise ValidationError.new(reasons) unless reasons.empty?
      end

      def desc(*args)
        return unless @enable_description
        super
      end

      def define_file_tasks
        define_pot_file_task

        locales.each do |locale|
          define_edit_po_file_task(locale)
          define_po_file_task(locale)
          define_mo_file_task(locale)
        end
      end

      def define_pot_file_task
        return unless @enable_po

        path = create_path
        pot_dependencies = files.dup
        unless path.po_base_directory.exist?
          directory path.po_base_directory.to_s
          pot_dependencies << path.po_base_directory.to_s
        end
        file path.pot_file.to_s => pot_dependencies do
          create_pot(path.pot_file)
        end
      end

      def create_pot(pot_file_path)
        if @pot_creator
          @pot_creator.call(pot_file_path)
        else
          xgettext(pot_file_path)
        end
      end

      def xgettext(pot_file_path)
        command_line = [
          "--output", pot_file_path.to_s,
        ]
        if package_name
          command_line.concat(["--package-name", package_name])
        end
        if package_version
          command_line.concat(["--package-version", package_version])
        end
        command_line.concat(@xgettext_options)
        command_line.concat(files)
        XGetText.run(*command_line)
      end

      def define_edit_po_file_task(locale)
        return unless @enable_po

        path = create_path(locale)
        directory path.edit_po_directory.to_s
        dependencies = [
          path.pot_file.to_s,
          path.edit_po_directory.to_s,
        ]
        if path.po_file_is_updated?
          dependencies << internal_force_task_name
        end
        file path.edit_po_file.to_s => dependencies do
          if path.po_file_is_updated?
            rm_f(path.edit_po_file.to_s)
          end
          unless path.edit_po_file.exist?
            if path.po_file.exist?
              cp(path.po_file.to_s, path.edit_po_file.to_s)
            else
              MsgInit.run("--input", path.pot_file.to_s,
                          "--output", path.edit_po_file.to_s,
                          "--locale", path.locale,
                          "--no-translator",
                          *@msginit_options)
            end
          end

          edit_po_file_mtime = path.edit_po_file.mtime
          MsgMerge.run("--update",
                       "--sort-by-file",
                       "--no-wrap",
                       *@msgmerge_options,
                       path.edit_po_file.to_s,
                       path.pot_file.to_s)
          if path.po_file.exist? and path.po_file.mtime > edit_po_file_mtime
            MsgMerge.run("--output", path.edit_po_file.to_s,
                         "--sort-by-file",
                         "--no-wrap",
                         "--no-obsolete-entries",
                         *@msgmerge_options,
                         path.po_file.to_s,
                         path.edit_po_file.to_s)
          end
        end
      end

      def define_po_file_task(locale)
        return unless @enable_po

        path = create_path(locale)
        directory path.po_directory.to_s
        dependencies = [
          path.edit_po_file.to_s,
          path.po_directory.to_s,
        ]
        CLEAN << path.po_time_stamp_file.to_s
        file path.po_file.to_s => dependencies do
          MsgCat.run("--output", path.po_file.to_s,
                     "--sort-by-file",
                     "--no-location",
                     "--no-report-warning",
                     "--no-obsolete-entries",
                     "--remove-header-field=POT-Creation-Date",
                     *@msgcat_options,
                     path.edit_po_file.to_s)
          touch(path.po_time_stamp_file.to_s)
        end
      end

      def define_mo_file_task(locale)
        path = create_path(locale)
        directory path.mo_directory.to_s
        mo_dependencies = [
          path.po_file.to_s,
          path.mo_directory.to_s,
        ]
        file path.mo_file.to_s => mo_dependencies do
          MsgFmt.run(path.po_file.to_s, "--output", path.mo_file.to_s)
        end
      end

      def define_named_tasks
        namespace :gettext do
          define_internal_tasks
          if @enable_po
            define_pot_tasks
            define_po_tasks
          end

          define_mo_tasks
        end

        desc "Update *.mo"
        task :gettext => (current_scope + ["gettext", "mo", "update"]).join(":")
      end

      def define_internal_tasks
        namespace :internal do
          task :force
        end
      end

      def internal_force_task_name
        [namespace_prefix, "gettext", "internal", "force"].compact.join(":")
      end

      def define_pot_tasks
        namespace :pot do
          path = create_path
          desc "Create #{path.pot_file}"
          task :create => path.pot_file.to_s
        end
      end

      def define_po_tasks
        namespace :po do
          desc "Add a new locale"
          task :add, [:locale] do |_task, args|
            locale = args.locale || ENV["LOCALE"]
            if locale.nil?
              raise "specify locale name by " +
                    "rake '#{_task.name}[${LOCALE}]' or " +
                    "rake #{_task.name} LOCALE=${LOCALE}'"
            end
            define_edit_po_file_task(locale)
            define_po_file_task(locale)
            path = create_path(locale)
            Rake::Task[path.po_file].invoke
          end

          update_tasks = []
          @locales.each do |locale|
            namespace locale do
              path = create_path(locale)
              desc "Update #{path.po_file}"
              task :update => path.po_file.to_s
              update_tasks << (current_scope + ["update"]).join(":")
            end
          end

          desc "Update *.po"
          task :update => update_tasks
        end
      end

      def define_mo_tasks
        namespace :mo do
          update_tasks = []
          @locales.each do |locale|
            namespace locale do
              path = create_path(locale)
              desc "Update #{path.mo_file}"
              task :update => path.mo_file.to_s
              update_tasks << (current_scope + ["update"]).join(":")
            end
          end

          desc "Update *.mo"
          task :update => update_tasks
        end
      end

      def create_path(locale=nil)
        locale = locale.to_s if locale.is_a?(Symbol)
        Path.new(Pathname.new(@po_base_directory),
                 Pathname.new(@mo_base_directory),
                 @domain,
                 locale)
      end

      def target_files
        files = @spec.files.find_all do |file|
          /\A\.(?:rb|erb|glade)\z/i =~ File.extname(file)
        end
        files += @spec.executables.collect do |executable|
          "bin/#{executable}"
        end
        files
      end

      def detect_locales
        locales = []
        return locales unless File.exist?(po_base_directory)

        Dir.open(po_base_directory) do |dir|
          dir.each do |entry|
            next unless /\A[a-z]{2,3}(?:_[A-Z]{2})?\z/ =~ entry
            next unless File.directory?(File.join(dir.path, entry))
            locales << entry
          end
        end
        locales
      end

      def current_scope
        scope = Rake.application.current_scope
        if scope.is_a?(Array)
          scope
        else
          if scope.empty?
            []
          else
            [scope.path]
          end
        end
      end

      def namespace_recursive(namespace_spec, &block)
        first, rest = namespace_spec.split(/:/, 2)
        namespace first do
          if rest.nil?
            block.call
          else
            namespace_recursive(rest, &block)
          end
        end
      end

      class Path
        attr_reader :po_base_directory
        attr_reader :mo_base_directory
        attr_reader :domain
        attr_reader :locale
        def initialize(po_base_directory, mo_base_directory, domain, locale=nil)
          @po_base_directory = po_base_directory
          @mo_base_directory = mo_base_directory
          @domain = domain
          @locale = locale
        end

        def pot_file
          @po_base_directory + "#{@domain}.pot"
        end

        def po_directory
          @po_base_directory + @locale
        end

        def po_file
          po_directory + "#{@domain}.po"
        end

        def po_time_stamp_file
          po_directory + "#{@domain}.po.time_stamp"
        end

        def po_file_is_updated?
          return false unless po_file.exist?
          return true unless po_time_stamp_file.exist?
          po_file.mtime > po_time_stamp_file.mtime
        end

        def edit_po_directory
          po_directory
        end

        def edit_po_file
          edit_po_directory + "#{@domain}.edit.po"
        end

        def mo_directory
          @mo_base_directory + @locale + "LC_MESSAGES"
        end

        def mo_file
          mo_directory + "#{@domain}.mo"
        end
      end
    end
  end
end
