# typed: strict
require 'yaml'
require 'json'
require 'yaml/store'

# Some info about the configuration versioning design:
# https://github.com/cristibalan/braid/issues/66#issuecomment-354211311
#
# Current configuration format:
# ```
# {
#   "config_version": 1,
#   "mirrors": {
#     <mirror path: string>: {
#       "url": <upstream URL: string>,
#       "path": <remote path: string>,
#       "branch": <upstream branch: string>,
#       "tag": <upstream tag: string>,
#       "revision": <current upstream revision: string>
#     }
#   }
# }
# ```
#
# History of configuration formats understood by current Braid:
#
# - Braid 1.1.0, config_version 1:
#   - "config_version" introduced; mirrors moved to "mirrors"
#   - Single-file mirrors (f340b0c)
# - Braid 1.0.18:
#   - Locked mirrors indicated by absence of "branch" and "tag" attributes, not
#     presence of "lock" attribute (e6535aa)
# - Braid 1.0.17:
#   - Support for full-history mirrors ("squashed": false) removed; "squashed"
#     attribute no longer written (eb72030)
# - Braid 1.0.11:
#   - "remote" attribute no longer written (f8fd088)
# - Braid 1.0.9:
#   - .braids -> .braids.json (6806c61)
# - Braid 1.0.0:
#   - YAML -> JSON (9d3fa11)
#   - Support for Subversion mirrors removed ("type": "svn") removed (9d8d390)
#
#
# (Entries that predate the creation of this list have commit IDs for reference.
# Of course, when adding a new entry, you can't add the commit ID in the same
# commit, but you don't need to because people can just run `git log` on this
# file.)

module Braid
  class Config
    extend T::Sig

    # TODO (typing): Migrate to T::Enum?
    ConfigMode = T.type_alias { Integer }

    MODE_UPGRADE = 1
    MODE_READ_ONLY = 2
    MODE_MAY_WRITE = 3

    CURRENT_CONFIG_VERSION = 1

    class PathAlreadyInUse < BraidError
      sig {returns(String)}
      def message
        "path already in use: #{super}"
      end
    end
    class MirrorDoesNotExist < BraidError
      sig {returns(String)}
      def message
        "mirror does not exist: #{super}"
      end
    end

    class RemoveMirrorDueToBreakingChange < StandardError
    end

    # For upgrade-config command only.  XXX: Ideally would be immutable.
    sig {returns(Integer)}
    attr_reader :config_version
    sig {returns(T::Boolean)}
    attr_reader :config_existed
    sig {returns(T::Array[String])}
    attr_reader :breaking_change_descs

    sig {params(config_file: String, old_config_files: T::Array[String], mode: ConfigMode).void}
    def initialize(config_file: CONFIG_FILE, old_config_files: [OLD_CONFIG_FILE], mode: MODE_MAY_WRITE)
      @config_file     = config_file
      old_config_files = old_config_files
      @mode            = mode

      data = load_config(@config_file, old_config_files)
      @config_existed = T.let(!data.nil?, T::Boolean)
      if !@config_existed
        @config_version = T.let(CURRENT_CONFIG_VERSION, Integer)
        @db = T.let({}, T::Hash[String, Mirror::MirrorAttributes])
      elsif data['config_version'].is_a?(Numeric)
        @config_version = data['config_version']
        # WARNING (typing): In general, slapping a type annotation on the loaded
        # data without validating its structure could be misleading as we work
        # on the rest of the code.  In the worst case, it might lead us to
        # delete useful sanity checks because they trigger Sorbet unreachable
        # code errors.  As of this writing (2024-08-04), we have nothing to lose
        # because we don't attempt to defend against malformed data anyway, and
        # there's a benefit to having the type information available.  If we add
        # any validation, consider whether the annotations should be changed to
        # verify that we did the validation correctly, e.g., by starting with a
        # type like `T.anything` instead of `T.untyped`.
        @db = data['mirrors']
      else
        # Before config versioning (Braid < 1.1.0)
        @config_version = 0
        @db = data
      end

      if @config_version > CURRENT_CONFIG_VERSION
        raise BraidError, <<-MSG
