-- ----------------------------------------------------------------------------
-- test.lua
-- Luabins test suite
-- See copyright notice in luabins.h
-- ----------------------------------------------------------------------------

package.cpath = "./?.so;"..package.cpath

local randomseed = 1235134892
--local randomseed = os.time()

print("===== BEGIN LUABINS TEST SUITE (seed " .. randomseed .. ") =====")
math.randomseed(randomseed)

-- ----------------------------------------------------------------------------
-- Utility functions
-- ----------------------------------------------------------------------------

local invariant = function(v)
  return function()
    return v
  end
end

local escape_string = function(str)
  return str:gsub(
      "[^0-9A-Za-z_%- :]",
      function(c)
        return ("%%%02X"):format(c:byte())
      end
    )
end

local ensure_equals = function(msg, actual, expected)
  if actual ~= expected then
    error(
        msg..":\n  actual: `"..escape_string(tostring(actual))
        .."`\nexpected: `"..escape_string(tostring(expected)).."'"
      )
  end
end

local ensure_equals_permute
do
  -- Based on MIT-licensed
  -- http://snippets.luacode.org/sputnik.lua?p=snippets/ \
  -- Iterator_over_Permutations_of_a_Table_62
  -- Which is based on PiL
  local function permgen(a, n, fn)
    if n == 0 then
      fn(a)
    else
      for i = 1, n do
        -- put i-th element as the last one
        a[n], a[i] = a[i], a[n]

        -- generate all permutations of the other elements
        permgen(a, n - 1, fn)

        -- restore i-th element
        a[n], a[i] = a[i], a[n]
      end
    end
  end

  --- an iterator over all permutations of the elements of a list.
  -- Please note that the same list is returned each time,
  -- so do not keep references!
  -- @param a list-like table
  -- @return an iterator which provides the next permutation as a list
  local function permute_iter(a, n)
    local n = n or #a
    local co = coroutine.create(function() permgen(a, n, coroutine.yield) end)
    return function() -- iterator
      local code, res = coroutine.resume(co)
      return res
    end
  end

  ensure_equals_permute = function(
      msg,
      actual,
      expected_prefix,
      expected_body,
      expected_suffix,
      expected_body_size
    )
    expected_body_size = expected_body_size or #expected_body

    local expected
    for t in permute_iter(expected_body, expected_body_size) do
      expected = expected_prefix .. table.concat(t) .. expected_suffix
      if actual == expected then
        return actual
      end
    end

    error(
        msg..":\nactual: `"..escape_string(tostring(actual))
        .."`\nexpected one of permutations: `"
        ..escape_string(tostring(expected)).."'"
      )
  end
end

local function deepequals(lhs, rhs)
  if type(lhs) ~= "table" or type(rhs) ~= "table" then
    return lhs == rhs
  end

  local checked_keys = {}

  for k, v in pairs(lhs) do
    checked_keys[k] = true
    if not deepequals(v, rhs[k]) then
      return false
    end
  end

  for k, v in pairs(rhs) do
    if not checked_keys[k] then
      return false -- extra key
    end
  end

  return true
end

local nargs = function(...)
  return select("#", ...), ...
end

local pack = function(...)
  return select("#", ...), { ... }
end

local eat_true = function(t, ...)
  if t == nil then
    error("failed: " .. (...))
  end
  return ...
end

-- ----------------------------------------------------------------------------
-- Test helper functions
-- ----------------------------------------------------------------------------

local luabins_local = require 'luabins'
assert(luabins_local == luabins)

assert(type(luabins.save) == "function")
assert(type(luabins.load) == "function")

local check_load_fn_ok = function(eq, saved, ...)
  local expected = { nargs(...) }
  local loaded = { nargs(eat_true(luabins.load(saved))) }

  ensure_equals("num arguments match", loaded[1], expected[1])
  for i = 2, expected[1] do
    assert(eq(loaded[i], expected[i]))
  end

  return saved
end

local check_load_ok = function(saved, ...)
  return check_load_fn_ok(deepequals, saved, ...)
end

