require 'nokogiri'
require 'date'
require 'psych'

module ASF

  # Represents a podling, drawing information from both podlings.xml and LDAP.

  class Podling
    include Enumerable

    # name of the podling, from podlings.xml
    attr_writer :name

    # name of the podling, from podlings.xml
    attr_accessor  :resource

    # array of aliases for the podling, from podlings.xml
    attr_accessor  :resourceAliases

    # status of the podling, from podlings.xml.  Valid values are
    # <tt>current</tt>, <tt>graduated</tt>, or <tt>retired</tt>.
    attr_accessor  :status

    # description of the podling, from podlings.xml
    attr_accessor  :description

    # list of userids of the mentors, from podlings.xml
    attr_accessor  :mentors

    # userid of the champion, from podlings.xml
    attr_accessor  :champion

    # if reporting monthly, a list of months reports are expected.  Can also
    # ge <tt>nil</tt> or an empty list.  From podlings.xml.
    attr_accessor  :monthly

    # three consecutive months, starting with this one
    def quarter
      [
        Date.today.strftime('%B'),
        Date.today.next_month.strftime('%B'),
        Date.today.next_month.next_month.strftime('%B')
      ]
    end

    # <resolution link="">, from podlings.xml
    attr_accessor  :resolutionLink

    # <resolution url="">, from podlings.xml
    attr_accessor  :resolutionURL

    # <resolution tlp="">, from podlings.xml
    attr_accessor  :resolutionTLP

    # create a podling from a Nokogiri node built from podlings.xml
    def initialize(node)
      @mtime = nil
      @name = node['name']
      @resource = node['resource']
      # Validate resource for later use resource can contain '-' and '.' (lucene.net)
      raise ArgumentError, "Invalid resource #{@resource}" unless @resource =~ /\A[-.\w]+\z/
      @sponsor = node['sponsor']
      # Needed for matching against mailing list names
      @resourceAliases = []
      @resourceAliases = node['resourceAliases'].split(/,\s*/) if node['resourceAliases']
      @status = node['status']
      @enddate = node['enddate']
      @startdate = node['startdate']
      @description = node.at('description').text
      @mentors = node.search('mentor').map { |mentor| mentor['username'] }
      @champion = node.at('champion')['availid'] if node.at('champion')

      @reporting = node.at('reporting') || nil # ensure variable is defined
      @monthly = @reporting&.text&.split(/,\s*/)

      res = node.at('resolution')
      if res
        @resolutionLink = res.attr('link')
        @resolutionURL = res.attr('url')
        @resolutionTLP = res.attr('tlp')
      else
        @resolutionLink = nil
        @resolutionURL = nil
        @resolutionTLP = nil
      end

      # Note: the following optional elements are not currently processed:
      # - resolution (except for resolution/@link)
      # - retiring/graduating
      # The following podling attributes are not processed:
      # - longname
    end

    # name for this podling, originally from the resource attribute in
    # podlings.xml.
    def name
      @resource
    end

    # also map resource to id
    def id
      @resource
    end

    # display name for this podling, originally from the name attribute in
    # podlings.xml.
    def display_name
      @name || @resource
    end

    # TLP name (name differ from podling name)
    def tlp_name
      @resolutionLink || name
    end

    def current?
      @status == 'current'
    end

    def graduated?
      @status == 'graduated'
    end

    def retired?
      @status == 'retired'
    end

    # date this podling was accepted for incubation
    def startdate
      return unless @startdate and @startdate.length >= 7 # "YYYY-MM"
      # assume 15th (mid-month) if no day specified
      return Date.parse("#{@startdate}-15") if @startdate.length == 7
      Date.parse(@startdate)
    rescue ArgumentError
      nil
    end

    # date this podling either retired or graduated.  <tt>nil</tt> for
    # current podlings.
    def enddate
      return unless @enddate and @enddate.length >= 7
      # assume 15th (mid-month) if no day specified
      return Date.parse("#{@enddate}-15") if @enddate.length == 7
      Date.parse(@enddate)
    rescue ArgumentError
      nil
    end

    # number of days in incubation
    def duration
      last = enddate || Date.today
      first = startdate || Date.today
      (last - first).to_i
    end

    # lazy evaluation of reporting
    def reporting
      if @reporting.instance_of? Nokogiri::XML::Element
        group = @reporting['group']
        @reporting = %w(January April July October) if group == '1'
        @reporting = %w(February May August November) if group == '2'
        @reporting = %w(March June September December) if group == '3'
      end

      @reporting
    end

    # provides a concatenated reporting schedule
    def schedule
      self.reporting + self.monthly
    end

    # list of all podlings, regardless of status
    def self.list
      incubator_content = ASF::SVN['incubator-content']
      podlings_xml = File.join(incubator_content, 'podlings.xml')

      # see if there is a later version
      cache = ASF::Config.get(:cache)
      if File.exist? File.join(cache, 'podlings.xml')
        if File.mtime(File.join(cache, 'podlings.xml')) > File.mtime(podlings_xml)
          podlings_xml = File.join(cache, 'podlings.xml')
        end
      end

      if @mtime != File.mtime(podlings_xml)
        @list = []
        podlings = Nokogiri::XML(File.read(podlings_xml))
        # check for errors as they adversely affect the generated output
        raise Exception.new(podlings.errors.inspect) if podlings.errors.size > 0
        podlings.search('podling').map do |node|
          @list << new(node)
        end
        @mtime = File.mtime(podlings_xml)
      end

      @list
    end

    # hash of podlings with status
    def self.list_with_status
      self.list.map {|pod| [pod.id, pod.status]}.to_h
    end
    
    # list of current podlings
    def self.current
      self._list('current')
    end

    # list of current podling ids
    def self.currentids
      self._listids('current')
    end

    # list of graduated podlings
    def self.graduated
      self._list('graduated')
    end

    # list of graduated podling ids
    def self.graduatedids
      self._listids('graduated')
    end

    # list of retired podlings
    def self.retired
      self._list('retired')
    end

    # list of retired podling ids
    def self.retiredids
      self._listids('retired')
    end

    # list of all podlings
    def self.all
      self._list('all')
    end

    # list of all podling ids
    def self.allids
      self._listids('all')
    end

    # last modified time of podlings.xml in the local working directory,
    # as of the last time #list was called.
    def self.mtime
      @mtime
    end

    # find a podling by name
    def self.find(name)
      name = name.downcase

      result = list.find do |podling|
        podling.name == name or podling.display_name.downcase == name or
          podling.resourceAliases.any? {|aname| aname.downcase == name}
      end

      result ||= list.find do |podling|
        podling.resource == name or
        podling.tlp_name.downcase == name
      end
    end

    # below is for backwards compatibility

    # make class itself enumerable
    class << self
      include Enumerable
    end

    # return the entire list as a hash
    def self.to_h
      Hash[self.to_a]
    end

    # provide a list of podling names and descriptions
    def self.each(&block)
      list.each { |podling| block.call podling.name, podling }
    end

    # allow attributes to be accessed as hash
    def [](name)
      return self.send name if self.respond_to? name
    end

    # list of PPMC owners from LDAP
    def owners
      ASF::Project.find(id).owners
    end

    # list of PPMC committers from LDAP
    def members
      ASF::Project.find(id).members
    end

    def hasLDAP?
      ASF::Project.find(id).hasLDAP?
    end

    # base name used in constructing mailing list name.
    def mail_list
      case name.downcase
      when 'odftoolkit'
        'odf'
      else
        name.downcase
      end
    end

    # development mailing list associated with a given podling
    def dev_mail_list
      case name
        when 'climatemodeldiagnosticanalyzer'
          'dev@cmda.incubator.apache.org'
        when 'odftoolkit'
          'odf-dev@incubator.apache.org'
        when 'log4cxx2'
          'log4cxx-dev@logging.apache.org'
        else
          "dev@#{name}.apache.org"
      end
    end

    # private mailing list associated with a given podling
    def private_mail_list
      if name == 'log4cxx2'
        'private@logging.apache.org'
      else
        list = dev_mail_list
        list ? list.sub('dev', 'private') : 'private@incubator.apache.org'
      end
    end

    # Is this a podling mailing list?
    def mail_list?(list)
      return true if _match_mailname?(list, name())
      # Also check aliases
      @resourceAliases.each { |name|
        return true if _match_mailname?(list, name)
      }
      return false
    end

    # Match against new and old list types
    def _match_mailname?(list, name)
      return true if list.start_with?("#{name}-")
      return true if list.start_with?("incubator-#{name}-")
    end

    # status information associated with this podling.  Keys in the hash return
    # include: <tt>:ipClearance</tt>, <tt>:sourceControl</tt>, <tt>:wiki</tt>,
    # <tt>:jira</tt>, <tt>:proposal</tt>, <tt>:website</tt>, <tt>:news</tt>
    def podlingStatus
      incubator_content = ASF::SVN['incubator-podlings']
      resource_yml = File.join(incubator_content, "#{@resource}.yml")
      if File.exist?(resource_yml)
        rawYaml = Psych.load_file(resource_yml, permitted_classes: [Date, Symbol])
        hash = { }
        hash[:sga] = rawYaml[:sga].strftime('%Y-%m-%d') if rawYaml[:sga]&.class == Date
        hash[:asfCopyright] = rawYaml[:asfCopyright].strftime('%Y-%m-%d') if rawYaml[:asfCopyright]&.class == Date
        hash[:distributionRights] = rawYaml[:distributionRights].strftime('%Y-%m-%d') if rawYaml[:distributionRights]&.class == Date
        hash[:ipClearance] = rawYaml[:ipClearance].strftime('%Y-%m-%d') if rawYaml[:ipClearance]&.class == Date
        hash[:sourceControl] = rawYaml[:sourceControl]
        hash[:wiki] = rawYaml[:wiki]
        hash[:jira] = rawYaml[:jira]
        hash[:proposal] = rawYaml[:proposal]
        hash[:website] = rawYaml[:website]
        hash[:news] = []
        rawYaml[:news]&.each do |ni|
          newsItem = {}
          newsItem[:date] = ni[:date].strftime('%Y-%m-%d') if ni[:date]&.class == Date
          newsItem[:note] = ni[:note]
          hash[:news].push(newsItem)
        end
        hash
      else
        {news: [], website: "http://#{self.resource}.incubator.apache.org"}
      end
    end

    # Return the instance as a hash.  Keys in the hash are:
    # <tt>:name</tt>, <tt>:status</tt>, <tt>:description</tt>,
    # <tt>:mentors</tt>, <tt>:startdate</tt>, <tt>:champion</tt>,
    # <tt>:reporting</tt>, <tt>:resource</tt>, <tt>:resourceAliases</tt>,
    # <tt>:sponsor</tt>, <tt>:duration</tt>, and <tt>:podlingStatus</tt>
    def as_hash # might be confusing to use to_h here?
      hash = {
          name: @name,
          status: status,
          description: description,
          mentors: mentors,
          startdate: startdate,
      }
      hash[:enddate] = enddate if enddate
      hash[:champion] = champion if champion

      # Tidy up the reporting output
      podlingStatus = self.podlingStatus
      r = @reporting
      if r.instance_of? Nokogiri::XML::Element
        group = r['group']
        hash[:reporting] = {
            group: group
        }
        hash[:reporting][:text] = r.text if r.text.length > 0
        hash[:reporting][:monthly] = r.text.split(/,\s*/) if r['monthly']
        hash[:reporting][:schedule] = self.schedule
      elsif r
        hash[:reporting] = r
      end

      hash[:resource] = resource
      hash[:resourceAliases] = resourceAliases
      hash[:namesearch] = namesearch if namesearch
      hash[:sponsor] = @sponsor if @sponsor
      hash[:duration] = self.duration
      hash[:podlingStatus] = podlingStatus
      hash[:resolutionLink] = resolutionLink if resolutionLink
      hash[:resolutionURL] = resolutionURL if resolutionURL
      hash[:resolutionTLP] = resolutionTLP if resolutionTLP
      hash
    end

    # status information associated with this podling.  Keys in the hash return
    # include: <tt>:issueTracker</tt>, <tt>:wiki</tt>, <tt>:jira</tt>,
    # <tt>:proposal</tt>, <tt>:asfCopyright, <tt>:distributionRights</tt>,
    # <tt>:ipClearance</tt>, <tt>:sga</tt>, <tt>:website</tt>,
    # <tt>:graduationDate</tt>, <tt>:resolution</tt>
    def default_status
      {
          issueTracker: 'jira',
          wiki: self.resource.upcase,
          jira: self.resource.upcase,
          proposal: "http://wiki.apache.org/incubator/#{self.resource.capitalize}Proposal",
          asfCopyright: nil,
          distributionRights: nil,
          ipClearance: nil,
          sga: nil,
          website: "http://#{self.resource}.incubator.apache.org",
          graduationDate: nil,
          resolution: nil
      }
    end

    # Override Proposed Names that are wrong
    NAME_FIXES = {
      # Proposed name is actually correct for the issue, but the podling uses 'DataLab'
      'Data Lab' => 'DataLab', # PODLINGNAMESEARCH-184
    }

    # parse (and cache) names mentioned in podlingnamesearches
    # Note: customfield_12310520 = 'Podling', customfield_12310521 = 'Proposed Name'
    def self.namesearch
      # cache JIRA response
      cache = File.join(ASF::Config.get(:cache), 'pns.jira')
      if not File.exist?(cache) or File.mtime(cache) < Time.now - 300
        query = 'https://issues.apache.org/jira/rest/api/2/search?' +
            'maxResults=1000&' +
            'jql=project=PODLINGNAMESEARCH&fields=summary,resolution,customfield_12310521'
        begin
          res = Net::HTTP.get_response(URI(query))
          res.value() # Raises error if not OK
          file = File.new(cache, 'wb') # Allow for non-UTF-8 chars
          file.write res.body
        rescue StandardError => e
          Wunderbar.warn 'ASF::Podling.namesearch: ' + e.message
          FileUtils.touch cache # Don't try again for a while
        end
      end

      # parse JIRA titles for proposed name
      issues = JSON.parse(File.read(cache))['issues'].map do |issue|
        resolution = issue['fields']['resolution']
        resolution = resolution ? resolution['name'] : 'Unresolved'
        # Ignore duplicates and abandoned entries etc.
        # PODLINGNAMESEARCH-9 is resolved as 'Not A Problem': this means it is cleared for use
        next unless %w{Fixed Unresolved Resolved Implemented}.include?(resolution) ||
          issue['key'] == 'PODLINGNAMESEARCH-9'

        name = issue['fields']['customfield_12310521']

        if name
          name.sub!(/^Apache\s+/, '')
          name.gsub!(/\s+\(.*?\)/, '')
          # Fix up incorrect 'Proposed Name' entries
          name = NAME_FIXES[name] || name
          name = nil if name =~ /^\s*This/ or name !~ /[A-Za-z]{3}/ or name =~ %r{^N/A}
        end

        next unless name

        [name, {issue: issue['key'], resolution: resolution}]
      end

      issues.compact.sort_by(&:first).to_h
    end

    # return podlingnamesearch for this podling
    def namesearch
      Podling.namesearch[display_name]
    end

    private

    def self._list(status)
      list.select { |podling| status == 'all' or podling.status == status }
    end

    def self._listids(status)
      self._list(status).map(&:id)
    end
  end
end
