local system = require("system")
require("spec.helpers")

describe("Terminal:", function()

  local wincodepage

  setup(function()
    wincodepage = system.getconsoleoutputcp()
    assert(system.setconsoleoutputcp(system.CODEPAGE_UTF8))  -- set to UTF8
  end)

  teardown(function()
    assert(system.setconsoleoutputcp(wincodepage))
  end)



  describe("isatty()", function()

    local newtmpfile = require("pl.path").tmpname

    -- set each param to true to make it a tty, to false for a stream
    local function getttyresults(sin, sout, serr)
      assert(type(sin) == "boolean", "sin must be a boolean")
      assert(type(sout) == "boolean", "sout must be a boolean")
      assert(type(serr) == "boolean", "serr must be a boolean")

      local tmpfile = "./spec/04-term_helper.output"
      local lua_bin = system.getenv("LUA") or "lua"
      local execcmd = lua_bin .. " ./spec/04-term_helper.lua -- " .. tmpfile

      sin = sin and "" or 'echo "hello" | '
      if system.windows then
        sout = sout and "" or (" > " .. newtmpfile())
        serr = serr and "" or (" 2> " .. newtmpfile())
      else
        sout = sout and "" or (" > " .. newtmpfile())
        serr = serr and "" or (" 2> " .. newtmpfile())
      end

      local cmd = sin .. execcmd .. sout .. serr

      -- print("cmd: ", cmd)

      os.remove(tmpfile)
      assert(os.execute(cmd))
      local result = assert(require("pl.utils").readfile(tmpfile))
      os.remove(tmpfile)

      -- print("result: ", result)

      return assert(require("pl.compat").load("return " .. result))()
    end



    it("returns true for all if a terminal #manual", function()
      assert.are.same(
        {
          stdin = true,
          stdout = true,
          stderr = true,
        },
        getttyresults(true, true, true)
      )
    end)


    it("returns false for stdin if not a terminal #manual", function()
      assert.are.same(
        {
          stdin = false,
          stdout = true,
          stderr = true,
        },
        getttyresults(false, true, true)
      )
    end)


    it("returns false for stdout if not a terminal #manual", function()
      assert.are.same(
        {
          stdin = true,
          stdout = false,
          stderr = true,
        },
        getttyresults(true, false, true)
      )
    end)


    it("returns false for stderr if not a terminal #manual", function()
      assert.are.same(
        {
          stdin = true,
          stdout = true,
          stderr = false,
        },
        getttyresults(true, true, false)
      )
    end)

  end)



  describe("getconsoleflags()", function()

    win_it("returns the consoleflags #manual", function()
      local flags, err = system.getconsoleflags(io.stdout)
      assert.is_nil(err)
      assert.is_userdata(flags)
      assert.equals("bitflags:", tostring(flags):sub(1,9))
    end)


    nix_it("returns the consoleflags, always value 0", function()
      local flags, err = system.getconsoleflags(io.stdout)
      assert.is_nil(err)
      assert.is_userdata(flags)
      assert.equals("bitflags:", tostring(flags):sub(1,9))
      assert.equals(0, flags:value())
    end)


    it("returns an error if called with an invalid argument", function()
      assert.has.error(function()
        system.getconsoleflags("invalid")
      end, "bad argument #1 to 'getconsoleflags' (FILE* expected, got string)")
    end)

  end)



  describe("setconsoleflags()", function()

    win_it("sets the consoleflags #manual", function()
      local old_flags = assert(system.getconsoleflags(io.stdout))
      finally(function()
        system.setconsoleflags(io.stdout, old_flags)   -- ensure we restore the original ones
      end)

      local new_flags
      if old_flags:has_all_of(system.COF_VIRTUAL_TERMINAL_PROCESSING) then
        new_flags = old_flags - system.COF_VIRTUAL_TERMINAL_PROCESSING
      else
        new_flags = old_flags + system.COF_VIRTUAL_TERMINAL_PROCESSING
      end

      local success, err = system.setconsoleflags(io.stdout, new_flags)
      assert.is_nil(err)
      assert.is_true(success)

      local updated_flags = assert(system.getconsoleflags(io.stdout))
      assert.equals(new_flags:value(), updated_flags:value())
    end)


    nix_it("sets the consoleflags, always succeeds", function()
      assert(system.setconsoleflags(io.stdout, system.getconsoleflags(io.stdout)))
    end)


    it("returns an error if called with an invalid argument", function()
      assert.has.error(function()
        system.setconsoleflags("invalid")
      end, "bad argument #1 to 'setconsoleflags' (FILE* expected, got string)")
    end)

  end)



  describe("tcgetattr()", function()

    nix_it("gets the terminal flags #manual", function()
      local flags, err = system.tcgetattr(io.stdin)
      assert.is_nil(err)
      assert.is_table(flags)
      assert.equals("bitflags:", tostring(flags.iflag):sub(1,9))
      assert.equals("bitflags:", tostring(flags.oflag):sub(1,9))
      assert.equals("bitflags:", tostring(flags.lflag):sub(1,9))
      assert.equals("bitflags:", tostring(flags.cflag):sub(1,9))
      assert.not_equal(0, flags.iflag:value())
      assert.not_equal(0, flags.oflag:value())
      assert.not_equal(0, flags.lflag:value())
      assert.not_equal(0, flags.cflag:value())
      assert.is.table(flags.cc)
    end)


    win_it("gets the terminal flags, always 0", function()
      local flags, err = system.tcgetattr(io.stdin)
      assert.is_nil(err)
      assert.is_table(flags)
      assert.equals("bitflags:", tostring(flags.iflag):sub(1,9))
      assert.equals("bitflags:", tostring(flags.oflag):sub(1,9))
      assert.equals("bitflags:", tostring(flags.lflag):sub(1,9))
      assert.equals("bitflags:", tostring(flags.cflag):sub(1,9))
      assert.equals(0, flags.iflag:value())
      assert.equals(0, flags.oflag:value())
      assert.equals(0, flags.lflag:value())
      assert.equals(0, flags.cflag:value())
      assert.same({}, flags.cc)
    end)


    it("returns an error if called with an invalid argument", function()
      assert.has.error(function()
        system.tcgetattr("invalid")
      end, "bad argument #1 to 'tcgetattr' (FILE* expected, got string)")
    end)

  end)



  describe("tcsetattr()", function()

    nix_it("sets the terminal flags, if called with flags #manual", function()
      -- system.listtermflags(io.stdin)
      local old_flags = assert(system.tcgetattr(io.stdin))
      finally(function()
        system.tcsetattr(io.stdin, system.TCSANOW, old_flags)  -- ensure we restore the original ones
      end)

      local new_flags = assert(system.tcgetattr(io.stdin)) -- just get them again, and then update
      -- change iflags
      local flag_to_change = system.I_IGNCR
      if new_flags.iflag:has_all_of(flag_to_change) then
        new_flags.iflag = new_flags.iflag - flag_to_change
      else
        new_flags.iflag = new_flags.iflag + flag_to_change
      end

      -- change oflags
      flag_to_change = system.O_OPOST
      if new_flags.oflag:has_all_of(flag_to_change) then
        new_flags.oflag = new_flags.oflag - flag_to_change
      else
        new_flags.oflag = new_flags.oflag + flag_to_change
      end

      -- change lflags
      flag_to_change = system.L_ECHO
      if new_flags.lflag:has_all_of(flag_to_change) then
        new_flags.lflag = new_flags.lflag - flag_to_change
      else
        new_flags.lflag = new_flags.lflag + flag_to_change
      end

      assert(system.tcsetattr(io.stdin, system.TCSANOW, new_flags))

      local updated_flags = assert(system.tcgetattr(io.stdin))
      assert.equals(new_flags.iflag:value(), updated_flags.iflag:value())
      assert.equals(new_flags.oflag:value(), updated_flags.oflag:value())
      assert.equals(new_flags.lflag:value(), updated_flags.lflag:value())
    end)


    win_it("sets the terminal flags, if called with flags, always succeeds", function()
      local success, err = system.tcsetattr(io.stdin, system.TCSANOW, system.tcgetattr(io.stdin))
      assert.is_nil(err)
      assert.is_true(success)
    end)


    it("returns an error if called with an invalid first argument", function()
      assert.has.error(function()
        system.tcsetattr("invalid", system.TCSANOW, {})
      end, "bad argument #1 to 'tcsetattr' (FILE* expected, got string)")
    end)


    it("returns an error if called with an invalid second argument", function()
      assert.has.error(function()
        system.tcsetattr(io.stdin, "invalid", {})
      end, "bad argument #2 to 'tcsetattr' (number expected, got string)")
    end)


    it("returns an error if called with an invalid third argument #manual", function()
      assert.has.error(function()
        system.tcsetattr(io.stdin, system.TCSANOW, "invalid")
      end, "bad argument #3 to 'tcsetattr' (table expected, got string)")
    end)


    it("returns an error if iflag is not a bitflags object #manual", function()
      local flags = assert(system.tcgetattr(io.stdin))
      flags.iflag = 0
      assert.has.error(function()
        system.tcsetattr(io.stdin, system.TCSANOW, flags)
      end, "bad argument #3, field 'iflag' must be a bitflag object")
    end)


    it("returns an error if oflag is not a bitflags object #manual", function()
      local flags = assert(system.tcgetattr(io.stdin))
      flags.oflag = 0
      assert.has.error(function()
        system.tcsetattr(io.stdin, system.TCSANOW, flags)
      end, "bad argument #3, field 'oflag' must be a bitflag object")
    end)


    it("returns an error if lflag is not a bitflags object #manual", function()
      local flags = assert(system.tcgetattr(io.stdin))
      flags.lflag = 0
      assert.has.error(function()
        system.tcsetattr(io.stdin, system.TCSANOW, flags)
      end, "bad argument #3, field 'lflag' must be a bitflag object")
    end)

  end)



  describe("getconsolecp()", function()

    win_it("gets the console codepage", function()
      local cp, err = system.getconsolecp()
      assert.is_nil(err)
      assert.is_number(cp)
    end)

    nix_it("gets the console codepage, always 65001 (utf8)", function()
      local cp, err = system.getconsolecp()
      assert.is_nil(err)
      assert.equals(65001, cp)
    end)

  end)



  describe("setconsolecp()", function()

    win_it("sets the console codepage", function()
      local old_cp = assert(system.getconsolecp())
      finally(function()
        system.setconsolecp(old_cp)  -- ensure we restore the original one
      end)

      local new_cp
      if old_cp ~= system.CODEPAGE_UTF8 then
        new_cp = system.CODEPAGE_UTF8  -- set to UTF8
      else
        new_cp = 850    -- another common one
      end

      local success, err = system.setconsolecp(new_cp)
      assert.is_nil(err)
      assert.is_true(success)

      local updated_cp = assert(system.getconsolecp())
      assert.equals(new_cp, updated_cp)
    end)


    nix_it("sets the console codepage, always succeeds", function()
      assert(system.setconsolecp(850))
    end)


    it("returns an error if called with an invalid argument", function()
      assert.has.error(function()
        system.setconsolecp("invalid")
      end, "bad argument #1 to 'setconsolecp' (number expected, got string)")
    end)

  end)



  describe("getconsoleoutputcp()", function()

    win_it("gets the console output codepage", function()
      local cp, err = system.getconsoleoutputcp()
      assert.is_nil(err)
      assert.is_number(cp)
    end)

    nix_it("gets the console output codepage, always 65001 (utf8)", function()
      local cp, err = system.getconsoleoutputcp()
      assert.is_nil(err)
      assert.equals(65001, cp)
    end)

  end)



  describe("setconsoleoutputcp()", function()

    win_it("sets the console output codepage", function()
      local old_cp = assert(system.getconsoleoutputcp())
      finally(function()
        system.setconsoleoutputcp(old_cp)  -- ensure we restore the original one
      end)

      local new_cp
      if old_cp ~= system.CODEPAGE_UTF8 then
        new_cp = system.CODEPAGE_UTF8  -- set to UTF8
      else
        new_cp = 850    -- another common one
      end

      local success, err = system.setconsoleoutputcp(new_cp)
      assert.is_nil(err)
      assert.is_true(success)

      local updated_cp = assert(system.getconsoleoutputcp())
      assert.equals(new_cp, updated_cp)
    end)


    nix_it("sets the console output codepage, always succeeds", function()
      assert(system.setconsoleoutputcp(850))
    end)


    it("returns an error if called with an invalid argument", function()
      assert.has.error(function()
        system.setconsoleoutputcp("invalid")
      end, "bad argument #1 to 'setconsoleoutputcp' (number expected, got string)")
    end)

  end)



  describe("getnonblock()", function()

    nix_it("gets the non-blocking flag", function()
      local nb, err = system.getnonblock(io.stdin)
      assert.is_nil(err)
      assert.is_boolean(nb)
    end)


    win_it("gets the non-blocking flag, always false", function()
      local nb, err = system.getnonblock(io.stdin)
      assert.is_nil(err)
      assert.is_false(nb)
    end)


    it("returns an error if called with an invalid argument", function()
      assert.has.error(function()
        system.getnonblock("invalid")
      end, "bad argument #1 to 'getnonblock' (FILE* expected, got string)")
    end)

  end)



  describe("setnonblock()", function()

    nix_it("sets the non-blocking flag", function()
      local old_nb = system.getnonblock(io.stdin)
      assert.is.boolean(old_nb)

      finally(function()
        system.setnonblock(io.stdin, old_nb)  -- ensure we restore the original one
      end)

      local new_nb = not old_nb

      local success, err = system.setnonblock(io.stdin, new_nb)
      assert.is_nil(err)
      assert.is_true(success)

      local updated_nb = assert(system.getnonblock(io.stdin))
      assert.equals(new_nb, updated_nb)
    end)


    win_it("sets the non-blocking flag, always succeeds", function()
      local success, err = system.setnonblock(io.stdin, true)
      assert.is_nil(err)
      assert.is_true(success)
    end)


    it("returns an error if called with an invalid argument", function()
      assert.has.error(function()
        system.setnonblock("invalid")
      end, "bad argument #1 to 'setnonblock' (FILE* expected, got string)")
    end)

  end)



  describe("termsize() #manual", function()

    it("gets the terminal size", function()
      local rows, columns = system.termsize()
      assert.is_number(rows)
      assert.is_number(columns)
    end)

  end)



  describe("utf8cwidth()", function()

    -- utf-8 strings
    local ch1 = string.char(65)                  -- "A"   single
    local ch2 = string.char(240, 159, 154, 128)  -- "🚀"  double
    local ch3 = string.char(228, 189, 160)       -- "你"  double
    local ch4 = string.char(229, 165, 189)       -- "好"  double
    local ch5 = string.char(226, 130, 172)       -- "€"   ambiguous

    -- unicode codepoints
    local cp1 = 65      -- "A"   single
    local cp2 = 128640  -- "🚀"  double
    local cp3 = 20320   -- "你"  double
    local cp4 = 22909   -- "好"  double
    local cp5 = 8364    -- "€"   ambiguous

    it("handles zero width characters", function()
      assert.same({0}, {system.utf8cwidth("")}) -- empty string returns 0-size
      assert.same({0}, {system.utf8cwidth("\0")}) -- null character

      -- zero-width (from wcwidth_zero_width.c / wcwidth_update.lua)
      local zw_sp   = string.char(0xE2, 0x80, 0x8B)   -- U+200B Zero Width Space
      local zw_nj   = string.char(0xE2, 0x80, 0x8C)   -- U+200C Zero Width Non-Joiner
      local zw_j    = string.char(0xE2, 0x80, 0x8D)   -- U+200D Zero Width Joiner
      local zw_nb   = string.char(0xEF, 0xBB, 0xBF)   -- U+FEFF Zero Width No-Break Space (BOM)
      local soft_hy = string.char(0xC2, 0xAD)        -- U+00AD Soft hyphen

      assert.same({0}, {system.utf8cwidth(zw_sp)})
      assert.same({0}, {system.utf8cwidth(zw_nj)})
      assert.same({0}, {system.utf8cwidth(zw_j)})
      assert.same({0}, {system.utf8cwidth(zw_nb)})
      assert.same({0}, {system.utf8cwidth(soft_hy)})
    end)

    it("handles single width characters", function()
      assert.same({1}, {system.utf8cwidth("a")})
      assert.same({1}, {system.utf8cwidth(ch1)})
    end)

    it("handles double width characters", function()
      assert.same({2}, {system.utf8cwidth(ch2)})
      assert.same({2}, {system.utf8cwidth(ch3)})
      assert.same({2}, {system.utf8cwidth(ch4)})
    end)

    it("handles ambiguous width characters", function()
      assert.same({99}, {system.utf8cwidth(ch5, 99)})
    end)

    it("ambiguous width defaults to 1", function()
      assert.same({1}, {system.utf8cwidth(ch5, nil)})
    end)

    it("returns the width of the first character in the string", function()
      assert.same({nil, 'Control characters have no width'}, {system.utf8cwidth("\a" .. ch1)})  -- bell character + EURO
      assert.same({1}, {system.utf8cwidth(ch1 .. ch2)})
      assert.same({2}, {system.utf8cwidth(ch2 .. ch3 .. ch4)})
    end)

    it("handles integer codepoints", function()
      assert.same({1}, {system.utf8cwidth(cp1)})
      assert.same({2}, {system.utf8cwidth(cp2)})
      assert.same({2}, {system.utf8cwidth(cp3)})
      assert.same({2}, {system.utf8cwidth(cp4)})
      assert.same({99}, {system.utf8cwidth(cp5, 99)}) -- ambiguous width
    end)

    it("returns an error on control characters", function()
      assert.same({nil, 'Control characters have no width'}, {system.utf8cwidth("\a")})  -- bell character
      assert.same({nil, 'Control characters have no width'}, {system.utf8cwidth("\27")}) -- escape character
    end)

    it("returns an error on bad argument", function()
      assert.has.error(function()
        system.utf8cwidth(true)
      end, "bad argument #1 to 'utf8cwidth' (Expected UTF-8-string or codepoint-integer as first argument)")
    end)

    it("returns an error on bad unicode values", function()
      assert.same({nil, "Invalid Unicode codepoint"}, {system.utf8cwidth(-10)})
      assert.same({nil, "Invalid Unicode codepoint"}, {system.utf8cwidth(999999999999)})
    end)

  end)



  describe("utf8swidth()", function()

    local ch1 = string.char(65)                  -- "A"   single
    local ch2 = string.char(240, 159, 154, 128)  -- "🚀"  double
    local ch3 = string.char(228, 189, 160)       -- "你"  double
    local ch4 = string.char(229, 165, 189)       -- "好"  double
    local ch5 = string.char(226, 130, 172)       -- "€"   ambiguous

    it("handles zero width characters", function()
      assert.same({0}, {system.utf8swidth("")}) -- empty string returns 0-size
      assert.same({nil, 'Control characters have no width'}, {system.utf8swidth("\a")})  -- bell character
      assert.same({nil, 'Control characters have no width'}, {system.utf8swidth("\27")}) -- escape character
    end)

    it("handles multi-character UTF8 strings", function()
      assert.same({15}, {system.utf8swidth("hello " .. ch1 .. ch2 .. " world")})
      assert.same({16}, {system.utf8swidth("hello " .. ch3 .. ch4 .. " world")})
    end)

    it("handles ambiguous width characters", function()
      assert.same({12}, {system.utf8swidth(ch5 .. "1234567890", 2)})
    end)

    it("ambiguous width defaults to 1", function()
      assert.same({1}, {system.utf8swidth(ch5, nil)})
    end)

    it("ambiguous width must be 1 or 2", function()
      assert.has.error(function()
        system.utf8swidth(ch5, 3)
      end, "bad argument #2 to 'utf8swidth' (Ambiguous width must be 1 or 2)")
    end)

  end)



  describe("termbackup() & termrestore()", function()

    -- this is all Lua code, so testing one platform should be good enough
    win_it("creates and restores a backup", function()
      local backup = system.termbackup()

      local old_cp = assert(system.getconsoleoutputcp())
      finally(function()
        system.setconsoleoutputcp(old_cp)  -- ensure we restore the original one
      end)

      -- get the console page...
      local new_cp
      if old_cp ~= system.CODEPAGE_UTF8 then
        new_cp = system.CODEPAGE_UTF8  -- set to UTF8
      else
        new_cp = 850    -- another common one
      end

      -- change the console page...
      local success, err = system.setconsoleoutputcp(new_cp)
      assert.is_nil(err)
      assert.is_true(success)
      -- ... and check it
      local updated_cp = assert(system.getconsoleoutputcp())
      assert.equals(new_cp, updated_cp)

      -- restore the console page
      system.termrestore(backup)
      local restored_cp = assert(system.getconsoleoutputcp())
      assert.equals(old_cp, restored_cp)
    end)


    it("termrestore() fails on bad input", function()
      assert.has.error(function()
        system.termrestore("invalid")
      end, "arg #1 to termrestore, expected backup table, got string")
    end)

  end)



  describe("termwrap()", function()

    local old_backup
    local old_restore
    local result

    setup(function()
      old_backup = system.termbackup
      old_restore = system.termrestore
      system.termbackup = function()
        table.insert(result,"backup")
      end

      system.termrestore = function()
        table.insert(result,"restore")
      end
    end)


    before_each(function()
      result = {}
    end)


    teardown(function()
      system.termbackup = old_backup
      system.termrestore = old_restore
    end)



    it("calls both backup and restore", function()
      system.termwrap(function()
        table.insert(result,"wrapped")
      end)()

      assert.are.same({"backup", "wrapped", "restore"}, result)
    end)


    it("passes all args", function()
      system.termwrap(function(...)
        table.insert(result,{...})
      end)(1, 2, 3)

      assert.are.same({"backup", {1,2,3}, "restore"}, result)
    end)


    it("returns all results", function()
      local a, b, c = system.termwrap(function(...)
        return 1, nil, 3 -- ensure nil is passed as well
      end)()

      assert.are.same({"backup", "restore"}, result)
      assert.equals(1, a)
      assert.is_nil(b)
      assert.equals(3, c)
    end)

  end)



  describe("autotermrestore()", function()

    local old_backup
    local old_restore
    local result


    before_each(function()
      _G._TEST = true

      package.loaded["system"] = nil
      system = require("system")

      old_backup = system.termbackup
      old_restore = system.termrestore
      system.termbackup = function(...)
        table.insert(result,"backup")
        return old_backup(...)
      end

      system.termrestore = function(...)
        table.insert(result,"restore")
        return old_restore(...)
      end

      result = {}
    end)


    after_each(function()
      -- system.termbackup = old_backup
      -- system.termrestore = old_restore
      _G._TEST = false

      package.loaded["system"] = nil
      system = require("system")
    end)



    it("calls backup", function()
      local ok, err = system.autotermrestore()
      assert.is_nil(err)
      assert.is_true(ok)

      assert.are.same({"backup"}, result)
    end)


    it("returns an error on the second call", function()
      local ok, err = system.autotermrestore()
      assert.is_nil(err)
      assert.is_true(ok)

      local ok, err = system.autotermrestore()
      assert.are.equal("global terminal backup was already set up", err)
      assert.is_nil(ok)
    end)


    it("calls restore upon being garbage collected", function()
      local ok, err = system.autotermrestore()
      assert.is_nil(err)
      assert.is_true(ok)

      -- ensure tables from previous tests are GC'ed
      collectgarbage()
      collectgarbage()
      -- clear references
      result = {}
      system._reset_global_backup()
      collectgarbage()
      collectgarbage()

      assert.are.same({"restore"}, result)
    end)

  end)



  describe("keyboard input", function()

    local old_readkey = system._readkey
    local current_buffer
    local function setbuffer(str)
      assert(type(str) == "string", "setbuffer() expects a string")
      if str == "" then
        current_buffer = nil
      else
        current_buffer = str
      end
    end


    setup(function()
      system._readkey = function()
        if not current_buffer then
          return nil
        end
        local ch = current_buffer:byte(1, 1)
        if #current_buffer == 1 then
          current_buffer = nil
        else
          current_buffer = current_buffer:sub(2, -1)
        end
        return ch
      end
    end)


    teardown(function()
      system._readkey = old_readkey
    end)



    describe("readkey()", function()

      it("fails without a timeout", function()
        assert.has.error(function()
          system.readkey()
        end, "arg #1 to readkey, expected timeout in seconds, got nil")
      end)


      it("reads a single byte for single-byte characters", function()
        setbuffer("abc")
        assert.equals(string.byte("a"), system.readkey(0))
        assert.equals(string.byte("b"), system.readkey(0))
        assert.equals(string.byte("c"), system.readkey(0))
      end)


      it("reads a single byte for multi-byte chars", function()
        setbuffer(string.char(226, 130, 172) ..        -- "€"   single
                  string.char(240, 159, 154, 128))     -- "🚀"  double
        assert.equals(226, system.readkey(0))
        assert.equals(130, system.readkey(0))
        assert.equals(172, system.readkey(0))
        assert.equals(240, system.readkey(0))
        assert.equals(159, system.readkey(0))
        assert.equals(154, system.readkey(0))
        assert.equals(128, system.readkey(0))
      end)


      it("times out", function()
        setbuffer("")
        local timing = system.gettime()
        assert.same({ nil, "timeout" }, { system.readkey(0.5) })

        timing = system.gettime() - timing
        -- assert.is.near(0.5, timing, 0.1)  -- this works in CI for Unix and Windows, but not MacOS (locally it passes)
        assert.is.near(1, timing, 0.5)       -- this also works for MacOS in CI
      end)


      it("calls flseep to execute the sleep", function()
        setbuffer("")

        local sleep_called = false
        local mysleep = function()
          sleep_called = true
        end

        system.readkey(0.01, mysleep)
        assert.is_true(sleep_called)
      end)


      it("returns errors by fsleep", function()
        setbuffer("")

        local mysleep = function()
          return nil, "boom!"
        end

        local ok, err = system.readkey(1, mysleep)
        assert.is.falsy(ok)
        assert.equals("boom!", err)
      end)

    end)



    describe("readansi()", function()

      it("fails without a timeout", function()
        assert.has.error(function()
          system.readansi()
        end, "arg #1 to readansi, expected timeout in seconds, got nil")
      end)


      it("reads a single byte for single-byte characters", function()
        setbuffer("abc")
        assert.are.same({"a", "char"}, {system.readansi(0)})
        assert.are.same({"b", "char"}, {system.readansi(0)})
        assert.are.same({"c", "char"}, {system.readansi(0)})
      end)


      it("reads a control byte for single-byte control characters", function()
        setbuffer("\000\031\127")
        assert.are.same({"\000", "ctrl"}, {system.readansi(0)})
        assert.are.same({"\031", "ctrl"}, {system.readansi(0)})
        assert.are.same({"\127", "ctrl"}, {system.readansi(0)})
      end)


      it("reads a multi-byte characters one at a time", function()
        setbuffer(string.char(226, 130, 172) ..        -- "€"   single
                  string.char(240, 159, 154, 128))     -- "🚀"  double
        assert.are.same({"€", "char"}, {system.readansi(0)})
        assert.are.same({"🚀", "char"}, {system.readansi(0)})
      end)


      it("reads ANSI escape sequences, marked by '<esc>['", function()
        setbuffer("\27[A\27[B\27[C\27[D")
        assert.are.same({"\27[A", "ansi"}, {system.readansi(0)})
        assert.are.same({"\27[B", "ansi"}, {system.readansi(0)})
        assert.are.same({"\27[C", "ansi"}, {system.readansi(0)})
        assert.are.same({"\27[D", "ansi"}, {system.readansi(0)})
      end)


      it("reads ANSI escape sequences, marked by '<esc>O'", function()
        setbuffer("\27OA\27OB\27OC\27OD")
        assert.are.same({"\27OA", "ansi"}, {system.readansi(0)})
        assert.are.same({"\27OB", "ansi"}, {system.readansi(0)})
        assert.are.same({"\27OC", "ansi"}, {system.readansi(0)})
        assert.are.same({"\27OD", "ansi"}, {system.readansi(0)})
      end)


      it("reads ANSI escape sequences, just an '<esc>O' (<alt><O> key press)", function()
        setbuffer("\27O")
        assert.are.same({"\27O", "ansi"}, {system.readansi(0)})
      end)


      it("reads <alt>+key key presses; <esc>+<key>", function()
        setbuffer("\27a\27b\27c\27d")
        assert.are.same({"\27a", "ansi"}, {system.readansi(0)})
        assert.are.same({"\27b", "ansi"}, {system.readansi(0)})
        assert.are.same({"\27c", "ansi"}, {system.readansi(0)})
        assert.are.same({"\27d", "ansi"}, {system.readansi(0)})
      end)


      it("reads <ctrl><alt>[ key press; <esc>+<esc>", function()
        setbuffer("\27\27\27\27")
        assert.are.same({"\27\27", "ansi"}, {system.readansi(0)})
        assert.are.same({"\27\27", "ansi"}, {system.readansi(0)})
      end)


      it("returns a single <esc> character if no sequence is found", function()
        setbuffer("\27")
        assert.are.same({"\27", "ctrl"}, {system.readansi(0)})
      end)


      it("times out", function()
        setbuffer("")
        local timing = system.gettime()
        assert.same({ nil, "timeout" }, { system.readansi(0.5) })

        timing = system.gettime() - timing
        -- assert.is.near(0.5, timing, 0.1)  -- this works in CI for Unix and Windows, but not MacOS (locally it passes)
        assert.is.near(1, timing, 0.5)       -- this also works for MacOS in CI
      end)


      it("incomplete ANSI sequences will be completed on next call", function()
        setbuffer("\27[")
        assert.are.same({nil, "timeout", "\27["}, {system.readansi(0)})
        setbuffer("A")
        assert.are.same({"\27[A", "ansi"}, {system.readansi(0)})
      end)


      it("incomplete UTF8 sequences will be completed on next call", function()
        setbuffer(string.char(240, 159))
        assert.are.same({nil, "timeout", string.char(240, 159)}, {system.readansi(0)})
        setbuffer(string.char(154, 128))
        assert.are.same({"🚀", "char"}, {system.readansi(0)})
      end)

    end)

  end)

end)
