local bin = require "bin"
local http = require "http"
local nmap = require "nmap"
local os = require "os"
local stdnse = require "stdnse"
local tab = require "tab"
local table = require "table"
_ENV = stdnse.module("ipp", stdnse.seeall)

---
--
-- A small CUPS ipp (Internet Printing Protocol) library implementation
--
-- @author Patrik Karlsson
--

-- The IPP layer
IPP = {

  StatusCode = {
    OK        = 0,
  },

  State = {
    IPP_JOB_PENDING     = 3,
    IPP_JOB_HELD        = 4,
    IPP_JOB_PROCESSING  = 5,
    IPP_JOB_STOPPED     = 6,
    IPP_JOB_CANCELED    = 7,
    IPP_JOB_ABORTED     = 8,
    IPP_JOB_COMPLETED   = 9,
  },

  StateName = {
    [3] = "Pending",
    [4] = "Held",
    [5] = "Processing",
    [6] = "Stopped",
    [7] = "Canceled",
    [8] = "Aborted",
    [9] = "Completed",
  },

  OperationID = {
    IPP_CANCEL_JOB           = 0x0008,
    IPP_GET_JOB_ATTRIBUTES   = 0x0009,
    IPP_GET_JOBS             = 0x000a,
    CUPS_GET_PRINTERS        = 0x4002,
    CUPS_GET_DOCUMENT        = 0x4027
  },

  PrinterState = {
    IPP_PRINTER_IDLE         = 3,
    IPP_PRINTER_PROCESSING   = 4,
    IPP_PRINTER_STOPPED      = 5,
  },

  Attribute = {

    IPP_TAG_OPERATION = 0x01,
    IPP_TAG_JOB = 0x02,
    IPP_TAG_END = 0x03,
    IPP_TAG_PRINTER = 0x04,
    IPP_TAG_INTEGER = 0x21,
    IPP_TAG_ENUM = 0x23,
    IPP_TAG_NAME = 0x42,
    IPP_TAG_KEYWORD = 0x44,
    IPP_TAG_URI = 0x45,
    IPP_TAG_CHARSET = 0x47,
    IPP_TAG_LANGUAGE = 0x48,

    new = function(self, tag, name, value)
      local o = { tag = tag, name = name, value = value }
      setmetatable(o, self)
      self.__index = self
      return o
    end,

    parse = function(data, pos)
      local attrib = IPP.Attribute:new()
      local val
      pos, attrib.tag, attrib.name, val = bin.unpack(">CPP", data, pos)
      -- print(attrib.name, stdnse.tohex(val))
      attrib.value = {}
      table.insert(attrib.value, { tag = attrib.tag, val = val })

      repeat
        local tag, name_len, val

        if ( #data < pos + 3 ) then
          break
        end

        pos, tag, name_len = bin.unpack(">CS", data, pos)
        if ( name_len == 0 ) then
          pos, val = bin.unpack(">P", data, pos)
          table.insert(attrib.value, { tag = tag, val = val })
        else
          pos = pos - 3
        end
      until( name_len ~= 0 )

      -- do minimal decoding
      for i=1, #attrib.value do
        if ( attrib.value[i].tag == IPP.Attribute.IPP_TAG_INTEGER ) then
          attrib.value[i].val = select(2, bin.unpack(">I", attrib.value[i].val))
        elseif ( attrib.value[i].tag == IPP.Attribute.IPP_TAG_ENUM ) then
          attrib.value[i].val = select(2, bin.unpack(">I", attrib.value[i].val))
        end
      end

      if ( 1 == #attrib.value ) then
        attrib.value = attrib.value[1].val
      end
      --print(attrib.name, attrib.value, stdnse.tohex(val))

      return pos, attrib
    end,

    __tostring = function(self)
      if ( "string" == type(self.value) ) then
        return bin.pack(">CSASA", self.tag, #self.name, self.name, #self.value, self.value)
      else
        local data = bin.pack(">CSASA", self.tag, #self.name, self.name, #self.value[1].val, self.value[1].val)
        for i=2, #self.value do
          data = data .. bin.pack(">CSP", self.value[i].tag, 0, self.value[i].val)
        end
        return data
      end
    end

  },

  -- An attribute group, groups several attributes
  AttributeGroup = {

    new = function(self, tag, attribs)
      local o = { tag = tag, attribs = attribs or {} }
      setmetatable(o, self)
      self.__index = self
      return o
    end,

    addAttribute = function(self, attrib)
      table.insert(self.attribs, attrib)
    end,

    --
    -- Gets the first attribute matching name and optionally tag from the
    -- attribute group.
    --
    -- @param name string containing the attribute name
    -- @param tag number containing the attribute tag
    getAttribute = function(self, name, tag)
      for _, attrib in ipairs(self.attribs) do
        if ( attrib.name == name ) then
          if ( not(tag) ) then
            return attrib
          elseif ( tag and attrib.tag == tag ) then
            return attrib
          end
        end
      end
    end,

    getAttributeValue = function(self, name, tag)
      for _, attrib in ipairs(self.attribs) do
        if ( attrib.name == name ) then
          if ( not(tag) ) then
            return attrib.value
          elseif ( tag and attrib.tag == tag ) then
            return attrib.value
          end
        end
      end
    end,

    __tostring = function(self)
      local data = bin.pack("C", self.tag)

      for _, attrib in ipairs(self.attribs) do
        data = data .. tostring(attrib)
      end
      return data
    end

  },

  -- The IPP request
  Request = {

    new = function(self, opid, reqid)
      local o = {
        version        = 0x0101,
        opid           = opid,
        reqid          = reqid,
        attrib_groups  = {},
      }
      setmetatable(o, self)
      self.__index = self
      return o
    end,

    addAttributeGroup = function(self, group)
      table.insert( self.attrib_groups, group )
    end,

    __tostring = function(self)
      local data = bin.pack(">SSI", self.version, self.opid, self.reqid )

      for _, group in ipairs(self.attrib_groups) do
        data = data .. tostring(group)
      end
      data = data .. bin.pack("C", IPP.Attribute.IPP_TAG_END)
      return data
    end,

  },

  -- A class to handle responses from the server
  Response = {

    -- Creates a new instance of response
    new = function(self)
      local o = {}
      setmetatable(o, self)
      self.__index = self
      return o
    end,

    getAttributeGroups = function(self, tag)
      local groups = {}
      for _, v in ipairs(self.attrib_groups or {}) do
        if ( v.tag == tag ) then
          table.insert(groups, v)
        end
      end
      return groups
    end,

    parse = function(data)
      local resp = IPP.Response:new()
      local pos

      pos, resp.version, resp.status, resp.reqid = bin.unpack(">SSI", data)

      resp.attrib_groups = {}
      local group
      repeat
        local tag, attrib
        pos, tag = bin.unpack(">C", data, pos)

        if ( tag == IPP.Attribute.IPP_TAG_OPERATION or
          tag == IPP.Attribute.IPP_TAG_JOB or
          tag == IPP.Attribute.IPP_TAG_PRINTER or
          tag == IPP.Attribute.IPP_TAG_END ) then

          if ( group ) then
            table.insert(resp.attrib_groups, group)
            group = IPP.AttributeGroup:new(tag)
          else
            group = IPP.AttributeGroup:new(tag)
          end
        else
          pos = pos - 1
        end

        if ( not(group) ) then
          stdnse.debug2("Unexpected tag: %d", tag)
          return
        end

        pos, attrib = IPP.Attribute.parse(data, pos)
        group:addAttribute(attrib)

      until( pos == #data  + 1)

      return resp
    end,

  },


}

HTTP = {

  Request = function(host, port, request)
    local headers = {
      ['Content-Type'] = 'application/ipp',
      ['User-Agent'] = 'CUPS/1.5.1',
    }
    port.version.service_tunnel = "ssl"
    local http_resp = http.post(host, port, '/', { header = headers }, nil, tostring(request))
    if ( http_resp.status ~= 200 ) then
      return false, "Unexpected response from server"
    end

    local response = IPP.Response.parse(http_resp.body)
    if ( not(response) ) then
      return false, "Failed to parse response"
    end

    return true, response
  end,

}


Helper = {

  new = function(self, host, port, options)
    local o = { host = host, port = port, options = options or {} }
    setmetatable(o, self)
    self.__index = self
    return o
  end,

  connect = function(self)
    self.socket = nmap.new_socket()
    self.socket:set_timeout(self.options.timeout or 10000)
    return self.socket:connect(self.host, self.port)
  end,

  getPrinters = function(self)

    local attribs = {
      IPP.Attribute:new(IPP.Attribute.IPP_TAG_CHARSET, "attributes-charset", "utf-8" ),
      IPP.Attribute:new(IPP.Attribute.IPP_TAG_LANGUAGE, "attributes-natural-language", "en"),
    }

    local ag = IPP.AttributeGroup:new(IPP.Attribute.IPP_TAG_OPERATION, attribs)
    local request = IPP.Request:new(IPP.OperationID.CUPS_GET_PRINTERS, 1)
    request:addAttributeGroup(ag)

    local status, response = HTTP.Request( self.host, self.port, tostring(request) )
    if ( not(response) ) then
      return status, response
    end

    local printers = {}

    for _, ag in ipairs(response:getAttributeGroups(IPP.Attribute.IPP_TAG_PRINTER)) do
      local attrib = {
        ["printer-name"] = "name",
        ["printer-location"] = "location",
        ["printer-make-and-model"] = "model",
        ["printer-state"] = "state",
        ["queued-job-count"] = "queue_count",
        ["printer-dns-sd-name"] = "dns_sd_name",
      }

      local printer = {}
      for k, v in pairs(attrib) do
        if ( ag:getAttributeValue(k) ) then
          printer[v] = ag:getAttributeValue(k)
        end
      end
      table.insert(printers, printer)
    end
    return true, printers
  end,

  getQueueInfo = function(self, uri)
    local uri = uri or ("ipp://%s/"):format(self.host.ip)

    local attribs = {
      IPP.Attribute:new(IPP.Attribute.IPP_TAG_CHARSET, "attributes-charset", "utf-8" ),
      IPP.Attribute:new(IPP.Attribute.IPP_TAG_LANGUAGE, "attributes-natural-language", "en-us"),
      IPP.Attribute:new(IPP.Attribute.IPP_TAG_URI, "printer-uri", uri),
      IPP.Attribute:new(IPP.Attribute.IPP_TAG_KEYWORD, "requested-attributes", {
        -- { tag = IPP.Attribute.IPP_TAG_KEYWORD, val = "job-originating-host-name"},
        { tag = IPP.Attribute.IPP_TAG_KEYWORD, val = "com.apple.print.JobInfo.PMJobName"},
        { tag = IPP.Attribute.IPP_TAG_KEYWORD, val = "com.apple.print.JobInfo.PMJobOwner"},
        { tag = IPP.Attribute.IPP_TAG_KEYWORD, val = "job-id" },
        { tag = IPP.Attribute.IPP_TAG_KEYWORD, val = "job-k-octets" },
        { tag = IPP.Attribute.IPP_TAG_KEYWORD, val = "job-name" },
        { tag = IPP.Attribute.IPP_TAG_KEYWORD, val = "job-state" },
        { tag = IPP.Attribute.IPP_TAG_KEYWORD, val = "printer-uri" },
        -- { tag = IPP.Attribute.IPP_TAG_KEYWORD, val = "job-originating-user-name" },
        -- { tag = IPP.Attribute.IPP_TAG_KEYWORD, val = "job-printer-state-message" },
        -- { tag = IPP.Attribute.IPP_TAG_KEYWORD, val = "job-printer-uri" },
        { tag = IPP.Attribute.IPP_TAG_KEYWORD, val = "time-at-creation" } } ),
        IPP.Attribute:new(IPP.Attribute.IPP_TAG_KEYWORD, "which-jobs", "not-completed" )
    }

    local ag = IPP.AttributeGroup:new(IPP.Attribute.IPP_TAG_OPERATION, attribs)
    local request = IPP.Request:new(IPP.OperationID.IPP_GET_JOBS, 1)
    request:addAttributeGroup(ag)

    local status, response = HTTP.Request( self.host, self.port, tostring(request) )
    if ( not(response) ) then
      return status, response
    end

    local results = {}
    for _, ag in ipairs(response:getAttributeGroups(IPP.Attribute.IPP_TAG_JOB)) do
      local uri = ag:getAttributeValue("printer-uri")
      local printer = uri:match(".*/(.*)$") or "Unknown"
      -- some jobs have multiple state attributes, so far the ENUM ones have been correct
      local state = ag:getAttributeValue("job-state", IPP.Attribute.IPP_TAG_ENUM) or ag:getAttributeValue("job-state")
      -- some jobs have multiple id tag, so far the INTEGER type have shown the correct ID
      local id = ag:getAttributeValue("job-id", IPP.Attribute.IPP_TAG_INTEGER) or ag:getAttributeValue("job-id")
      local attr = ag:getAttribute("time-at-creation")
      local tm = ag:getAttributeValue("time-at-creation")
      local size = ag:getAttributeValue("job-k-octets") .. "k"
      local jobname = ag:getAttributeValue("com.apple.print.JobInfo.PMJobName") or "Unknown"
      local owner = ag:getAttributeValue("com.apple.print.JobInfo.PMJobOwner") or "Unknown"

      results[printer] = results[printer] or {}
      table.insert(results[printer], {
        id = id,
        time = os.date("%Y-%m-%d %H:%M:%S", tm),
        state = ( IPP.StateName[tonumber(state)] or "Unknown" ),
        size = size,
        owner = owner,
        jobname = jobname })
    end

    local output = {}
    for name, entries in pairs(results) do
      local t = tab.new(5)
      tab.addrow(t, "id", "time", "state", "size (kb)", "owner", "jobname")
      for _, entry in ipairs(entries) do
        tab.addrow(t, entry.id, entry.time, entry.state, entry.size, entry.owner, entry.jobname)
      end
      if ( 1<#t ) then
        table.insert(output, { name = name, tab.dump(t) })
      end
    end

    return output
  end,

  close = function(self)
    return self.socket:close()
  end,
}

return _ENV;
