-- A (very (very!)) simple IRC client. Reference:
-- http://tools.ietf.org/html/rfc2812

local component = require("component")
local computer = require("computer")

if not component.isAvailable("internet") then
  io.stderr:write("OpenIRC requires an Internet Card to run!\n")
  return
end

local event = require("event")
local internet = require("internet")
local shell = require("shell")
local term = require("term")
local text = require("text")

local args, options = shell.parse(...)
if #args < 1 then
  print("Usage: irc <nickname> [server:port]")
  return
end

local nick = args[1]
local host = args[2] or "irc.esper.net:6667"

-- try to connect to server.
local sock, reason = internet.open(host)
if not sock then
  io.stderr:write(reason .. "\n")
  return
end

-- custom print that uses all except the last line for printing.
local function print(message, overwrite)
  local w, h = component.gpu.getResolution()
  local line
  repeat
    line, message = text.wrap(text.trim(message), w, w)
    if not overwrite then
      component.gpu.copy(1, 1, w, h - 1, 0, -1)
    end
    overwrite = false
    component.gpu.fill(1, h - 1, w, 1, " ")
    component.gpu.set(1, h - 1, line)
  until not message or message == ""
end

-- utility method for reply tracking tables.
function autocreate(table, key)
  table[key] = {}
  return table[key]
end

-- extract nickname from identity.
local function name(identity)
  return identity and identity:match("^[^!]+") or identity or "Anonymous"
end

-- user defined callback for messages (via `lua function(msg) ... end`)
local callback = nil

-- list of whois info per user (used to accumulate whois replies).
local whois = setmetatable({}, {__index=autocreate})

-- list of users per channel (used to accumulate names replies).
local names = setmetatable({}, {__index=autocreate})

-- timer used to drive socket reading.
local timer

-- ignored commands, reserved according to RFC.
-- http://tools.ietf.org/html/rfc2812#section-5.3
local ignore = {
  [213]=true, [214]=true, [215]=true, [216]=true, [217]=true,
  [218]=true, [231]=true, [232]=true, [233]=true, [240]=true,
  [241]=true, [244]=true, [244]=true, [246]=true, [247]=true,
  [250]=true, [300]=true, [316]=true, [361]=true, [362]=true,
  [363]=true, [373]=true, [384]=true, [492]=true,
  -- custom ignored responses.
  [265]=true, [266]=true, [330]=true
}

-- command numbers to names.
local commands = {
--Replys
  RPL_WELCOME = "001",
  RPL_YOURHOST = "002",
  RPL_CREATED = "003",
  RPL_MYINFO = "004",
  RPL_BOUNCE = "005",
  RPL_LUSERCLIENT = "251",
  RPL_LUSEROP = "252",
  RPL_LUSERUNKNOWN = "253",
  RPL_LUSERCHANNELS = "254",
  RPL_LUSERME = "255",
  RPL_AWAY = "301",
  RPL_UNAWAY = "305",
  RPL_NOWAWAY = "306",
  RPL_WHOISUSER = "311",
  RPL_WHOISSERVER = "312",
  RPL_WHOISOPERATOR = "313",
  RPL_WHOISIDLE = "317",
  RPL_ENDOFWHOIS = "318",
  RPL_WHOISCHANNELS = "319",
  RPL_CHANNELMODEIS = "324",
  RPL_NOTOPIC = "331",
  RPL_TOPIC = "332",
  RPL_NAMREPLY = "353",
  RPL_ENDOFNAMES = "366",
  RPL_MOTDSTART = "375",
  RPL_MOTD = "372",
  RPL_ENDOFMOTD = "376",
  RPL_WHOISSECURE = "671",
  RPL_HELPSTART = "704",
  RPL_HELPTXT = "705",
  RPL_ENDOFHELP = "706",
  RPL_UMODEGMSG = "718",
  
--Errors
  ERR_BANLISTFULL = "478",
  ERR_CHANNELISFULL = "471",
  ERR_UNKNOWNMODE = "472",
  ERR_INVITEONLYCHAN = "473",
  ERR_BANNEDFROMCHAN = "474",
  ERR_CHANOPRIVSNEEDED = "482",
  ERR_UNIQOPRIVSNEEDED = "485",
  ERR_USERNOTINCHANNEL = "441",
  ERR_NOTONCHANNEL = "442",
  ERR_NICKCOLLISION = "436",
  ERR_NICKNAMEINUSE = "433",
  ERR_ERRONEUSNICKNAME = "432",
  ERR_WASNOSUCHNICK = "406",
  ERR_TOOMANYCHANNELS = "405",
  ERR_CANNOTSENDTOCHAN = "404",
  ERR_NOSUCHCHANNEL = "403",
  ERR_NOSUCHNICK = "401",
  ERR_MODELOCK = "742"
}

