# frozen_string_literal: true

require_relative '../../puppet/module_tool'
require_relative '../../puppet/network/format_support'
require 'uri'
require_relative '../../puppet/util/json'
require 'set'

module Puppet::ModuleTool
  # This class provides a data structure representing a module's metadata.
  # @api private
  class Metadata
    include Puppet::Network::FormatSupport

    attr_accessor :module_name

    DEFAULTS = {
      'name' => nil,
      'version' => nil,
      'author' => nil,
      'summary' => nil,
      'license' => 'Apache-2.0',
      'source' => '',
      'project_page' => nil,
      'issues_url' => nil,
      'dependencies' => Set.new.freeze,
      'data_provider' => nil,
    }

    def initialize
      @data = DEFAULTS.dup
      @data['dependencies'] = @data['dependencies'].dup
    end

    # Returns a filesystem-friendly version of this module name.
    def dashed_name
      @data['name'].tr('/', '-') if @data['name']
    end

    # Returns a string that uniquely represents this version of this module.
    def release_name
      return nil unless @data['name'] && @data['version']

      [dashed_name, @data['version']].join('-')
    end

    alias :name :module_name
    alias :full_module_name :dashed_name

    # Merges the current set of metadata with another metadata hash.  This
    # method also handles the validation of module names and versions, in an
    # effort to be proactive about module publishing constraints.
    def update(data)
      process_name(data) if data['name']
      process_version(data) if data['version']
      process_source(data) if data['source']
      process_data_provider(data) if data['data_provider']
      merge_dependencies(data) if data['dependencies']

      @data.merge!(data)
      self
    end

    # Validates the name and version_requirement for a dependency, then creates
    # the Dependency and adds it.
    # Returns the Dependency that was added.
    def add_dependency(name, version_requirement = nil, repository = nil)
      validate_name(name)
      validate_version_range(version_requirement) if version_requirement

      dup = @data['dependencies'].find { |d| d.full_module_name == name && d.version_requirement != version_requirement }
      raise ArgumentError, _("Dependency conflict for %{module_name}: Dependency %{name} was given conflicting version requirements %{version_requirement} and %{dup_version}. Verify that there are no duplicates in the metadata.json.") % { module_name: full_module_name, name: name, version_requirement: version_requirement, dup_version: dup.version_requirement } if dup

      dep = Dependency.new(name, version_requirement, repository)
      @data['dependencies'].add(dep)

      dep
    end

    # Provides an accessor for the now defunct 'description' property.  This
    # addresses a regression in Puppet 3.6.x where previously valid templates
    # referring to the 'description' property were broken.
    # @deprecated
    def description
      @data['description']
    end

    def dependencies
      @data['dependencies'].to_a
    end

    # Returns a hash of the module's metadata.  Used by Puppet's automated
    # serialization routines.
    #
    # @see Puppet::Network::FormatSupport#to_data_hash
    def to_hash
      @data
    end
    alias :to_data_hash :to_hash

    def to_json
      data = @data.dup.merge('dependencies' => dependencies)

      contents = data.keys.map do |k|
        value = begin
          Puppet::Util::Json.dump(data[k], :pretty => true)
        rescue
          data[k].to_json
        end
        %Q("#{k}": #{value})
      end

      "{\n" + contents.join(",\n").gsub(/^/, '  ') + "\n}\n"
    end

    # Expose any metadata keys as callable reader methods.
    def method_missing(name, *args)
      return @data[name.to_s] if @data.key? name.to_s

      super
    end

    private

    # Do basic validation and parsing of the name parameter.
    def process_name(data)
      validate_name(data['name'])
      author, @module_name = data['name'].split(%r{[-/]}, 2)

      data['author'] ||= author if @data['author'] == DEFAULTS['author']
    end

    # Do basic validation on the version parameter.
    def process_version(data)
      validate_version(data['version'])
    end

    def process_data_provider(data)
      validate_data_provider(data['data_provider'])
    end

    # Do basic parsing of the source parameter.  If the source is hosted on
    # GitHub, we can predict sensible defaults for both project_page and
    # issues_url.
    def process_source(data)
      if data['source'] =~ %r{://}
        source_uri = URI.parse(data['source'])
      else
        source_uri = URI.parse("http://#{data['source']}")
      end

      if source_uri.host =~ /^(www\.)?github\.com$/
        source_uri.scheme = 'https'
        source_uri.path.sub!(/\.git$/, '')
        data['project_page'] ||= @data['project_page'] || source_uri.to_s
        data['issues_url'] ||= @data['issues_url'] || source_uri.to_s.sub(%r{/*$}, '') + '/issues'
      end
    rescue URI::Error
      nil
    end

    # Validates and parses the dependencies.
    def merge_dependencies(data)
      data['dependencies'].each do |dep|
        add_dependency(dep['name'], dep['version_requirement'], dep['repository'])
      end

      # Clear dependencies so @data dependencies are not overwritten
      data.delete 'dependencies'
    end

    # Validates that the given module name is both namespaced and well-formed.
    def validate_name(name)
      return if name =~ %r{\A[a-z0-9]+[-/][a-z][a-z0-9_]*\Z}i

      namespace, modname = name.split(%r{[-/]}, 2)
      modname = :namespace_missing if namespace == ''

      err = case modname
            when nil, '', :namespace_missing
              _("the field must be a namespaced module name")
            when /[^a-z0-9_]/i
              _("the module name contains non-alphanumeric (or underscore) characters")
            when /^[^a-z]/i
              _("the module name must begin with a letter")
            else
              _("the namespace contains non-alphanumeric characters")
            end

      raise ArgumentError, _("Invalid 'name' field in metadata.json: %{err}") % { err: err }
    end

    # Validates that the version string can be parsed as per SemVer.
    def validate_version(version)
      return if SemanticPuppet::Version.valid?(version)

      err = _("version string cannot be parsed as a valid Semantic Version")
      raise ArgumentError, _("Invalid 'version' field in metadata.json: %{err}") % { err: err }
    end

    # Validates that the given _value_ is a symbolic name that starts with a letter
    # and then contains only letters, digits, or underscore. Will raise an ArgumentError
    # if that's not the case.
    #
    # @param value [Object] The value to be tested
    def validate_data_provider(value)
      if value.is_a?(String)
        unless value =~ /^[a-zA-Z][a-zA-Z0-9_]*$/
          if value =~ /^[a-zA-Z]/
            raise ArgumentError, _("field 'data_provider' contains non-alphanumeric characters")
          else
            raise ArgumentError, _("field 'data_provider' must begin with a letter")
          end
        end
      else
        raise ArgumentError, _("field 'data_provider' must be a string")
      end
    end

    # Validates that the version range can be parsed by Semantic.
    def validate_version_range(version_range)
      SemanticPuppet::VersionRange.parse(version_range)
    rescue ArgumentError => e
      raise ArgumentError, _("Invalid 'version_range' field in metadata.json: %{err}") % { err: e }
    end
  end
end
