#!/usr/bin/env lua

local VISUALDELAY = os.getenv("VISUALDELAY")

local visual = VISUALDELAY or false
local visual_delay = VISUALDELAY and (tonumber(VISUALDELAY)) or 0.1
local short_delay = 0.3
local long_delay = 1

local unistd = require("posix.unistd")
local time = require("posix.time")
local curses = require("posix.curses")
local rote = require("rote")

local rt = rote.RoteTerm(24, 80)

--[[
local function os_execread(cmd)
   local fd = io.popen(cmd, "r")
   local out = fd:read("*a")
   fd:close()
   return (out:gsub("\n$", ""))
end
]]
--local branch = os_execread("git branch | grep '*'"):sub(3)
--print("Running in branch "..branch)

os.execute("make coverage")
os.execute("rm -f *.gcda */*.gcda")
os.execute("rm -f coverage.info test.htoprc")
os.execute("rm -rf lcov")
os.execute("killall htop")
os.execute("ps aux | grep '[s]leep 12345' | awk '{print $2}' | xargs kill 2> /dev/null")

os.execute("cp ./default.htoprc ./test.htoprc")
rt:forkPty("LC_ALL=C HTOPRC=./test.htoprc ./htop 2> htop-valgrind.txt")

local stdscr, term_win
-- Curses initialization needed even when not in visual mode
-- because luaposix only initializes KEY_* constants after initscr().
stdscr = curses.initscr()
if visual then
   curses.echo(false)
   curses.start_color()
   curses.raw(true)
   curses.halfdelay(1)
   stdscr:keypad(true)
   term_win = curses.newwin(24, 80, 0, 0)
   local function makePair(foreground, background)
      return background * 8 + 7 - foreground
   end
   -- initialize the color pairs the way rt:draw() expects it
   for foreground = 0, 7 do
      for background = 0, 7 do
         if foreground ~= 7 or background ~= 0 then
            local pair = makePair(foreground, background)
            curses.init_pair(pair, foreground, background)
         end
      end
   end
else
   curses.endwin()
end

local function show(key)
   rt:update()
   if visual then
      rt:draw(term_win, 0, 0)
      if key then
         term_win:mvaddstr(0, 0, tostring(key))
      end
      term_win:refresh()

      delay(visual_delay)
   end
end

local function send(key, times, quick)
   if times == 0 then return end
   for _ = 1, times or 1 do
      delay(0.003) -- 30ms delay to avoid clobbering Esc sequences
      if type(key) == "string" then
         for c in key:gmatch('.') do
            rt:keyPress(string.byte(c))
         end
      else
         rt:keyPress(key)
      end
      if not quick then
         show(key)
      end
   end
   if quick then
      show(key)
   end
end

