#
# Simple web server that routes requests to views based on URLs.
#

require 'wunderbar/sinatra'
require 'wunderbar/bootstrap'
require 'wunderbar/vue'
require 'ruby2js/es2017/strict'
require 'ruby2js/filter/functions'
require 'ruby2js/filter/require'
require 'erb'
require 'uri'
require 'sanitize'
require 'escape'
require 'time' # for iso8601
require 'whimsy/asf/meeting-util'

require_relative 'personalize'
require_relative 'helpers'
require_relative 'models/mailbox'
require_relative 'models/events'
require_relative 'tasks'


# monkey patch mail gem to work around a regression introduced in 2.7.0:
# https://github.com/mikel/mail/pull/1168
module Mail
  class Message
    def raw_source=(value)
      @raw_source = ::Mail::Utilities.to_crlf(value)
    end
  end

  module Utilities
    def self.safe_for_line_ending_conversion?(string)
      if RUBY_VERSION >= '1.9'
        string.ascii_only? or
          (string.encoding != Encoding::BINARY and string.valid_encoding?)
      else
        string.ascii_only?
      end
    end
  end
end

require 'whimsy/asf'
require 'whimsy/asf/memapps'
ASF::Mail.configure

SECS_TO_DAYS = 60*60*24


disable :logging # suppress log of requests to stderr/error.log

require 'whimsy/asf/status'

error do
  err = env['sinatra.error']
  <<~EOD
  <pre>
  Error detected, please see web server error log for full details:

  #{Time.now.gmtime.to_s}: #{err.detailed_message}
  </pre>
  EOD
end

# list of messages
get '/' do
  redirect to('/') if env['REQUEST_URI'] == env['SCRIPT_NAME']

  # determine latest month for which there are messages
  current = Date.today.strftime('%Y%m') # exclude future-dated entries
  archives = Dir[File.join(ARCHIVE, '*.yml')].select {|name| name =~ %r{/\d{6}\.yml$} && File.basename(name,'.yml') <= current}
  @mbox = archives.empty? ? nil : File.basename(archives.max, '.yml')
  if @mbox
    @messages = Mailbox.new(@mbox).client_headers.select do |message|
      message[:status] != :deleted
    end
  else
    @messages = [] # ensure the array exists
  end

  # Show outstanding emeritus requests
  ASF::EmeritusRequestFiles.listnames(true).each do |epoch, file|
    days = (((Time.now.to_i - epoch.to_i).to_f / SECS_TO_DAYS)).round(1)
    id = File.basename(file, '.*')
    @messages << {
      date: Time.at(epoch.to_i).gmtime.asctime,
      time: Time.at(epoch.to_i).gmtime.iso8601,
      href: "/roster/committer/#{id}",
      href2: ASF::SVN.svnpath!('emeritus-requests-received', file),
      from: ASF::Person.find(id).cn,
      subject: "Pending emeritus request - #{days} days old",
      status: days < 10.0 ? :emeritusPending : :emeritusReady
    }
  end

  # Show outstanding withdrawal requests
  ASF::WithdrawalRequestFiles.listnames(true, env).each do |epoch, file|
    days = (((Time.now.to_i - epoch.to_i).to_f / SECS_TO_DAYS)).round(1)
    id = File.basename(file, '.*')
    @messages << {
      date: Time.at(epoch.to_i).gmtime.asctime,
      time: Time.at(epoch.to_i).gmtime.iso8601,
      href: "/roster/committer/#{id}",
      href2: ASF::SVN.svnpath!('withdrawn-pending', file),
      from: ASF::Person.find(id).cn,
      subject: "Pending withdrawal request - #{days} days old",
      status: days < 10.0 ? :withdrawalPending : :withdrawalReady
    }
  end

  @cssmtime = File.mtime('public/secmail.css').to_i
  @appmtime = Wunderbar::Asset.convert(File.join(settings.views, 'app.js.rb')).mtime.to_i
  _html :index
end

# alias for root directory
get '/index.html' do
  call env.merge('PATH_INFO' => '/')
end