This version of Braid (#{VERSION}) is too old to understand your project's Braid
configuration file (version #{@config_version}).  See the instructions at
https://cristibalan.github.io/braid/config_versions.html to install and use a
compatible newer version of Braid.
MSG
      end

      # In all modes, instantiate all mirrors to scan for breaking changes.
      @breaking_change_descs = T.let([], T::Array[String])
      paths_to_delete = []
      @db.each do |path, attributes|
        begin
          mirror = Mirror.new(path, attributes,
            lambda {|desc| @breaking_change_descs.push(desc)})
          # In MODE_UPGRADE, update @db now.  In other modes, we won't write the
          # config if an upgrade is needed, so it doesn't matter that we don't
          # update @db.
          #
          # It's OK to change the values of existing keys during iteration:
          # https://groups.google.com/d/msg/comp.lang.ruby/r5OI6UaxAAg/SVpU0cktmZEJ
          write_mirror(mirror) if @mode == MODE_UPGRADE
        rescue RemoveMirrorDueToBreakingChange
          # I don't know if deleting during iteration is well-defined in all
          # Ruby versions we support, so defer the deletion.
          # ~ matt@mattmccutchen.net, 2017-12-31
          paths_to_delete.push(path) if @mode == MODE_UPGRADE
        end
      end
      paths_to_delete.each do |path|
        @db.delete(path)
      end

      if @mode != MODE_UPGRADE && !@breaking_change_descs.empty?
        raise BraidError, <<-MSG
This version of Braid (#{VERSION}) no longer supports a feature used by your
Braid configuration file (version #{@config_version}).  Run 'braid upgrade-config --dry-run'
for information about upgrading your configuration file, or see the instructions
at https://cristibalan.github.io/braid/config_versions.html to install and run a
compatible older version of Braid.
MSG
      end

      if @mode == MODE_MAY_WRITE && @config_version < CURRENT_CONFIG_VERSION
        raise BraidError, <<-MSG
This command may need to write to your Braid configuration file,
but this version of Braid (#{VERSION}) cannot write to your configuration file
(currently version #{config_version}) without upgrading it to configuration version #{CURRENT_CONFIG_VERSION},
which would force other developers on your project to upgrade Braid.  Run
'braid upgrade-config' to proceed with the upgrade, or see the instructions at
https://cristibalan.github.io/braid/config_versions.html to install and run a
compatible older version of Braid.
MSG
      end

    end

    sig {params(url: String, options: Mirror::Options).returns(Mirror)}
    def add_from_options(url, options)
      mirror = Mirror.new_from_options(url, options)

      add(mirror)
      mirror
    end

    sig {returns(T::Array[String])}
    def mirrors
      @db.keys
    end

    sig {params(path: String).returns(T.nilable(Mirror))}
    def get(path)
      key = path.to_s.sub(/\/$/, '')
      attributes = @db[key]
      attributes ? Mirror.new(path, attributes) : nil
    end

    sig {params(path: String).returns(Mirror)}
    def get!(path)
      mirror = get(path)
      raise MirrorDoesNotExist, path unless mirror
      mirror
    end

    sig {params(mirror: Mirror).void}
    def add(mirror)
      raise PathAlreadyInUse, mirror.path if get(mirror.path)
      write_mirror(mirror)
      write_db
    end

    sig {params(mirror: Mirror).void}
    def remove(mirror)
      @db.delete(mirror.path)
      write_db
    end

    sig {params(mirror: Mirror).void}
    def update(mirror)
      raise MirrorDoesNotExist, mirror.path unless get(mirror.path)
      write_mirror(mirror)
      write_db
    end

    # Public for upgrade-config command only.
    sig {void}
    def write_db
      new_db = T.let({}, T::Hash[String, Mirror::MirrorAttributes])
      @db.keys.sort.each do |key|
        attrs = T.must(@db[key])
        new_db[key] = new_attrs = {}
        Braid::Mirror::ATTRIBUTES.each do |k|
          new_attrs[k] = attrs[k] if attrs.has_key?(k)
        end
      end
      new_data = {
        'config_version' => CURRENT_CONFIG_VERSION,
        'mirrors' => new_db
      }
      File.open(@config_file, 'wb') do |f|
        f.write JSON.pretty_generate(new_data)
        f.write "\n"
      end
    end

    private

    sig {params(config_file: String, old_config_files: T::Array[String]).returns(T.untyped)}
    def load_config(config_file, old_config_files)
      (old_config_files + [config_file]).each do |file|
        next unless File.exist?(file)
        begin
          store = T.unsafe(YAML::Store).new(file)
          data = {}
          store.transaction(true) do
            store.roots.each do |path|
              data[path] = store[path]
            end
          end
          return data
        rescue
          data = JSON.parse(file)
          return data if data
        end
      end
      nil
    end

    sig {params(mirror: Mirror).void}
    def write_mirror(mirror)
      @db[mirror.path] = clean_attributes(mirror.attributes)
    end

    sig {params(hash: Mirror::MirrorAttributes).returns(Mirror::MirrorAttributes)}
    def clean_attributes(hash)
      hash.reject { |_, v| v.nil? }
    end
  end
end