-- main command handling callback.
local function handleCommand(prefix, command, args, message)
  ---------------------------------------------------
  -- Keepalive

  if command == "PING" then
    sock:write(string.format("PONG :%s\r\n", message))
    sock:flush()

  ---------------------------------------------------
  -- General commands
  elseif command == "NICK" then
    print(name(prefix) .. " is now known as " .. tostring(args[1] or message) .. ".")
  elseif command == "MODE" then
    print("[" .. args[1] .. "] Mode is now " .. tostring(args[2] or message) .. ".")
  elseif command == "QUIT" then
    print(name(prefix) .. " quit (" .. (message or "Quit") .. ").")
  elseif command == "JOIN" then
    print("[" .. args[1] .. "] " .. name(prefix) .. " entered the room.")
  elseif command == "PART" then
    print("[" .. args[1] .. "] " .. name(prefix) .. " has left the room (quit: " .. (message or "Quit") .. ").")
  elseif command == "TOPIC" then
    print("[" .. args[1] .. "] " .. name(prefix) .. " has changed the topic to: " .. message)
  elseif command == "KICK" then
    print("[" .. args[1] .. "] " .. name(prefix) .. " kicked " .. args[2])
  elseif command == "PRIVMSG" then
    if string.find(message, "\001TIME\001") then
      sock:write("NOTICE " .. name(prefix) .. " :\001TIME " .. os.date() .. "\001\r\n")
      sock:flush()
    elseif string.find(message, "\001VERSION\001") then
      sock:write("NOTICE " .. name(prefix) .. " :\001VERSION Minecraft/OpenComputers Lua 5.2\001\r\n")
      sock:flush()
    elseif string.find(message, "\001PING") then
      sock:write("NOTICE " .. name(prefix) .. " :" .. message .. "\001\r\n")
      sock:flush()
    end
    if string.find(message, nick) then
      computer.beep()
    end
    if string.find(message, "\001ACTION") then
      print("[" .. args[1] .. "] " .. name(prefix) .. string.gsub(string.gsub(message, "\001ACTION", ""), "\001", ""))
    else
      print("[" .. args[1] .. "] " .. name(prefix) .. ": " .. message)
    end
  elseif command == "NOTICE" then
    print("[NOTICE] " .. message)
  elseif command == "ERROR" then
    print("[ERROR] " .. message)

  ---------------------------------------------------
  -- Ignored reserved numbers
  -- -- http://tools.ietf.org/html/rfc2812#section-5.3

  elseif tonumber(command) and ignore[tonumber(command)] then
    -- ignore

  ---------------------------------------------------
  -- Command replies
  -- http://tools.ietf.org/html/rfc2812#section-5.1

  elseif command == commands.RPL_WELCOME then
    print(message)
  elseif command == commands.RPL_YOURHOST then -- ignore
  elseif command == commands.RPL_CREATED then -- ignore
  elseif command == commands.RPL_MYINFO then -- ignore
  elseif command == commands.RPL_BOUNCE then -- ignore
  elseif command == commands.RPL_LUSERCLIENT then
    print(message)
  elseif command == commands.RPL_LUSEROP then -- ignore
  elseif command == commands.RPL_LUSERUNKNOWN then -- ignore
  elseif command == commands.RPL_LUSERCHANNELS then -- ignore
  elseif command == commands.RPL_LUSERME then
    print(message)
  elseif command == commands.RPL_AWAY then
    print(string.format("%s is away: %s", name(args[1]), message))
  elseif command == commands.RPL_UNAWAY or command == commands.RPL_NOWAWAY then
    print(message)
  elseif command == commands.RPL_WHOISUSER then
    local nick = args[2]:lower()
    whois[nick].nick = args[2]
    whois[nick].user = args[3]
    whois[nick].host = args[4]
    whois[nick].realName = message
  elseif command == commands.RPL_WHOISSERVER then
    local nick = args[2]:lower()
    whois[nick].server = args[3]
    whois[nick].serverInfo = message
  elseif command == commands.RPL_WHOISOPERATOR then
    local nick = args[2]:lower()
    whois[nick].isOperator = true
  elseif command == commands.RPL_WHOISIDLE then
    local nick = args[2]:lower()
    whois[nick].idle = tonumber(args[3])
  elseif command == commands.RPL_WHOISSECURE then
    local nick = args[2]:lower()
    whois[nick].secureconn = "Is using a secure connection"
  elseif command == commands.RPL_ENDOFWHOIS then
    local nick = args[2]:lower()
    local info = whois[nick]
    if info.nick then print("Nick: " .. info.nick) end
    if info.user then print("User name: " .. info.user) end
    if info.realName then print("Real name: " .. info.realName) end
    if info.host then print("Host: " .. info.host) end
    if info.server then print("Server: " .. info.server .. (info.serverInfo and (" (" .. info.serverInfo .. ")") or "")) end
    if info.secureconn then print(info.secureconn) end
    if info.channels then print("Channels: " .. info.channels) end
    if info.idle then print("Idle for: " .. info.idle) end
    whois[nick] = nil
  elseif command == commands.RPL_WHOISCHANNELS then
    local nick = args[2]:lower()
    whois[nick].channels = message
  elseif command == commands.RPL_CHANNELMODEIS then
    print("Channel mode for " .. args[1] .. ": " .. args[2] .. " (" .. args[3] .. ")")
  elseif command == commands.RPL_NOTOPIC then
    print("No topic is set for " .. args[1] .. ".")
  elseif command == commands.RPL_TOPIC then
    print("Topic for " .. args[1] .. ": " .. message)
  elseif command == commands.RPL_NAMREPLY then
    local channel = args[3]
    table.insert(names[channel], message)
  elseif command == commands.RPL_ENDOFNAMES then
    local channel = args[2]
    print("Users on " .. channel .. ": " .. (#names[channel] > 0 and table.concat(names[channel], " ") or "none"))
    names[channel] = nil
  elseif command == commands.RPL_MOTDSTART then
    if options.motd then
      print(message .. args[1])
    end
  elseif command == commands.RPL_MOTD then
    if options.motd then
      print(message)
    end
  elseif command == commands.RPL_ENDOFMOTD then -- ignore
  elseif command == commands.RPL_HELPSTART or 
  command == commands.RPL_HELPTXT or 
  command == commands.RPL_ENDOFHELP then
    print(message)
  elseif command == commands.ERR_BANLISTFULL or
  command == commands.ERR_BANNEDFROMCHAN or
  command == commands.ERR_CANNOTSENDTOCHAN or
  command == commands.ERR_CHANNELISFULL or
  command == commands.ERR_CHANOPRIVSNEEDED or
  command == commands.ERR_ERRONEUSNICKNAME or
  command == commands.ERR_INVITEONLYCHAN or
  command == commands.ERR_NICKCOLLISION or
  command == commands.ERR_NOSUCHNICK or
  command == commands.ERR_NOTONCHANNEL or
  command == commands.ERR_UNIQOPRIVSNEEDED or
  command == commands.ERR_UNKNOWNMODE or
  command == commands.ERR_USERNOTINCHANNEL or
  command == commands.ERR_WASNOSUCHNICK or
  command == commands.ERR_MODELOCK then
    print("[ERROR]: " .. message)
  elseif tonumber(command) and (tonumber(command) >= 200 and tonumber(command) < 400) then
    print("[Response " .. command .. "] " .. table.concat(args, ", ") .. ": " .. message)

  ---------------------------------------------------
  -- Error messages. No real point in handling those manually.
  -- http://tools.ietf.org/html/rfc2812#section-5.2

  elseif tonumber(command) and (tonumber(command) >= 400 and tonumber(command) < 600) then
    print("[Error] " .. table.concat(args, ", ") .. ": " .. message)

  ---------------------------------------------------
  -- Unhandled message.

  else
    print("Unhandled command: " .. command .. ": " .. message)
  end
end

-- catch errors to allow manual closing of socket and removal of timer.
local result, reason = pcall(function()
  -- say hello.
  term.clear()
  print("Welcome to OpenIRC!")

  -- avoid sock:read locking up the computer.
  sock:setTimeout(0.05)

  -- http://tools.ietf.org/html/rfc2812#section-3.1
  sock:write(string.format("NICK %s\r\n", nick))
  sock:write(string.format("USER %s 0 * :%s [OpenComputers]\r\n", nick:lower(), nick))
  sock:flush()

  -- socket reading logic (receive messages) driven by a timer.
  timer = event.timer(0.5, function()
    if not sock then
      return false
    end
    repeat
      local ok, line = pcall(sock.read, sock)
      if ok then
        if not line then
          print("Connection lost.")
          sock:close()
          sock = nil
          return false
        end
        line = text.trim(line) -- get rid of trailing \r
        local match, prefix = line:match("^(:(%S+) )")
        if match then line = line:sub(#match + 1) end
        local match, command = line:match("^(([^:]%S*))")
        if match then line = line:sub(#match + 1) end
        local args = {}
        repeat
          local match, arg = line:match("^( ([^:]%S*))")
          if match then
            line = line:sub(#match + 1)
            table.insert(args, arg)
          end
        until not match
        local message = line:match("^ :(.*)$")

        if callback then
          local result, reason = pcall(callback, prefix, command, args, message)
          if not result then
            print("Error in callback: " .. tostring(reason))
          end
        end
        handleCommand(prefix, command, args, message)
      end
    until not ok
  end, math.huge)

  -- default target for messages, so we don't have to type /msg all the time.
  local target = nil

  -- command history.
  local history = {}

  repeat
    local w, h = component.gpu.getResolution()
    term.setCursor(1, h)
    term.write((target or "?") .. "> ")
    local line = term.read(history)
    if sock and line and line ~= "" then
      line = text.trim(line)
      if line:lower():sub(1,4) == "/me " then
        print("[" .. (target or "?") .. "] You " .. string.gsub(line, "/me ", ""), true)
      else
        print("[" .. (target or "?") .. "] me: " .. line, true)
      end
      if line:lower():sub(1, 5) == "/msg " then
        local user, message = line:sub(6):match("^(%S+) (.+)$")
        if message then
          message = text.trim(message)
        end
        if not user or not message or message == "" then
          print("Invalid use of /msg. Usage: /msg nick|channel message.")
          line = ""
        else
          target = user
          line = "PRIVMSG " .. target .. " :" .. message
        end
      elseif line:lower():sub(1, 6) == "/join " then
        local channel = text.trim(line:sub(7))
        if not channel or channel == "" then
          print("Invalid use of /join. Usage: /join channel.")
          line = ""
        else
          target = channel
          line = "JOIN " .. channel
        end
      elseif line:lower():sub(1, 5) == "/lua " then
        local script = text.trim(line:sub(6))
        local result, reason = load(script, "=stdin", setmetatable({print=print, socket=sock}, {__index=_G}))
        if not result then
          result, reason = load("return " .. script, "=stdin", setmetatable({print=print, socket=sock}, {__index=_G}))
        end
        line = ""
        if not result then
          print("Error: " .. tostring(reason))
        else
          result, reason = pcall(result)
          if not result then
            print("Error: " .. tostring(reason))
          elseif type(reason) == "function" then
            callback = reason
          elseif reason then
            line = tostring(reason)
          end
        end
      elseif line:lower():sub(1,4) == "/me " then
        if not target then
          print("No default target set. Use /msg or /join to set one.")
          line = ""
        else
          line = "PRIVMSG " .. target .. " :\001ACTION " .. line:sub(5) .. "\001"
        end
      elseif line:sub(1, 1) == "/" then
        line = line:sub(2)
      elseif line ~= "" then
        if not target then
          print("No default target set. Use /msg or /join to set one.")
          line = ""
        else
          line = "PRIVMSG " .. target .. " :" .. line
        end
      end
      if line and line ~= "" then
        sock:write(line .. "\r\n")
        sock:flush()
      end
    end
  until not sock or not line
end)

if sock then
  sock:write("QUIT\r\n")
  sock:close()
end
if timer then
  event.cancel(timer)
end

if not result then
  error(reason, 0)
end
return reason