# support for fetching previous month's worth of messages
get %r{/(\d{6})} do |mbox|
  @mbox = mbox
  _json :index # This invokes workbench/views/index.json.rb
end

get '/deleted' do
  current = Mailbox.allmailboxes.last
  return [404, 'Not found'] unless current
  redirect to("/#{current}/deleted")
end

# display deleted messages
get %r{/(\d{6})/deleted} do |mbox|
  @mbox = mbox
  @prv, @nxt = Mailbox.prev_next(mbox)
  @messages = Mailbox.new(@mbox).client_headers(listall: true).select do |message|
    message[:status] == :deleted
  end
  _html :deleted
end

get '/pending' do
  current = Mailbox.allmailboxes.last
  return [404, 'Not found'] unless current
  redirect to("/#{current}/pending")
end

# display pending messages
get %r{/(\d{6})/pending} do |mbox|
  @mbox = mbox
  @prv, @nxt = Mailbox.prev_next(mbox)
  @messages = Mailbox.new(@mbox).client_headers.reject do |message|
    message[:status] == :deleted
  end
  _html :pending
end

get '/all' do
  current = Mailbox.allmailboxes.last
  return [404, 'Not found'] unless current
  redirect to("/#{current}/all")
end

# display all messages
get %r{/(\d{6})/all} do |mbox|
  @mbox = mbox
  @prv, @nxt = Mailbox.prev_next(mbox)
  @messages = Mailbox.new(@mbox).client_headers(listall: true)
  _html :all
end

# retrieve a single message
get %r{/(\d{6})/(\w+)/} do |month, hash|
  @message = Mailbox.new(month).headers[hash]
  pass unless @message
  _html :message
end

# task lists
post '/tasklist/:file' do
  unavailable = Status.updates_disallowed_reason # are updates disallowed?
  return [503, unavailable] if unavailable

  @jsmtime = File.mtime('public/tasklist.js').to_i
  @cssmtime = File.mtime('public/secmail.css').to_i

  if request.content_type == 'application/json'
    _json(:"actions/#{params[:file]}")
  else
    @dryrun = JSON.parse(_json(:"actions/#{params[:file]}"))
    _html :tasklist
  end
end

# posted actions
SAFE_ACTIONS = %w[check-mail check-signature]
post '/actions/:file' do
  unavailable = Status.updates_disallowed_reason # are updates disallowed?
  return [503, unavailable] if unavailable && !SAFE_ACTIONS.include?(params[:file])

  _json :"actions/#{params[:file]}"
end

# mark a single message as deleted
delete %r{/(\d+)/(\w+)/} do |month, hash|
  unavailable = Status.updates_disallowed_reason # are updates disallowed?
  return [503, unavailable] if unavailable

  success = false

  Mailbox.update(month) do |headers|
    if headers[hash]
      headers[hash][:status] = :deleted
      success = true
    end
  end

  pass unless success
  _json success: true
end

# update a single message
patch %r{/(\d{6})/(\w+)/} do |month, hash|
  unavailable = Status.updates_disallowed_reason # are updates disallowed?
  return [503, unavailable] if unavailable

  success = false

  Mailbox.update(month) do |headers|
    if headers[hash]
      updates = JSON.parse(request.env['rack.input'].read)
      # undelete attachments if requested
      attStatus = updates.delete('attachment_status')
      if attStatus
        headers[hash][:attachments]&.each do |att|
          att[:status] = nil
        end
      end

      # special processing for entries with symbols as keys
      headers[hash].each do |key, value|
        if key.is_a? Symbol and updates.has_key? key.to_s
          headers[hash][key] = updates.delete(key.to_s)
        end
      end

      headers[hash].merge! updates
      success = true
    end
  end

  pass unless success
  [204, {}, '']
end

