#### libremiliacr
#### Copyright(C) 2020-2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or modify
#### it under the terms of the GNU General Public License as published
#### the Free Software Foundation, either version 3 of the License, or
#### (at your option) any later version.
####
#### This program 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 General Public License for more details.
####
#### You should have received a copy of the GNU General Public License
#### along with this program.If not, see<http:####www.gnu.org/licenses/.>
require "./resolver"
require "json"
require "yaml"

module RemiLib::Config
  annotation ConfigOpts
  end

  # The `ConfigFile` module is included in classes that represent a
  # configuration file.  This module must be included for the library to handle
  # the configuration (de)serialization correctly.
  #
  # Any class that includes `ConfigFile` must also be annotated with
  # `ConfigOpts` to declare the associated configuration filename and format.
  # For example:
  #
  # ```crystal
  # require "libremiliacr"
  #
  # module SomeProgram
  #   # Define a class to represent a configuration file named "test.json".
  #   @[RemiConf::ConfigOpts(filename: "test.json", format: :json)]
  #   class Config
  #     include ::RemiConf::ConfigFile
  #     property username : String = ""
  #   end
  #
  #   # Define a resolver that will automatically locate config files.
  #   res = RemiConf::Resolver.xdg("some-program")
  #
  #   # Read the config.  Since we're using an xdg resolver, this will look for the
  #   # file at $XDG_CONFIG_HOME/some-program/test.json
  #   config = Config.readConfig(res)
  #   puts config.username
  # end
  # ```
  module ConfigFile
    # :nodoc:
    macro included
      {% ann = @type.annotation(::RemiLib::Config::ConfigOpts) %}
      {% if ann %}
        class_getter filename : String =
        {% if ann[:filename] %}
           {{ann[:filename]}}
        {% else %}
          {% raise "#{@type} included RemiLib::Config::ConfigFile, but does not have a filename in its annotation" %}
        {% end %}

        {% if ann[:format] %}
          {% if ann[:format] == :json %}
            include JSON::Serializable
          {% elsif ann[:format] == :yaml %}
            include YAML::Serializable
          {% elsif ann[:format] == :hjson %}
            {% if flag?(:remiconf_no_hjson) %}
              {% raise "A config type of hjson was requested, but remiconf_no_hjson was used" %}
            {% else %}
              include RemiHjson::Serializable
            {% end %}
          {% else %}
            {%
             raise "#{@type} included RemiLib::Config::ConfigFile, but the format '#{ann[:format]} is not valid"
            %}
          {% end %}
        {% else %}
          {% raise "#{@type} included RemiLib::Config::ConfigFile, but does not have a format in its annotation" %}
        {% end %}
      {% else %}
        {% raise "#{@type} included RemiLib::Config::ConfigFile, but has no annotation" %}
      {% end %}

      # Returns the path associated with this configuration file.  For example,
      # if the `Resolver` has a `Resolver#configDir` of `test-program`, and the
      # including class is associated with a configuration file named
      # "settings.yaml", this will return `<system-specific config
      # dir>/test-program/settings.yaml`.
      def self.configPath(res : RemiLib::Config::Resolver) : Path
        Path[res.configDir, {{@type}}.filename]
      end

      # Deserializes a new instance of the including class from `io` and returns
      # that new instance.
      def self.readConfig(io : IO) : {{@type}}
        {% if ann[:format] == :json %}
          {{@type}}.from_json(io)
        {% elsif ann[:format] == :yaml %}
          {{@type}}.from_yaml(io)
        {% elsif ann[:format] == :hjson %}
          {% if flag?(:remiconf_no_hjson) %}
            {% raise "A config type of hjson was requested, but remiconf_no_hjson was used" %}
          {% else %}
            %parser = RemiHjson::Parser.new(io.gets_to_end)
          {% end %}
          {{@type}}.new(%parser)
        {% else %}
          {% raise "#{@type} included RemiLib::Config::ConfigFile, but the format '#{ann[:format]} is not valid" %}
        {% end %}
      end

      # Looks for the associated configuration file using the given `Resolver`,
      # then deserializes it and returns a new instance of the including class.
      # This will raise an exception if the file does not exist (see
      # `File.open`).
      def self.readConfig(res : RemiLib::Config::Resolver) : {{@type}}
        File.open({{@type}}.configPath(res)) do |file|
          return {{@type}}.readConfig(file)
        end
      end

      # Looks for the associated configuration file using the given `Resolver`.
      # If the file is found, this deserializes it to a new instance of the
      # including class, then returns the new instance and `true`.  If the file
      # was not found, this returns a new instance by calling `#new` and
      # `false`.
      def self.readConfig?(res : RemiLib::Config::Resolver) : Tuple({{@type}}, Bool)
        filename = {{@type}}.configPath(res)
        if File.exists?(filename)
          File.open(filename) do |file|
            return { {{@type}}.readConfig(file), true }
          end
        else
          return { {{@type}}.new, false }
        end
      end

      # Looks for the associated configuration file using the given
      # `ResolverSet`.  If the file is found, this deserializes it to a new
      # instance of the including class, then returns the new instance and
      # `true`.  If the file was not found, this returns a new instance by
      # calling `#new` and `false`.
      def self.readConfig?(res : RemiLib::Config::ResolverSet) : Tuple({{@type}}, Bool)
        ret = {{@type}}.new
        found = false

        res.each do |resolv|
          ret, found = readConfig?(resolv)
          break if found
        end

        return { ret, found }
      end
    end

    # Serializes this instance to `io` using the associated format.
    def write(io : IO)
      {% begin %}
        {% ann = @type.annotation(::RemiLib::Config::ConfigOpts) %}
        {% if ann[:format] == :json %}
          self.to_json(io)
        {% elsif ann[:format] == :yaml %}
          self.to_yaml(io)
        {% elsif ann[:format] == :hjson %}
          {% if flag?(:remiconf_no_hjson) %}
            {% raise "A config type of hjson was requested, but remiconf_no_hjson was used" %}
          {% else %}
            self.toHjson(io)
          {% end %}
        {% else %}
          {% raise "#{@type} included RemiLib::Config::ConfigFile, but the format '#{ann[:format]} is not valid" %}
        {% end %}
      {% end %}
    end

    # Serializes this instance using the associated format, then writes the
    # serialized data to `filename`.  Any existing file will be overwritten.
    def write(filename : Path)
      File.open(filename, "w") do |file|
        write(file)
      end
    end

    # Serializes this instance using the associated format, then writes the
    # serialized data to the associated configuration file.  Any existing file
    # will be overwritten.
    def write(res : Resolver)
      {% begin %}
        write({{@type}}.configPath(res))
      {% end %}
    end
  end
end