local check_fn_ok = function(eq, ...)
  local saved = assert(luabins.save(...))

  assert(type(saved) == "string")

  print("saved length", #saved, "(display truncated to 70 chars)")
  print(escape_string(saved):sub(1, 70))

  return check_load_fn_ok(eq, saved, ...)
end

local check_ok = function(...)
  print("check_ok")
  return check_fn_ok(deepequals, ...)
end

local check_fail_save = function(msg, ...)
  print("check_fail_save")
  local res, err = luabins.save(...)
  ensure_equals("result", res, nil)
  ensure_equals("error message", err, msg)
--  print("/check_fail_save")
end

local check_fail_load = function(msg, v)
  print("check_fail_load")
  local res, err = luabins.load(v)
  ensure_equals("result", res, nil)
  ensure_equals("error message", err, msg)
--  print("/check_fail_load")
end

print("===== BEGIN LARGE DATA OK =====")

-- Based on actual bug.
-- This dataset triggered Lua C data stack overflow.
-- (Note that bug is not triggered if check_ok is used)
-- Update data with
-- $ lua etc/toluabins.lua test/large_data.lua>test/large_data.luabins
-- WARNING: Keep this test above other tests, so Lua stack is small.
assert(
    luabins.load(
        assert(io.open("test/large_data.luabins", "r"):read("*a"))
      )
  )

print("===== LARGE DATA OK =====")

-- ----------------------------------------------------------------------------
-- Basic tests
-- ----------------------------------------------------------------------------

print("===== BEGIN BASIC TESTS =====")

print("---> basic corrupt data tests")

check_fail_load("can't load: corrupt data", "")
check_fail_load("can't load: corrupt data", "bad data")

print("---> basic extra data tests")
do
  local s

  s = check_ok()
  check_fail_load("can't load: extra data at end", s .. "-")

  s = check_ok(nil)
  check_fail_load("can't load: extra data at end", s .. "-")

  s = check_ok(true)
  check_fail_load("can't load: extra data at end", s .. "-")

  s = check_ok(false)
  check_fail_load("can't load: extra data at end", s .. "-")

  s = check_ok(42)
  check_fail_load("can't load: extra data at end", s .. "-")

  s = check_ok(math.pi)
  check_fail_load("can't load: extra data at end", s .. "-")

  s = check_ok(1/0)
  check_fail_load("can't load: extra data at end", s .. "-")

  s = check_ok(-1/0)
  check_fail_load("can't load: extra data at end", s .. "-")

  s = check_ok("Luabins")
  check_fail_load("can't load: extra data at end", s .. "-")

  s = check_ok({ })

  check_fail_load("can't load: extra data at end", s .. "-")

  s = check_ok({ a = 1, 2 })
  check_fail_load("can't load: extra data at end", s .. "-")
end

print("---> basic type tests")

-- This is the way to detect NaN
check_fn_ok(function(lhs, rhs) return lhs ~= lhs and rhs ~= rhs end, 0/0)

check_ok("")

check_ok("Embedded\0Zero")

check_ok(("longstring"):rep(1024000))

check_fail_save("can't save: unsupported type detected", function() end)
check_fail_save(
    "can't save: unsupported type detected",
    coroutine.create(function() end)
  )
check_fail_save("can't save: unsupported type detected", newproxy())

print("---> basic table tests")

check_ok({ 1 })
check_ok({ a = 1 })
check_ok({ a = 1, 2, [42] = true, [math.pi] = math.huge })
check_ok({ { } })
check_ok({ a = {}, b = { c = 7 } })
check_ok({ 1, 2, 3 })
check_ok({ [1] = 1, [1.5] = 2, [2] = 3 })
check_ok({ 1, nil, 3 })
check_ok({ 1, nil, 3, [{ 1, nil, 3 }] = { 1, nil, 3 } })

print("---> basic tuple tests")

check_ok(nil, nil)

do
  local s = check_ok(nil, false, true, 42, "Embedded\0Zero", { { [{3}] = 54 } })
  check_fail_load("can't load: extra data at end", s .. "-")

  check_ok(check_ok(s)) -- Save data string couple of times more
end

print("---> basic table tuple tests")

check_ok({ a = {}, b = { c = 7 } }, nil, { { } }, 42)

check_ok({ ["1"] = "str", [1] = "num" })

check_ok({ [true] = true })
check_ok({ [true] = true, [false] = false, 1 })

print("---> basic fail save tests")

check_fail_save(
    "can't save: unsupported type detected",
    { { function() end } }
  )

check_fail_save(
    "can't save: unsupported type detected",
    nil, false, true, 42, "Embedded\0Zero", function() end,
    { { [{3}] = 54 } }
  )

print("---> recursive table test")

local t = {}; t[1] = t
check_fail_save("can't save: nesting is too deep", t)

print("---> metatable test")

check_ok(setmetatable({}, {__index = function(t, k) return k end}))

print("===== BASIC TESTS OK =====")

print("===== BEGIN FORMAT SANITY TESTS =====")

-- Format sanity checks for LJ2 compatibility tests.
-- These tests are intended to help debugging actual problems
-- of test suite, and are not feature complete.
-- What is not checked here, checked in the rest of suite.

do
  do
    local saved = check_ok(1)
    local expected =
      "\001".."N"
      .. "\000\000\000\000\000\000\240\063" -- Note number is a double

    ensure_equals(
        "1 as number",
        expected,
        saved
      )
  end

  do
    local saved = check_ok({ [true] = 1 })
    local expected =
      "\001".."T"
      .. "\000\000\000\000".."\001\000\000\000"
      .. "1"
      .. "N\000\000\000\000\000\000\240\063" -- Note number is a double

    ensure_equals(
        "1 as value",
        expected,
        saved
      )
  end

  do
    local saved = check_ok({ [1] = true })
    local expected =
      "\001".."T"
      .. "\001\000\000\000".."\000\000\000\000"
      .. "N\000\000\000\000\000\000\240\063" -- Note number is a double
      .. "1"

    ensure_equals(
        "1 as key",
        expected,
        saved
      )
  end
end

print("===== FORMAT SANITY TESTS OK =====")

print("===== BEGIN AUTOCOLLAPSE TESTS =====")

-- Note: those are ad-hoc tests, tuned for old implementation
-- which generated save data on Lua stack.
-- These tests are kept here for performance comparisons.

local LUABINS_CONCATTHRESHOLD = 1024

local gen_t = function(size)
  -- two per numeric entry, three per string entry,
  -- two entries per key-value pair
  local actual_size = math.ceil(size / (2 + 3))
  print("generating table of "..actual_size.." pairs")
  local t = {}
  for i = 1, actual_size do
    t[i] = "a"..i
  end
  return t
end

-- Test table value autocollapse
check_ok(gen_t(LUABINS_CONCATTHRESHOLD - 100)) -- underflow, no autocollapse
check_ok(gen_t(LUABINS_CONCATTHRESHOLD)) -- autocollapse, no extra elements
check_ok(gen_t(LUABINS_CONCATTHRESHOLD + 100)) -- autocollapse, extra elements

-- Test table key autocollapse
check_ok({ [gen_t(LUABINS_CONCATTHRESHOLD - 4)] = true })

-- Test multiarg autocollapse
check_ok(
    1,
    gen_t(LUABINS_CONCATTHRESHOLD - 5),
    2,
    gen_t(LUABINS_CONCATTHRESHOLD - 5),
    3
  )

print("===== AUTOCOLLAPSE TESTS OK =====")

print("===== BEGIN MIN TABLE SIZE TESTS =====")

do
  -- one small key
  do
    local data = { [true] = true }
    local saved = check_ok(data)
    ensure_equals(
        "format sanity check",
        "\001".."T".."\000\000\000\000".."\001\000\000\000".."11",
        saved
      )
    check_fail_load(
        "can't load: corrupt data, bad size",
        saved:sub(1, #saved - 1)
      )

    -- As long as array and hash size sum is correct
    -- (and both are within limits), load is successful.
    -- If values are swapped, we get some performance hit.
    check_load_ok(
        "\001".."T".."\001\000\000\000".."\000\000\000\000".."11",
        data
      )

    check_fail_load(
        "can't load: corrupt data, bad size",
        "\001".."T".."\001\000\000\000".."\001\000\000\000".."11"
      )

    check_fail_load(
        "can't load: corrupt data, bad size",
        "\001".."T".."\000\000\000\000".."\002\000\000\000".."11"
      )

    check_fail_load(
        "can't load: extra data at end",
        "\001".."T".."\000\000\000\000".."\000\000\000\000".."11"
      )

    check_fail_load(
        "can't load: corrupt data, bad size",
        "\001".."T".."\255\255\255\255".."\255\255\255\255".."11"
      )
    check_fail_load(
        "can't load: corrupt data, bad size",
        "\001".."T".."\000\255\255\255".."\000\255\255\255".."11"
      )
    check_fail_load(
        "can't load: corrupt data, bad size",
        "\255".."T".."\000\000\000\000".."\000\000\000\000"
      )
  end

  -- two small keys
  do
    local data = { [true] = true, [false] = false }
    local saved = check_ok({ [true] = true, [false] = false })
    ensure_equals_permute(
        "format sanity check",
        saved,
        "\001" .. "T" .. "\000\000\000\000" .. "\002\000\000\000",
        {
          "0" .. "0";
          "1" .. "1";
        },
        ""
      )
    check_fail_load(
        "can't load: corrupt data, bad size",
        saved:sub(1, #saved - 1)
      )

    -- See above about swapped array and hash sizes
    check_load_ok(
        "\001".."T".."\001\000\000\000".."\001\000\000\000".."1100",
        data
      )

    check_fail_load(
        "can't load: corrupt data, bad size",
        "\001".."T".."\000\000\000\000".."\003\000\000\000".."0011"
      )
  end

  -- two small and one large key
  do
    local saved = check_ok({ [true] = true, [false] = false, [1] = true })
    ensure_equals_permute(
        "format sanity check",
        saved,
        "\001" .. "T" .. "\001\000\000\000" .. "\002\000\000\000",
        {
          "0" .. "0";
          "1" .. "1";
           -- Note number is a double
          "N\000\000\000\000\000\000\240\063" .. "1";
        },
        ""
      )

    check_fail_load(
        "can't load: corrupt data",
        saved:sub(1, #saved - 1)
      )

    check_fail_load(
        "can't load: corrupt data, bad size",
        "\001".."T"
        .. "\002\000\000\000".."\002\000\000\000"
        .. "0011"
        .. "N\000\000\000\000\000\000\240\063"
        .. "1"
      )

    check_fail_load(
        "can't load: corrupt data, bad size",
        "\001".."T"
        .. "\001\000\000\000".."\003\000\000\000"
        .. "0011"
        .. "N\000\000\000\000\000\000\240\063"
        .. "1"
      )
  end

  -- two small and two large keys
  do
    local saved = check_ok(
        { [true] = true, [false] = false, [1] = true, [42] = true }
      )
    local expected =
      "\001".."T"
      .. "\001\000\000\000".."\003\000\000\000"
      .. "0011"
    ensure_equals_permute(
        "format sanity check",
        saved,
        "\001" .. "T" .. "\001\000\000\000" .. "\003\000\000\000",
        {
          "0" .. "0";
          "1" .. "1";
          "N\000\000\000\000\000\000\069\064" .. "1";
          "N\000\000\000\000\000\000\240\063" .. "1";
        },
        ""
      )

    check_fail_load(
        "can't load: corrupt data",
        saved:sub(1, #saved - 1)
      )

    check_fail_load(
        "can't load: corrupt data, bad size",
        "\001".."T"
        .. "\001\000\000\000".."\005\000\000\000"
        .. "0011"
        .. "N\000\000\000\000\000\000\069\064"
        .. "1"
        .. "N\000\000\000\000\000\000\240\063"
        .. "1"
      )

    check_fail_load(
        "can't load: corrupt data, bad size",
        "\001".."T"
        .. "\003\000\000\000".."\003\000\000\000"
        .. "0011"
        .. "N\000\000\000\000\000\000\069\064"
        .. "1"
        .. "N\000\000\000\000\000\000\240\063"
        .. "1"
      )
  end
end

print("===== MIN TABLE SIZE TESTS OK =====")

print("===== BEGIN LOAD TRUNCATION TESTS =====")

local function gen_random_dataset(num, nesting)
  num = num or math.random(0, 128)
  nesting = nesting or 1

  local gen_str = function()
    local t = {}
    local n = math.random(0, 1024)
    for i = 1, n do
      t[i] = string.char(math.random(0, 255))
    end
    return table.concat(t)
  end

  local gen_bool = function() return math.random() >= 0.5 end

  local gen_nil = function() return nil end

  local generators =
  {
    gen_nil;
    gen_nil;
    gen_nil;
    gen_bool;
    gen_bool;
    gen_bool;
    function() return math.random() end;
    function() return math.random(-10000, 10000) end;
    function() return math.random() * math.random(-10000, 10000) end;
    gen_str;
    gen_str;
    gen_str;
    function()
      if nesting >= 24 then
        return nil
      end

      local t = {}
      local n = math.random(0, 24 - nesting)
      for i = 1, n do
        local k = gen_random_dataset(1, nesting + 1)
        if k == nil then
          k = "(nil)"
        end
        t[ k ] = gen_random_dataset(
            1,
            nesting + 1
          )
      end

      return t
    end;
  }

  local t = {}
  for i = 1, num do
    local n = math.random(1, #generators)
    t[i] = generators[n]()
  end
  return unpack(t, 0, num)
end

local random_dataset_num, random_dataset_data = pack(gen_random_dataset())
local random_dataset_saved = check_ok(
    unpack(random_dataset_data, 0, random_dataset_num)
  )

local num_tries = 100
local errors = {}
for i = 1, num_tries do
  local to = math.random(1, #random_dataset_saved - 1)
  local new_data = random_dataset_saved:sub(1, to)

  local res, err = luabins.load(new_data)
  ensure_equals("truncated data must not be loaded", res, nil)
  errors[err] = (errors[err] or 0) + 1
end

print("truncation errors encountered:")
for err, n in pairs(errors) do
  print(err, n)
end

print("===== BASIC LOAD TRUNCATION OK =====")

print("===== BEGIN LOAD MUTATION TESTS =====")

local function mutate_string(str, num, override)
  num = num or math.random(1, 8)

  if num < 1 then
    return str
  end

  local mutators =
  {
    -- truncate at end
    function(str)
      local pos = math.random(1, #str)
      return str:sub(1, pos)
    end;
    -- truncate at beginning
    function(str)
      local pos = math.random(1, #str)
      return str:sub(-pos)
    end;
    -- cut out the middle
    function(str)
      local from = math.random(1, #str)
      local to = math.random(from, #str)
      return str:sub(1, from) .. str:sub(to)
    end;
    -- swap two halves
    function(str)
      local pos = math.random(1, #str)
      return str:sub(pos + 1, #str) .. str:sub(1, pos)
    end;
    -- swap two characters
    function(str)
      local pa, pb = math.random(1, #str), math.random(1, #str)
      local a, b = str:sub(pa, pa), str:sub(pb, pb)
      return
        str:sub(1, pa - 1) ..
        a ..
        str:sub(pa + 1, pb - 1) ..
        b ..
        str:sub(pb + 1, #str)
    end;
    -- replace one character
    function(str)
      local pos = math.random(1, #str)
      return
        str:sub(1, pos - 1) ..
        string.char(math.random(0, 255)) ..
        str:sub(pos + 1, #str)
    end;
    -- increase one character
    function(str)
      local pos = math.random(1, #str)
      local b = str:byte(pos, pos) + 1
      if b > 255 then
        b = 0
      end
      return
        str:sub(1, pos - 1) ..
        string.char(b) ..
        str:sub(pos + 1, #str)
    end;
    -- decrease one character
    function(str)
      local pos = math.random(1, #str)
      local b = str:byte(pos, pos) - 1
      if b < 0 then
        b = 255
      end
      return
        str:sub(1, pos - 1) ..
        string.char(b) ..
        str:sub(pos + 1, #str)
    end;
  }

  local n = override or math.random(1, #mutators)

  str = mutators[n](str)

  return mutate_string(str, num - 1, override)
end

local num_tries = 100000
local num_successes = 0
local errors = {}
for i = 1, num_tries do
  local new_data = mutate_string(random_dataset_saved)

  local res, err = luabins.load(new_data)
  if res == nil then
    errors[err] = (errors[err] or 0) + 1
  else
     num_successes = num_successes + 1
  end
end

if num_successes == 0 then
  print("no mutated strings loaded successfully")
else
  -- This is ok, since we may corrupt data, not format.
  -- If it is an issue for user, he must append checksum to data,
  -- as usual.
  print("mutated strings loaded successfully: "..num_successes)
end

print("mutation errors encountered:")
for err, n in pairs(errors) do
  print(err, n)
end

print("===== BASIC LOAD MUTATION OK =====")

print("OK")