# list of parts for a single message
get %r{/(\d{6})/(\w+)/_index_} do |month, hash|
  message = Mailbox.new(month).find(hash)
  return [404, 'Message Not Found'] unless message
  @attachments = message.attachments
  @headers = message.headers.dup
  @headers.delete :attachments
  @cssmtime = File.mtime('public/secmail.css').to_i
  @appmtime = Wunderbar::Asset.convert(File.join(settings.views, 'app.js.rb')).mtime.to_i
  @projects = (ASF::Podling.current+ASF::Committee.pmcs).map(&:name).sort

  # Check if applications closed; if so, check if application was received in time
  # Default to 'now' if envelope date not found; hopefully won't be triggered as emails
  # should now all have the field set up
  @meeting = ASF::MeetingUtil.applications_valid ||
    ASF::MeetingUtil.application_valid?(@headers[:envelope_date] || DateTime.now.iso8601)

  _html :parts
end

# message body for a single message
get %r{/(\d{6})/(\w+)/_body_} do |month, hash|
  @message = Mailbox.new(month).find(hash)
  return [404, 'Message Not Found'] unless @message
  @cssmtime = File.mtime('public/secmail.css').to_i
  @appmtime = Wunderbar::Asset.convert(File.join(settings.views, 'app.js.rb')).mtime.to_i
  _html :body
end

# header data for a single message
get %r{/(\d{6})/(\w+)/_headers_} do |month, hash|
  @headers = Mailbox.new(month).headers[hash]
  pass unless @headers
  _html :headers
end

# raw data for a single message
get %r{/(\d{6})/(\w+)/_raw_} do |month, hash|
  message = Mailbox.new(month).find(hash)
  pass unless message
  [200, {'Content-Type' => 'text/plain'}, message.raw]
end

# reparse an existing message
get %r{/(\d{6})/(\w+)/_reparse_} do |month, hash|
  unavailable = Status.updates_disallowed_reason # are updates disallowed?
  return [503, unavailable] if unavailable

  mailbox = Mailbox.new(month)
  message = mailbox.find(hash)
  pass unless message
  email = message.raw
  headers = Message.parse(email)
  message = Message.new(mailbox, hash, headers, email)
  message.write_headers
  [200, {'Content-Type' => 'text/plain'}, 'Done, reselect to show contents']
end

# intercede for potentially dangerous message attachments
get %r{/(\d{6})/(\w+)/_danger_/(.*?)} do |month, hash, name|
  message = Mailbox.new(month).find(hash)
  pass unless message

  @part = message.find(URI::RFC2396_Parser.new.unescape(name))
  pass unless @part

  _html :danger
end

# a specific attachment for a message
get %r{/(\d{6})/(\w+)/(.*?)} do |month, hash, name|
  message = Mailbox.new(month).find(hash)
  pass unless message

  part = message.find(URI::RFC2396_Parser.new.unescape(name))
  pass unless part

  [200, {'Content-Type' => part.content_type}, part.body.to_s]
end

# parse memapp-received
get '/memapp.json' do
  _json :memapp
end

# return email for a given id
get '/email.json' do
  _json do
    {email: ASF::Person.find(params[:id]).mail.first}
  end
end

# return a list of iclas
get '/iclas.json' do
  list = []
  ASF::ICLA.each do |icla|
    list << {
      filename: icla.claRef,
      id: icla.id,
      name: icla.name,
      fullname: icla.legal_name,
      email: icla.email
    }
  end
  list.to_json
end

# Return official list of members (not using LDAP)
# includes inactive members
get '/members.json' do
  list = ASF::Member.list.map { |k, v| {id: k, name: v[:name]}}
  _json list
end

# redirect to an icla
get %r{/icla/(.*)} do |filename|
  checkout = ASF::SVN.svnurl('iclas')
  ASF::ICLAFiles.update_cache(env)
  file = ASF::ICLAFiles.match_claRef(filename)
  pass unless file
  redirect to(checkout + '/' + file)
end

# event stream for server sent events (a.k.a EventSource)
get '/events', provides: 'text/event-stream' do
  events = Events.new

  stream :keep_open do |out|
    out.callback {events.close}

    loop do
      event = events.pop

      if event.is_a? Hash or event.is_a? Array
        out << "data: #{JSON.dump(event)}\n\n"
      elsif event == :heartbeat
        out << ":\n"
      elsif event == :exit
        out.close
        break
      else
        out << "data: #{event.inspect}\n\n"
      end
    end
  end
end
