
--[[
Copyright (c) 2012 Vladimir Dronnikov <dronnikov@gmail.com>

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
]]--

local table = require('table')
local debug = require('debug')
local os = require('os')

-- TODO: set options by command line args.
local options = {
  use_color = true
}

local function now()
  return lev.hrtime()
end

local A = { }

--
-- assertions
--

A.ok = function(a)
  return not not a
end

local function equal(a, b)
  if a == b then
    return true
  elseif type(a) == 'table' and type(b) == 'table' then
    -- compare array part
    for i = 1, #a do
      if not equal(a[i], b[i]) then return false end
    end
    for i = 1, #b do
      if not equal(b[i], a[i]) then return false end
    end
    -- compare hash part
    for k, v in pairs(a) do
      if not equal(v, b[k]) then return false end
    end
    for k, v in pairs(b) do
      if not equal(v, a[k]) then return false end
    end
    return true
  else
    return false
  end
end
A.equal = equal

A.is_nil = function(a)
  return equal(a, nil)
end

A.is_number = function(a)
  return equal(type(a), 'number')
end

A.is_boolean = function(a)
  return equal(type(a), 'boolean')
end

A.is_string = function(a)
  return equal(type(a), 'string')
end

A.is_table = function(a)
  return equal(type(a), 'table')
end

A.is_array = function(a)
  if not A.is_table(a) then return false end
  local i = 1
  for k, v in pairs(a) do
    if k ~= i then return false end
    i = i + 1
  end
  return true
end

A.is_hash = function(a)
  if not A.is_table(a) then return false end
  for i = 1, #a do
    return false
  end
  return true
end

local function is_callable(a)
  -- N.B: anything supporting __call
  -- N.B: __call should itself be callable :)
  return type(a) == 'function'
      or (a ~= nil and is_callable((debug.getmetatable(a) or {}).__call))
end
A.is_callable = is_callable

A.throws = function(...)
  local status, err = pcall(...)
  return equal(status, false) and A.ok(err), err
end

--
-- define not_* assertions
--

do
  local not_A = { }
  for k, v in pairs(A) do
    not_A['not_' .. k] = function (...)
      return not v(...)
    end
  end
  for k, v in pairs(not_A) do
    A[k] = v
  end
end

--
-- self tests
--

assert(A.ok(1))
assert(A.not_ok(false))
assert(A.is_nil(nil))
assert(A.throws(foo))
assert(not A.ok(A.is_nil(1)))

--
-- runner
--

local colors = {
  red = 31,
  green = 32
}

local checkmarks = {
  ok = '✔',
  ng = '✖'
}

local color_str = function (type, str)
  return "\27[" .. colors[type] .. "m" .. str .. "\27[0m"
end

local function run(tests, callback)

  -- sanity
  tests = tests or { }

  local exit_code = 0
  local test_cnt = 0
  local assert_cnt = 0
  local err_cnt = 0
  local total_elapsed_time = 0;

  -- count tests
  local ntests = 0
  for name, test in pairs(tests) do
    ntests = ntests + 1
  end
  test_cnt = ntests
  if callback and ntests == 0 then
    callback(exit_code, test_cnt, assert_cnt, err_cnt, total_elapsed_time)
    return
  end

  -- iterate over tests
  for name, test in pairs(tests) do
    -- compose testing context
    local tried = 0
    local errors = { }
    local started_at = now()
    local ctx = {
      -- raw assertions, returning booleans
      h = A,
      -- must be called at the end of test
      done = function ()
        local elapsed_time = now() - started_at
        -- print test summary
        local colored_checkmark = options.use_color and color_str('green', checkmarks.ok) or checkmarks.ok
        if #errors > 0 then
          colored_checkmark = options.use_color and color_str('red', checkmarks.ng) or checkmarks.ng
          err_cnt = err_cnt + #errors
          exit_code = 1
        end
        assert_cnt = assert_cnt + tried + #errors
        total_elapsed_time = total_elapsed_time + elapsed_time
        local str = ('%s %s: %d/%d within %.3f ms')
          :format(colored_checkmark, name, tried, tried + #errors, elapsed_time)
        print(str)
        for i = 1, #errors do
          print(errors[i])
        end
        -- report status
        ntests = ntests - 1
        if callback and ntests == 0 then
          callback(exit_code, test_cnt, assert_cnt, err_cnt, total_elapsed_time)
        end
      end,
    }
    -- delegate assertions
    for k, v in pairs(A) do
      ctx[k] = function (...)
        if v(...) then
          tried = tried + 1
        else
          p(...)
          local info = debug.getinfo(2)
          table.insert(
              errors,
              ('%s:%s: assertion "%s" failed')
                :format(info.short_src, info.currentline, k)
            )
        end
      end
    end
    -- run test
    local status, error = pcall(test, ctx)
    if not status then
      errors[#errors + 1] = error
      ctx.done()
    end
  end

end

--
-- given name of test files, run their exports as test suite
--

local exit_code = 0
local test_cnt = 0
local assert_cnt = 0
local err_cnt = 0
local elapsed_time = 0

local function run_suite(files, callback)
  local file = table.remove(files)
  if file == nil then
    callback(exit_code, test_cnt, assert_cnt, err_cnt, elapsed_time)
  end
  local status, tests = pcall(loadfile, file)
  --print('S?', file, status, tests)
  if status then
    run(tests(), function (err, ntests, nasserts, nerr, time)
      test_cnt = test_cnt + ntests
      assert_cnt = assert_cnt + nasserts
      err_cnt = err_cnt + nerr
      elapsed_time = elapsed_time + time
      --print('S!', err)
      if err ~= 0 then exit_code = err end
      run_suite(files, callback)
    end)
  else
    --debug(tests())
    run_suite(files, callback)
  end
end

--
-- command line arguments should be require()able modules
-- exporting tables of functions
--

local files = {...}
run_suite(files, function (status, ntests, nasserts, nerr, time)
  print(('Tests: %d, Asserts: %d, Errors: %d, Time: %.3f ms') :format(ntests, nasserts, nerr, time))
  os.exit(status)
end)
