---
-- A Vuze DHT protocol implementation based on the following documentation:
-- o http://wiki.vuze.com/w/Distributed_hash_table
--
-- It currently supports the PING and FIND_NODE requests and parses the
-- responses. The following main classes are used by the library:
--
-- o Request  - the request class containing all of the request classes. It
--              currently contains the Header, PING and FIND_NODE classes.
--
-- o Response - the response class containing all of the response classes. It
--              currently contains the Header, PING, FIND_NODE and ERROR
--              class.
--
-- o Session  - a class containing "session state" such as the transaction- and
--              instance ID's.
--
-- o Helper   - The helper class that serves as the main interface between
--              scripts and the library.
--
-- @author Patrik Karlsson <patrik@cqure.net>
--

local ipOps = require "ipOps"
local math = require "math"
local nmap = require "nmap"
local os = require "os"
local stdnse = require "stdnse"
local string = require "string"
local table = require "table"
local rand = require "rand"
_ENV = stdnse.module("vuzedht", stdnse.seeall)


Request = {

  Actions = {
    ACTION_PING = 1024,
    FIND_NODE = 1028,
  },

  -- The request Header class shared by all Requests classes
  Header = {

    -- Creates a new Header instance
    -- @param action number containing the request action
    -- @param session instance of Session
    -- @return o new instance of Header
    new = function(self, action, session)
      local o = {
        conn_id = string.char(255) .. rand.random_string(7),
        -- we need to handle this one like this, due to a bug in nsedoc
        -- it used to be action = action, but that breaks parsing
        ["action"] = action,
        trans_id = session:getTransactionId(),
        proto_version = 0x32,
        vendor_id = 0,
        network_id = 0,
        local_proto_version = 0x32,
        address = session:getAddress(),
        port = session:getPort(),
        instance_id = session:getInstanceId(),
        time = os.time(),
      }
      setmetatable(o, self)
      self.__index = self
      return o
    end,

    -- Converts the header to a string
    __tostring = function(self)
      local lhost = ipOps.ip_to_str(self.address)
      return self.conn_id .. string.pack( ">I4 I4 BB I4 B s1 I2 I4 I8 ", self.action, self.trans_id,
      self.proto_version, self.vendor_id, self.network_id, self.local_proto_version,
      lhost, self.port, self.instance_id, self.time )
    end,

  },

  -- The PING Request class
  Ping = {

    -- Creates a new Ping instance
    -- @param session instance of Session
    -- @return o new instance of Ping
    new = function(self, session)
      local o = {
        header = Request.Header:new(Request.Actions.ACTION_PING, session)
      }
      setmetatable(o, self)
      self.__index = self
      return o
    end,

    -- Converts a Ping Request to a string
    __tostring = function(self)
      return tostring(self.header)
    end,

  },

  -- The FIND_NODES Request class
  FindNode = {

    -- Creates a new FindNode instance
    -- @param session instance of Session
    -- @return o new instance of FindNode
    new = function(self, session)
      local o = {
        header = Request.Header:new(Request.Actions.FIND_NODE, session),
        node_id = '\xA7' .. rand.random_string(19),
        status = 0xFFFFFFFF,
        dht_size = 0,
      }
      setmetatable(o, self)
      self.__index = self
      return o
    end,

    -- Converts a FindNode Request to a string
    __tostring = function(self)
      local data = tostring(self.header)
      .. string.pack(">s1 I4I4", self.node_id, self.status, self.dht_size)
      return data
    end,
  }

}