local function string_at(x, y, len)
   rt:update()
   local out = {}
   for i = 1, len do
      out[#out+1] = rt:cellChar(y-1, x+i-2)
   end
   return table.concat(out)
end

local function is_string_at(x, y, str)
   return string_at(x, y, #str) == str
end

local function check_string_at(x, y, str)
   return { str, string_at(x, y, #str) }
end

local ESC = "\27\27"

function delay(t)
   time.nanosleep({ tv_sec = math.floor(t), tv_nsec = (t - math.floor(t)) * 1000000000 })
end

delay(2) -- give some time for htop to initialize.
rt:update()

local y_panelhdr = (function()
   for y = 1, 24 do
      if is_string_at(3, y, "PID") then
         return y
      end
   end
end)() or 1

assert.not_equal(y_panelhdr, 1)

local x_metercol2 = 41

show()

os.execute("sleep 12345 &")

local function terminated()
   return not os.execute("ps aux | grep -q '\\./[h]top'")
end

local function running_it(desc, fn)
   it(desc, function()
      assert(not terminated())
      show()
      fn()
      assert(not terminated())
   end)
end

local function check(t)
   return t[1], t[2]
end

local attrs = {
   black_on_cyan = 6,
   red_on_cyan = 22,
   white_on_black = 176,
   yellow_on_black = 112,
}

local function find_selected_y(from)
   rt:update()
   for y = from or (y_panelhdr + 1), rt:rows() - 1 do
      local attr = rt:cellAttr(y-1, 1)
      if attr == attrs.black_on_cyan then
         return y
      end
   end
   return y_panelhdr + 1
end

local function find_command_x()
   for x = 1, 80 do
      if is_string_at(x, y_panelhdr, "Command") then
         return x
      end
   end
   return 64
end

local function set_display_option(n)
   send("S")
   send(curses.KEY_DOWN)
   send(curses.KEY_RIGHT)
   send(curses.KEY_DOWN, n, "quick")
   send("\n")
   send(curses.KEY_F10)
end

describe("htop test suite", function()

   running_it("performs incremental filter", function()
      send("\\")
      send("x\127bux\127sted") -- test backspace
      send("\n")
      delay(short_delay)
      rt:update()
      local pid = ("      "..tostring(unistd.getpid())):sub(-5)
      local ourpid = check_string_at(1, y_panelhdr + 1, pid)
      send("\\")
      send(ESC)
      send(curses.KEY_F5)
      send(curses.KEY_HOME)
      delay(short_delay)
      rt:update()
      local initpid = check_string_at(1, y_panelhdr + 1, "    1")
      delay(short_delay)
      rt:update()
      send(curses.KEY_F5)
      assert.equal(check(ourpid))
      assert.equal(check(initpid))
   end)

   running_it("performs incremental search", function()
      send(curses.KEY_HOME)
      send("/")
      send("busted")
      local attr = rt:cellAttr(rt:rows() - 1, 30)
      delay(short_delay)
      local line = find_selected_y()
      local pid = ("      "..tostring(unistd.getpid())):sub(-5)
      assert.equal(attr, attrs.black_on_cyan)
      local ourpid = check_string_at(1, line, pid)
      send("\n")
      send(curses.KEY_HOME)
      assert.equal(check(ourpid))
   end)

   running_it("performs pid search", function()
      send(curses.KEY_F5)
      send(curses.KEY_END)
      send("1")
      delay(short_delay)
      local line = find_selected_y()
      local initpid = check_string_at(1, line, "    1")
      send(curses.KEY_F5)
      assert.equal(check(initpid))
   end)


   running_it("horizontal scroll", function()
      local h_scroll = 20
      send(curses.KEY_F5)
      delay(short_delay)
      local str1 = string_at(1+h_scroll, y_panelhdr+1, 5)
      send(curses.KEY_RIGHT)
      delay(short_delay)
      local str2 = string_at(1, y_panelhdr+1, 5)
      send(curses.KEY_LEFT)
      delay(short_delay)
      local str3 = string_at(1+h_scroll, y_panelhdr+1, 5)
      send(curses.KEY_LEFT)
      delay(short_delay)
      local str4 = string_at(1+h_scroll, y_panelhdr+1, 5)
      send(curses.KEY_F5)
      assert.equal(str1, str2)
      assert.equal(str2, str3)
      assert.equal(str3, str4)
   end)

   running_it("kills a process", function()
      send(curses.KEY_HOME)
      send("\\")
      send("sleep 12345")
      local attr = rt:cellAttr(rt:rows() - 1, 30)
      assert.equal(attr, attrs.black_on_cyan)
      send("\n")
      delay(short_delay)
      rt:update()
      local col = find_command_x()
      local procname = check_string_at(col, y_panelhdr + 1, "sleep 12345")
      send("k")
      send("\n")
      send("\\")
      send(ESC)
      delay(short_delay)
      assert.equal(check(procname))
      assert.not_equal((os.execute("ps aux | grep -q '[s]leep 12345'")), true)
   end)

   running_it("runs strace", function()
      send(curses.KEY_HOME)
      send("/")
      send("busted")
      send("\n")
      send("s")
      delay(long_delay)
      send(ESC)
   end)

   running_it("runs lsof", function()
      send(curses.KEY_HOME)
      send("/")
      send("busted")
      send("\n")
      send("l")
      delay(long_delay)
      send(ESC)
   end)

   running_it("performs filtering in lsof", function()
      send(curses.KEY_HOME)
      send("/")
      send("htop")
      send("\n")
      send("l")
      send(curses.KEY_F4)
      send("pipe")
      delay(long_delay)
      local pipefd = check_string_at(1, 3, "    3")
      send(ESC)
      assert.equal(check(pipefd))
   end)

   running_it("performs search in lsof", function()
      send(curses.KEY_HOME)
      send("/")
      send("htop")
      send("\n")
      send("l")
      send(curses.KEY_F3)
      send("pipe")
      delay(long_delay)
      local line = find_selected_y(3)
      local pipefd = check_string_at(1, line, "    3")
      send(ESC)
      assert.equal(check(pipefd))
   end)


   running_it("cycles through meter modes in the default meters", function()
      send("S")
      for _ = 1, 2 do
         send(curses.KEY_RIGHT)
         for _ = 1, 3 do
            send("\n", 4)
            send(curses.KEY_DOWN)
         end
      end
      send(ESC)
   end)

   running_it("show process of a user", function()
      send(curses.KEY_F5)
      send("u")
      send(curses.KEY_DOWN)
      delay(short_delay)
      rt:update()
      local chosen = string_at(1, y_panelhdr + 2, 9)
      send("\n")
      send(curses.KEY_HOME)
      delay(short_delay)
      rt:update()
      local shown = string_at(7, y_panelhdr + 1, 9)
      send("u")
      send("\n")
      send(curses.KEY_HOME)
      delay(short_delay)
      rt:update()
      local inituser = string_at(7, y_panelhdr + 1, 9)
      send(curses.KEY_F5)
      assert.equal(shown, chosen)
      assert.equal(inituser, "root     ")
   end)

   running_it("performs failing search", function()
      send(curses.KEY_HOME)
      send("/")
      send("xxxxxxxxxx")
      delay(short_delay)
      rt:update()
      local attr = rt:cellAttr(rt:rows() - 1, 30)
      assert.equal(attr, attrs.red_on_cyan)
      send("\n")
   end)

   running_it("cycles through search", function()
      send(curses.KEY_HOME)
      send("/")
      send("sh")
      local lastpid
      local pidpairs = {}
      for _ = 1, 3 do
         send(curses.KEY_F3)
         local line = find_selected_y()
         local pid = string_at(1, line, 5)
         if lastpid then
            pidpairs[#pidpairs + 1] = { lastpid, pid }
            lastpid = pid
         end
      end
      send(curses.KEY_HOME)
      for _, pair in pairs(pidpairs) do
         assert.not_equal(pair[1], pair[2])
      end
   end)

   running_it("visits each setup screen", function()
      send("S")
      send(curses.KEY_DOWN, 3)
      send(curses.KEY_F10)
   end)

   running_it("adds and removes PPID column", function()
      send("S")
      send(curses.KEY_DOWN, 3)
      send(curses.KEY_RIGHT, 2)
      send(curses.KEY_DOWN, 2)
      send("\n")
      send(curses.KEY_F10)
      delay(short_delay)
      local ppid = check_string_at(2, y_panelhdr, "PPID")
      send("S")
      send(curses.KEY_DOWN, 3)
      send(curses.KEY_RIGHT, 1)
      send(curses.KEY_DC)
      send(curses.KEY_F10)
      delay(short_delay)
      local not_ppid = check_string_at(2, y_panelhdr, "PPID")
      assert.equal(check(ppid))
      assert.not_equal(check(not_ppid))
   end)

   running_it("changes CPU affinity for a process", function()
      send("a")
      send(" \n")
      send(ESC)
   end)

   running_it("renices for a process", function()
      send("/")
      send("busted")
      send("\n")
      local line = find_selected_y()
      local before = check_string_at(22, line, " 0")
      send(curses.KEY_F8)
      delay(short_delay)
      local after = check_string_at(22, line, " 1")
      assert.equal(check(before))
      assert.equal(check(after))
   end)

   running_it("tries to lower nice for a process", function()
      send("/")
      send("busted")
      send("\n")
      local line = find_selected_y()
      local before = string_at(22, line, 2)
      send(curses.KEY_F7)
      delay(short_delay)
      local after = string_at(22, line, 2)
      assert.equal(before, after) -- no permissions
   end)

   running_it("invert sort order", function()
      local cpu_col = 45
      send("P")
      send("I")
      send(curses.KEY_HOME)
      delay(short_delay)
      local zerocpu = check_string_at(cpu_col, y_panelhdr + 1, " 0.0")
      send("I")
      delay(short_delay)
      local nonzerocpu = check_string_at(cpu_col, y_panelhdr + 1, " 0.0")
      assert.equal(check(zerocpu))
      assert.not_equal(check(nonzerocpu))
   end)

   running_it("changes IO priority for a process", function()
      send("/")
      send("htop")
      send("\n")
      send("i")
      send(curses.KEY_END)
      send("\n")
      send(ESC)
   end)

   running_it("shows help", function()
      send(curses.KEY_F1)
      send("\n")
      set_display_option(9)
      send(curses.KEY_F1)
      send("\n")
      set_display_option(9)
   end)

   running_it("moves meters around", function()
      send("S")
      send(curses.KEY_RIGHT)
      send(curses.KEY_UP)
      send("\n")
      send(curses.KEY_DOWN)
      send(curses.KEY_UP)
      send(curses.KEY_RIGHT)
      send(curses.KEY_RIGHT)
      send(curses.KEY_LEFT)
      send(curses.KEY_LEFT)
      send("\n")
      send(curses.KEY_F10)
   end)

   local meters = {
      { name = "clock", down = 0, string = "Time" },
      { name = "load", down = 2, string = "Load" },
      { name = "battery", down = 7, string = "Battery" },
      { name = "hostname", down = 8, string = "Hostname" },
      { name = "memory", down = 3, string = "Mem" },
      { name = "CPU average", down = 16, string = "Avg" },
   }

   running_it("checks various CPU meters", function()
      send("S")
      send(curses.KEY_RIGHT, 3)
      send(curses.KEY_DOWN, 9, "quick")
      for _ = 9, 14 do
         send("\n")
         send("\n")
         send(curses.KEY_DC)
         send(curses.KEY_RIGHT)
         send(curses.KEY_DOWN)
      end
   end)

   for _, item in ipairs(meters) do
      running_it("adds and removes a "..item.name.." widget", function()
         send("S")
         send(curses.KEY_RIGHT, 3)
         send(curses.KEY_DOWN, item.down)
         send("\n")
         send(curses.KEY_UP, 4)
         send("\n")
         send(curses.KEY_F4, 4) -- cycle through meter modes
         delay(short_delay)
         rt:update()
         local with = check_string_at(x_metercol2, 2, item.string)
         send(curses.KEY_DC)
         delay(short_delay)
         local without = check_string_at(x_metercol2, 2, item.string)
         send(curses.KEY_F10)
         assert.equal(check(with))
         assert.not_equal(check(without))
      end)
   end

   running_it("goes through themes", function()
      send(curses.KEY_F2)
      send(curses.KEY_DOWN, 2)
      send(curses.KEY_RIGHT)
      for _ = 1, 6 do
         send("\n")
         send(curses.KEY_DOWN)
      end
      send(curses.KEY_UP, 6)
      send("\n")
      send(curses.KEY_F10)
   end)

   local display_options = {
      { name = "tree view", down = 0 },
      { name = "shadow other user's process", down = 1 },
      { name = "hide kernel threads", down = 2 },
      { name = "hide userland threads", down = 3 },
      { name = "display threads in different color", down = 4 },
      { name = "show custom thread names", down = 5 },
      { name = "highlight basename", down = 6 },
      { name = "highlight large numbers", down = 7 },
      { name = "leave margin around header", down = 8 },
      { name = "use detailed CPU time", down = 9 },
      { name = "count from zero", down = 10 },
      { name = "update process names", down = 11 },
      { name = "guest time in CPU%", down = 12 },
   }

   for _, item in ipairs(display_options) do
      running_it("checks display option to "..item.name, function()
         for _ = 1, 2 do
            set_display_option(item.down)
            delay(short_delay)
         end
      end)
   end

   running_it("shows detailed CPU with guest time", function()
      for _ = 1, 2 do
         send("S")
         send(curses.KEY_DOWN)
         send(curses.KEY_RIGHT)
         send(curses.KEY_DOWN, 9)
         send("\n")
         send(curses.KEY_DOWN, 3)
         send("\n")
         send(curses.KEY_LEFT)
         send(curses.KEY_UP)
         send(curses.KEY_RIGHT)
         send(curses.KEY_F4, 4) -- cycle through CPU meter modes
         send(curses.KEY_F10)
         delay(short_delay)
      end
   end)

   running_it("expands and collapses tree", function()
      send(curses.KEY_F5) -- tree view
      send(curses.KEY_HOME)
      send(curses.KEY_DOWN) -- second process in the tree
      send("-")
      send("+")
      send(curses.KEY_F5)
   end)

   running_it("sets sort key", function()
      send(".")
      send("\n")
   end)

   running_it("tags all children", function()
      send(curses.KEY_F5) -- tree view
      send(curses.KEY_HOME) -- ensure we're at init
      send("c")
      local taggedattrs = {}
      rt:update()
      for y = y_panelhdr + 2, 23 do
         table.insert(taggedattrs, rt:cellAttr(y-1, 4))
      end
      delay(short_delay)
      send("U")
      local untaggedattrs = {}
      rt:update()
      for y = y_panelhdr + 2, 23 do
         table.insert(untaggedattrs, rt:cellAttr(y-1, 4))
      end
      send(curses.KEY_F5)

      for _, taggedattr in ipairs(taggedattrs) do
         assert.equal(attrs.yellow_on_black, taggedattr)
      end
      for _, untaggedattr in ipairs(untaggedattrs) do
         assert.equal(attrs.white_on_black, untaggedattr)
      end
   end)

   for i = 1, 62 do
      running_it("show column "..i, function()
         send("S")
         send(curses.KEY_END)
         send(curses.KEY_RIGHT, 1)
         if i > 1 then
            send(curses.KEY_DC)
         end
         send(curses.KEY_RIGHT, 1)
         local down = i
         while down > 13 do
            send(curses.KEY_NPAGE)
            down = down - 13
         end
         send(curses.KEY_DOWN, down, "quick")
         send("\n")
         send(curses.KEY_F10)
         if i == 62 then
            send("S")
            send(curses.KEY_END)
            send(curses.KEY_RIGHT, 1)
            if i > 1 then
               send(curses.KEY_DC)
            end
            send(curses.KEY_F10)
         end
      end)
   end

   it("finally quits", function()
      assert(not terminated())
      send("q")
      while not terminated() do
         unistd.sleep(1)
         send("q")
      end
      assert(terminated())
      if visual then
         curses.endwin()
      end
      os.execute("make lcov && xdg-open lcov/index.html")
   end)
end)
