require 'tgbot/version'

require 'http'
require 'mimemagic'
require 'ostruct'
require 'json'
require 'psych'

class Tgbot
  APIDOC = Psych.load_file File.join __dir__, 'api.yaml'

  def self.run(*args, &blk)
    bot = new(*args)
    bot.instance_exec(bot, &blk) if blk
    bot.run
  end

  attr_accessor :debug

  def initialize(token, proxy: nil, debug: false)
    @prefix = "/bot#{token}"
    @client = HTTP.persistent "https://api.telegram.org"
    if proxy
      addr, port = *proxy.split(':')
      @client = @client.via(addr, port.to_i)
    end
    @debug = debug
    @commands = []
    @start = @finish = nil
  end

  def start &blk
    @start = blk
  end

  def finish &blk
    @finish = blk
  end

  def on pattern=nil, name: nil, before_all: false, after_all: false, &blk
    if before_all && after_all
      raise ArgumentError, 'before_all and after_all can\'t both be true'
    end
    @commands << [pattern, name, before_all, after_all, blk]
  end

  def debug msg
    STDERR.puts "#{Time.now.strftime "%FT%T"} #{msg}" if @debug
  end

  class Update
    attr_reader :bot, :data

    def initialize bot, data
      @bot = bot
      @data = data
    end

    def interrupt!
      @bot.instance_variable_get(:@tasks).clear
    end

    alias done! interrupt!

    def retry! n=1
      @retried ||= n
      return if @retried <= 0
      @bot.instance_variable_get(:@updates) << self
      @retried -= 1
    end

    def match? pattern
      return true if pattern.nil?
      return false if text.nil?
      !!match(pattern)
    end

    def match pattern
      return nil if pattern.nil? || text.nil?
      text.match(pattern)
    end

    def message
      @data.to_h.find { |k, v| k.match? /message|post/ }&.last
    end

    def text
      message&.text&.gsub(/(\/\w+)(@\w+)/, '\1')
    end

    def reply *things, media: true, style: nil, **options
      meth = :send_message
      payload = { chat_id: message&.chat.id }
      things.each do |x|
        if IO === x
          magic = MimeMagic.by_magic(x)
          case
          when magic.audio?
            meth = :send_audio
            payload[:audio] = x
          when magic.image?
            meth = :send_photo
            payload[:photo] = x
          when magic.video?
            meth = :send_video
            payload[:video] = x
          else
            meth = :send_document
            payload[:document] = x
          end
        else
          payload[:text] = x.to_s
          payload[:parse_mode] = 'Markdown'
        end
      end
      payload = payload.merge options
      case style
      when :at
        if payload[:text] && message.from
          from = message.from
          if payload[:parse_mode].match? /Markdown/i
            prefix = "[#{from.first_name}](tg://user?id=#{from.id}) "
          elsif payload[:parse_mode].match? /HTML/i
            prefix = "<a href=\"tg://user?id=#{from.id}\">#{from.first_name}</a> "
          else
            prefix = ''
          end
          payload[:text] = prefix + payload[:text]
        end
      when nil
        if !payload[:reply_to_message_id]
          payload[:reply_to_message_id] = message_id
        end
      end
      @bot.send meth, payload
    end

    def message_id
      message&.message_id
    end

    def method_missing meth, *args, &blk
      return @data[meth] if args.empty? && @data[meth]
      @bot.send(meth, *args, &blk)
    end
  end

  def run &blk
    @offset = 0
    @timeout = 2
    @updates = []
    instance_exec(self, &@start) if @start
    loop do
      while x = @updates.shift
        u = Update === x ? x : Update.new(self, x)
        @offset = u.update_id if u.update_id && @offset < u.update_id
        @tasks = @commands.select { |pattern, *| u.match? pattern }
          .group_by { |e| e[2] ? :before : e[3] ? :after : nil }
          .values_at(:before, nil, :after).compact.flatten(1)
        while t = @tasks.shift
          debug ">> #{t[1] || t[0]}"
          u.instance_exec(u.match(t[0]), u, t, &t[4])
        end
      end
      res = get_updates offset: @offset + 1, limit: 7, timeout: 15
      if res.ok
        @updates.push *res.result
      else
        debug "#{res.error_code}: #{res.description}"
      end
    end
  rescue HTTP::ConnectionError
    debug "connect failed, check proxy?"
    retry
  rescue Interrupt
    instance_exec(self, &@finish) if @finish
  ensure
    @client.close
  end

  def api_version
    search_in_doc(/changes/i, '')[0].desc[0].content[/\d+\.\d+/]
  end

  private

  def method_missing meth, *args, &blk
    meth = meth.to_s.split('_').map.
      with_index { |x, i| i.zero? ? x : (x[0].upcase + x[1..-1]) }.join
    payload = make_payload meth, *args
    debug "/#{meth} #{payload}"
    result = @client.post("#@prefix/#{meth}", form: payload).to_s
    result = json_to_ostruct(result)
    debug "=> #{result}"
    blk ? blk.call(result) : result
  end

  def json_to_ostruct json
    JSON.parse(json, object_class: OpenStruct)
  end

  def make_payload meth, *args
    defaults, schema = meth_info meth
    payload = {}
    args.each do |arg|
      if field = defaults.find { |k, _| k.any? { |l| check_match l, arg } }&.last
        defaults.delete_if { |_, v| v == field }
        payload[field] = arg
      end
      if Hash === arg
        defaults.delete_if { |_, v| arg.keys.include?(v) || arg.keys.include?(v.to_sym) }
        payload = payload.merge arg
      end
    end
    if !defaults.empty?
      debug "should 400: #{defaults.values.join(', ')} not specified"
    end
    check_type payload, schema
  end

  def meth_info meth
    unless table = (search_in_doc '', /^#{meth}$/)[0]&.table
      debug "don't find type of #{meth}"
      return {}, {}
    end
    defaults = table.select { |e| e.Required.match? /Yes/i }
      .map { |e|
        [e.Type.split(/\s+or\s+/).flat_map { |s| 
          string_to_native_types s, false
        }.compact, e.Parameter]
      }.reject { |(ts, _para)| ts.empty? }.to_h
    schema = table
      .map { |e|
        [e.Parameter, e.Type.split(/\s+or\s+/).map { |s|
          string_to_native_types s
        }]
      }.to_h
    return defaults, schema
  end

  def string_to_native_types s, keep_unknown=true
    if s['Array of ']
      return [string_to_native_types(s[9..-1], keep_unknown)]
    end
    case s
    when 'String'  then String
    when 'Integer' then Integer
    when 'Boolean' then [true, false]
    else
      keep_unknown ? s : nil
    end
  end

  def check_match k, arg
    if Array === k
      if k.size > 1
        k.any? { |t| t === arg }
      else
        Array === arg && arg.all? { |a| check_match k[0], a }
      end
    else
      return true if String === k # unknown types, like "User"
      k === arg
    end
  end

  def check_type payload, schema
    filtered = {}
    payload.each do |field, value|
      row = schema[field.to_s]
      if row&.any? { |k| check_match k, value }
        filtered[field] = value
      else
        debug "check_type failed at #{field} :: #{row&.join(' | ')} = #{value.inspect}"
      end
    end
    filtered
  end

  def search_in_doc *hints
    doc = [APIDOC]
    hints.each do |hint|
      if nxt = doc.flat_map { |x| x['children'] }.select { |x| x['name'][hint] }
        doc = nxt
      else
        return nil
      end
    end
    json_to_ostruct JSON.generate doc
  end
end