Response = {

  -- A table of currently supported Actions (Responses)
  -- It's used in the fromString method to determine which class to create.
  Actions = {
    ACTION_PING = 1025,
    FIND_NODE = 1029,
    ERROR = 1032,
  },

  -- Creates an address record based on received data
  -- @param data containing an address record [C][I|H][S] where
  --        [C] is the length of the address (4 or 16)
  --        [I|H] is the binary address
  --        [S] is the port number as a short
  -- @return o Address instance on success, nil on failure
  Address = {
    new = function(self, data)
      local o = { data = data }
      setmetatable(o, self)
      self.__index = self
      if ( o:parse() ) then
        return o
      end
    end,

    -- Parses the received data
    -- @return true on success, false on failure
    parse = function(self)
      local ip, err
      ip, self.port = string.unpack(">s1 I2", self.data)
      self.ip, err = ipOps.str_to_ip(ip)
      if not self.ip then
        stdnse.debug1("Unknown address type (length: %d)", #ip)
        return false, "Unknown address type"
      end
      return true
    end
  },

  -- The response header, present in all packets
  Header = {

    Vendors = {
      [0] = "Azureus",
      [1] = "ShareNet",
      [255] = "Unknown", -- to be honest, we report all except 0 and 1 as unknown
    },

    Networks = {
      [0] = "Stable",
      [1] = "CVS"
    },

    -- Creates a new Header instance
    -- @param data string containing the received data
    -- @return o instance of Header
    new = function(self, data)
      local o = { data = data }
      setmetatable(o, self)
      self.__index = self
      o:parse()
      return o
    end,

    -- parses the header
    parse = function(self)
      local pos
      self.action, self.trans_id, self.conn_id,
      self.proto_version, self.vendor_id, self.network_id,
      self.instance_id, pos = string.unpack(">I4 I4 c8 BB I4 I4 ", self.data)
    end,

    -- Converts the header to a suitable string representation
    __tostring = function(self)
      local result = {}
      table.insert(result, ("Transaction id: %d"):format(self.trans_id))
      table.insert(result, ("Connection id: 0x%s"):format(stdnse.tohex(self.conn_id)))
      table.insert(result, ("Protocol version: %d"):format(self.proto_version))
      table.insert(result, ("Vendor id: %s (%d)"):format(
        Response.Header.Vendors[self.vendor_id] or "Unknown", self.vendor_id))
      table.insert(result, ("Network id: %s (%d)"):format(
        Response.Header.Networks[self.network_id] or "Unknown", self.network_id))
      table.insert(result, ("Instance id: %d"):format(self.instance_id))
      return stdnse.format_output(true, result)
    end,

  },

  -- The PING response
  PING = {

    -- Creates a new instance of PING
    -- @param data string containing the received data
    -- @return o new PING instance
    new = function(self, data)
      local o = {
        header = Response.Header:new(data)
      }
      setmetatable(o, self)
      self.__index = self
      return o
    end,

    -- Creates a new PING instance based on received data
    -- @param data string containing received data
    -- @return status true on success, false on failure
    -- @return new instance of PING on success, error message on failure
    fromString = function(data)
      local ping = Response.PING:new(data)
      if ( ping ) then
        return true, ping
      end
      return false, "Failed to parse PING response"
    end,

    -- Converts the PING response to a response suitable for script output
    -- @return result formatted script output
    __tostring = function(self)
      return tostring(self.header)
    end,
  },

  -- A class to process the response from a FIND_NODE query
  FIND_NODE = {

    -- Creates a new FIND_NODE instance
    -- @param data string containing the received data
    -- @return o new instance of FIND_NODE
    new = function(self, data)
      local o = {
        header = Response.Header:new(data),
        data = data:sub(27)
      }
      setmetatable(o, self)
      self.__index = self
      o:parse()
      return o
    end,

    -- Parses the FIND_NODE response
    parse = function(self)
      local pos
      self.spoof_id, self.node_type, self.dht_size,
      self.network_coords, pos = string.unpack(">I4 I4 I4 c20", self.data)

      local contact_count
      contact_count, pos = string.unpack("B", self.data, pos)
      self.contacts = {}
      for i=1, contact_count do
        local contact = {}
        local address
        contact.type, contact.proto_version, address, contact.port, pos = string.unpack(
          ">BBs1I2", self.data, pos)

        contact.address = ipOps.str_to_ip(address)
        table.insert(self.contacts, contact)
      end
    end,

    -- Creates a new instance of FIND_NODE based on received data
    -- @param data string containing received data
    -- @return status true on success, false on failure
    -- @return new instance of FIND_NODE on success, error message on failure
    fromString = function(data)
      local find = Response.FIND_NODE:new(data)
      if ( find.header.proto_version < 13 ) then
        stdnse.debug1("ERROR: Unsupported version %d", find.header.proto_version)
        return false
      end

      return true, find
    end,

    -- Convert the FIND_NODE response to formatted string data, suitable
    -- for script output.
    -- @return string with formatted FIND_NODE data
    __tostring = function(self)
      if ( not(self.contacts) ) then
        return ""
      end

      local result = {}
      for _, contact in ipairs(self.contacts) do
        local address = contact.address
        if address:find(":") then
          address = ("[%s]"):format(address)
        end
        table.insert(result, ("%s:%d"):format(address, contact.port))
      end
      return stdnse.format_output(true, result)
    end
  },

  -- The ERROR action
  ERROR = {

    -- Creates a new ERROR instance based on received socket data
    -- @return o new ERROR instance on success, nil on failure
    new = function(self, data)
      local o = {
        header = Response.Header:new(data),
        data = data:sub(27)
      }
      setmetatable(o, self)
      self.__index = self
      if ( o:parse() ) then
        return o
      end
    end,

    -- parses the received data and attempts to create an ERROR response
    -- @return true on success, false on failure
    parse = function(self)
      local err_type, pos = string.unpack(">I4", self.data)
      if ( 1 == err_type ) then
        self.addr = Response.Address:new(self.data:sub(pos))
        return true
      end
      return false
    end,

    -- creates a new ERROR instance based on the received data
    -- @return true on success, false on failure
    fromString = function(data)
      local err = Response.ERROR:new(data)
      if ( err ) then
        return true, err
      end
      return false
    end,

    -- Converts the ERROR action to a formatted response
    -- @return string containing the formatted response
    __tostring = function(self)
      return ("Wrong address, expected: %s"):format(self.addr.ip)
    end,

  },

  -- creates a suitable Response class based on the Action received
  -- @return true on success, false on failure
  -- @return response instance of suitable Response class on success,
  --         err string error message if status is false
  fromString = function(data)
    local action, pos = string.unpack(">I4", data)

    if ( action == Response.Actions.ACTION_PING ) then
      return Response.PING.fromString(data)
    elseif ( action == Response.Actions.FIND_NODE ) then
      return Response.FIND_NODE.fromString(data)
    elseif ( action == Response.Actions.ERROR ) then
      return Response.ERROR.fromString(data)
    end

    stdnse.debug1("ERROR: Unknown response received from server")
    return false, "Failed to parse response"
  end,



}

-- The Session
Session = {

  -- Creates a new Session instance to keep track on some of the protocol
  -- stuff, such as transaction- and instance- identities.
  -- @param address the local address to pass in the requests to the server
  --        this could be either the local address or the IP of the router
  --        depending on if NAT is used or not.
  -- @param port the local port to pass in the requests to the server
  -- @return o new instance of Session
  new = function(self, address, port)
    local o = {
      trans_id = math.random(12345678),
      instance_id = math.random(12345678),
      address = address,
      port = port,
    }
    setmetatable(o, self)
    self.__index = self
    return o
  end,

  -- Gets the next transaction ID
  -- @return trans_id number
  getTransactionId = function(self)
    self.trans_id = self.trans_id + 1
    return self.trans_id
  end,

  -- Gets the next instance ID
  -- @return instance_id number
  getInstanceId = function(self)
    self.instance_id = self.instance_id + 1
    return self.instance_id
  end,

  -- Gets the stored local address used to create the session
  -- @return string containing the IP passed to the session
  getAddress = function(self)
    return self.address
  end,

  -- Get the stored local port used to create the session
  -- @return number containing the local port
  getPort = function(self)
    return self.port
  end

}

-- The Helper class, used as main interface between the scripts and the library
Helper = {

  -- Creates a new instance of the Helper class
  -- @param host table as passed to the action method
  -- @param port table as passed to the action method
  -- @param lhost [optional] used if an alternate local address is to be
  --        passed in the requests to the remote node (ie. NAT is in play).
  -- @param lport [optional] used if an alternate port is to be passed in
  --        the requests to the remote node.
  -- @return o new instance of Helper
  new = function(self, host, port, lhost, lport)
    local o = {
      host = host,
      port = port,
      lhost = lhost,
      lport = lport
    }
    setmetatable(o, self)
    self.__index = self
    return o
  end,

  -- Connects to the remote Vuze Node
  -- @return true on success, false on failure
  -- @return err string error message if status is false
  connect = function(self)
    local lhost = tonumber(self.lhost or stdnse.get_script_args('vuzedht.lhost'))
    local lport = tonumber(self.lport or stdnse.get_script_args('vuzedht.lport'))

    self.socket = nmap.new_socket()

    if ( lport ) then
      self.socket:bind(nil, lport)
    end
    local status, err = self.socket:connect(self.host, self.port)
    if ( not(status) ) then
      return false, "Failed to connect to server"
    end

    if ( not(lhost) or not(lport) ) then
      local status, lh, lp, _, _ = self.socket:get_info()
      if ( not(status) ) then
        return false, "Failed to get socket information"
      end
      lhost = lhost or lh
      lport = lport or lp
    end

    self.session = Session:new(lhost, lport)
    return true
  end,

  -- Sends a Vuze PING request to the server and parses the response
  -- @return status true on success, false on failure
  -- @return response PING response instance on success,
  --         err string containing the error message on failure
  ping = function(self)
    local ping = Request.Ping:new(self.session)
    local status, err = self.socket:send(tostring(ping))
    if ( not(status) ) then
      return false, "Failed to send PING request to server"
    end

    local data
    status, data = self.socket:receive()
    if ( not(status) ) then
      return false, "Failed to receive PING response from server"
    end
    local response
    status, response = Response.fromString(data)
    if ( not(status) ) then
      return false, "Failed to parse PING response from server"
    end
    return true, response
  end,

  -- Requests a list of known nodes by sending the FIND_NODES request
  -- to the remote node and parses the response.
  -- @return status true on success, false on failure
  -- @return response FIND_NODE response instance on success
  --         err string containing the error message on failure
  findNodes = function(self)
    local find = Request.FindNode:new(self.session)
    local status, err = self.socket:send(tostring(find))
    if ( not(status) ) then
      return false, "Failed to send FIND_NODE request to server"
    end

    local data
    status, data = self.socket:receive()
    local response
    status, response = Response.fromString(data)
    if ( not(status) ) then
      return false, "Failed to parse FIND_NODE response from server"
    end
    return true, response
  end,

  -- Closes the socket connect to the remote node
  close = function(self)
    self.socket:close()
  end,
}

return _ENV;
