--[[
LuCI - Lua Configuration Interface
Copyright 2019 lisaac <https://github.com/lisaac/luci-lib-docker>
]]--

require "nixio.util"
require "luci.util"
local jsonc = require "luci.jsonc"
local nixio = require "nixio"
local ltn12 = require "luci.ltn12"
local fs = require "nixio.fs"

local urlencode = luci.util.urlencode or luci.http and luci.http.protocol and luci.http.protocol.urlencode
local json_stringify = jsonc.stringify
local json_parse = jsonc.parse

local chunksource = function(sock, buffer)
	buffer = buffer or ""

	return function()
		local output
		local _, endp, count = buffer:find("^([0-9a-fA-F]+)\r\n")

		while not count do
			local newblock, code = sock:recv(1024)
			if not newblock then
				return nil, code
			end
			buffer = buffer .. newblock
			_, endp, count = buffer:find("^([0-9a-fA-F]+)\r\n")
		end

		count = tonumber(count, 16)
		if not count then
			return nil, -1, "invalid encoding"
		elseif count == 0 then -- finial
			return nil
		elseif count <= #buffer - endp then -- data >= count
			output = buffer:sub(endp + 1, endp + count)
			if count == #buffer - endp then -- [data]
				buffer = buffer:sub(endp + count + 1)
				count, code = sock:recvall(2) --read \r\n
				if not count then
					return nil, code
				end
			elseif count + 1 == #buffer - endp then  -- [data]\r
				buffer = buffer:sub(endp + count + 2)
				count, code = sock:recvall(1) --read \n
				if not count then
					return nil, code
				end
			else -- [data]\r\n[count]\r\n[data]...
				buffer = buffer:sub(endp + count + 3) -- cut buffer
			end
			return output
		else -- data < count
			output = buffer:sub(endp + 1, endp + count)
			buffer = buffer:sub(endp + count + 1)
			local remain, code = sock:recvall(count - #output) --need read remaining
			if not remain then
				return nil, code
			end
			output = output .. remain
			count, code = sock:recvall(2) --read \r\n
			if not count then
				return nil, code
			end
			return output
		end
	end
end

local chunksink = function (sock)
	return function(chunk, err)
		if not chunk then
			return sock:writeall("0\r\n\r\n")
		else
			return sock:writeall(("%X\r\n%s\r\n"):format(#chunk, tostring(chunk)))
		end
	end
end

local docker_stream_filter = function(buffer)
	buffer = buffer or ""
	if #buffer < 8 then
		return ""
	end
	local stream_type = ((string.byte(buffer, 1) == 1) and "stdout") or ((string.byte(buffer, 1) == 2) and "stderr") or ((string.byte(buffer, 1) == 0) and "stdin") or "stream_err"
	local valid_length = tonumber(string.byte(buffer, 5)) * 256 * 256 * 256 + tonumber(string.byte(buffer, 6)) * 256 * 256 + tonumber(string.byte(buffer, 7)) * 256 + tonumber(string.byte(buffer, 8))
	if valid_length > #buffer + 8 then
		return ""
	end
	return stream_type .. ": " .. string.sub(buffer, 9, valid_length + 8)
end

local open_socket = function(req_options)
	local socket
	if type(req_options) ~= "table" then
		return socket
	end
	if req_options.socket_path then
		socket = nixio.socket("unix", "stream")
		if socket:connect(req_options.socket_path) ~= true then
			return nil
		end
	elseif req_options.host and req_options.port then
		socket = nixio.connect(req_options.host, req_options.port)
	end
	if socket then
		return socket
	else
		return nil
	end
end

local send_http_socket = function(options, docker_socket, req_header, req_body, callback)
	if docker_socket:send(req_header) == 0 then
		return {
			headers={
				code=498,
				message="bad path",
				protocol="HTTP/1.1"
			},
			body={
				message="can\'t send data to socket"
			}
		}
	end

	if req_body and type(req_body) == "function" and req_header and req_header:match("chunked") then
		-- chunked send
		req_body(chunksink(docker_socket))
	elseif req_body and type(req_body) == "function" then
		-- normal send by req_body function
		req_body(docker_socket)
	elseif req_body and type(req_body) == "table" then
		-- json
		docker_socket:send(json_stringify(req_body))
		if options.debug then
			io.popen("echo '".. json_stringify(req_body) .. "' >> " .. options.debug_path)
		end
	elseif req_body then
		docker_socket:send(req_body)
		if options.debug then
			io.popen("echo '".. req_body .. "' >> " .. options.debug_path)
		end
	end

	local linesrc = docker_socket:linesource()
	-- read socket using source http://w3.impa.br/~diego/software/luasocket/ltn12.html
	-- http://lua-users.org/wiki/FiltersSourcesAndSinks
	-- handle response header
	local line = linesrc()
	if not line then
		docker_socket:close()
		return {
			headers = {
				code=499,
				message="bad socket path",
				protocol="HTTP/1.1"
			},
			body = {
				message="no data receive from socket"
			}
		}
	end

	local response = {
		code = 0,
		headers = {},
		body = {}
	}

	local p, code, msg = line:match("^([%w./]+) ([0-9]+) (.*)")
	response.protocol = p
	response.code = tonumber(code)
	response.message = msg
	line = linesrc()

	while line and line ~= "" do
		local key, val = line:match("^([%w-]+)%s?:%s?(.*)")
		if key and key ~= "Status" then
			if type(response.headers[key]) == "string" then
				response.headers[key] = {
					response.headers[key],
					val
				}
			elseif type(response.headers[key]) == "table" then
				response.headers[key][#response.headers[key] + 1] = val
			else
				response.headers[key] = val
			end
		end
		line = linesrc()
	end

	-- handle response body
	local body_buffer = linesrc(true)
	response.body = {}

	if type(callback) ~= "function" then
		if response.headers["Transfer-Encoding"] == "chunked" then
			local source = chunksource(docker_socket, body_buffer)
			code = ltn12.pump.all(source, (ltn12.sink.table(response.body))) and response.code or 555
			response.code = code
		else
			local body_source = ltn12.source.cat(ltn12.source.string(body_buffer), docker_socket:blocksource())
			code = ltn12.pump.all(body_source, (ltn12.sink.table(response.body))) and response.code or 555
			response.code = code
		end
	else
		if response.headers["Transfer-Encoding"] == "chunked" then
			local source = chunksource(docker_socket, body_buffer)
			callback(response, source)
		else
			local body_source = ltn12.source.cat(ltn12.source.string(body_buffer), docker_socket:blocksource())
			callback(response, body_source)
		end
	end
	docker_socket:close()
	return response
end

local gen_header = function(options, http_method, api_group, api_action, name_or_id, request)
	local header, query, path
	name_or_id = (name_or_id ~= "") and name_or_id or nil

	if request and type(request.query) == "table" then
		local k, v
		for k, v in pairs(request.query) do
			if type(v) == "table" then
				query = (query and query .. "&" or "?") .. k .. "=" .. urlencode(json_stringify(v))
			elseif type(v) == "boolean" then
				query = (query and query .. "&" or "?") .. k .. "=" .. (v and "true" or "false")
			elseif type(v) == "number" or type(v) == "string" then
				query = (query and query .. "&" or "?") .. k .. "=" .. v
			end
		end
	end

	path = (api_group and ("/" .. api_group) or "") .. (name_or_id and ("/" .. name_or_id) or "") .. (api_action and ("/" .. api_action) or "") .. (query or "")
	header = (http_method or "GET") .. " " .. path .. " " .. options.protocol .. "\r\n"
	header = header .. "Host: " .. options.host .. "\r\n"
	header = header .. "User-Agent: " .. options.user_agent .. "\r\n"
	header = header .. "Connection: close\r\n"

	if request and type(request.header) == "table" then
		local k, v
		for k, v in pairs(request.header) do
			header = header .. k .. ": " .. v .. "\r\n"
		end
	end

	-- when requst_body is function, we need to custom header using custom header
	if request and request.body and type(request.body) == "function" then
		if not header:match("Content-Length:") then
			header = header .. "Transfer-Encoding: chunked\r\n"
		end
	elseif http_method == "POST" and request and request.body and type(request.body) == "table" then
		local conetnt_json = json_stringify(request.body)
		header = header .. "Content-Type: application/json\r\n"
		header = header .. "Content-Length: " .. #conetnt_json .. "\r\n"
	elseif request and request.body and type(request.body) == "string" then
		header = header .. "Content-Length: " .. #request.body .. "\r\n"
	end

	header = header .. "\r\n"
	if options.debug then
		io.popen("echo '".. header .. "' >> " .. options.debug_path)
	end

	return header
end

local call_docker = function(options, http_method, api_group, api_action, name_or_id, request, callback)
	local req_options = setmetatable({}, {
		__index = options
	})

	local req_header = gen_header(req_options,
		http_method,
		api_group,
		api_action,
		name_or_id,
		request)

	local req_body = request and request.body or nil
	local docker_socket = open_socket(req_options)

	if docker_socket then
		return send_http_socket(options, docker_socket, req_header, req_body, callback)
	else
		return {
			headers = {
				code=497,
				message="bad socket path or host",
				protocol="HTTP/1.1"
			},
			body = {
				message="can\'t connect to socket"
			}
		}
	end
end

local gen_api = function(_table, http_method, api_group, api_action)
	local _api_action

	if api_action == "get_archive" or api_action == "put_archive" then
		_api_action = "archive"
	elseif api_action == "df" then
		_api_action = "system/df"
	elseif api_action ~= "list" and api_action ~= "inspect" and api_action ~= "remove" then
		_api_action = api_action
	elseif (api_group == "containers" or api_group == "images" or api_group == "exec") and (api_action == "list" or api_action == "inspect") then
		_api_action = "json"
	end

	local fp = function(self, request, callback)
		local name_or_id = request and (request.name or request.id or request.name_or_id) or nil

		if api_action == "list" then
			if (name_or_id ~= "" and name_or_id ~= nil) then
				if api_group == "images" then
					name_or_id = nil
				else
					request.query = request and request.query or {}
					request.query.filters = request.query.filters or {}
					request.query.filters.name = request.query.filters.name or {}
					request.query.filters.name[#request.query.filters.name + 1] = name_or_id
					name_or_id = nil
				end
			end
		elseif api_action == "create" then
			if (name_or_id ~= "" and name_or_id ~= nil) then
				request.query = request and request.query or {}
				request.query.name = request.query.name or name_or_id
				name_or_id = nil
			end
		elseif api_action == "logs" then
			local body_buffer = ""
			local response = call_docker(self.options,
				http_method,
				api_group,
				_api_action,
				name_or_id,
				request,
				callback)
			if response.code >= 200 and response.code < 300 then
				for i, v in ipairs(response.body) do
					body_buffer = body_buffer .. docker_stream_filter(response.body[i])
				end
				response.body = body_buffer
			end
			return response
		end

		local response = call_docker(self.options, http_method, api_group, _api_action, name_or_id, request, callback)

		if response.headers and response.headers["Content-Type"] == "application/json" then
			if #response.body == 1 then
				response.body = json_parse(response.body[1])
			else
				local tmp = {}
				for _, v in ipairs(response.body) do
					tmp[#tmp+1] = json_parse(v)
				end
				response.body = tmp
			end
		end
		return response
	end

	if api_group then
		_table[api_group][api_action] = fp
	else
		_table[api_action] = fp
	end
end

local _docker = {
	containers = {},
	exec = {},
	images = {},
	networks = {},
	volumes = {}
}

gen_api(_docker, "GET", "containers", "list")
gen_api(_docker, "POST", "containers", "create")
gen_api(_docker, "GET", "containers", "inspect")
gen_api(_docker, "GET", "containers", "top")
gen_api(_docker, "GET", "containers", "logs")
gen_api(_docker, "GET", "containers", "changes")
gen_api(_docker, "GET", "containers", "stats")
gen_api(_docker, "POST", "containers", "resize")
gen_api(_docker, "POST", "containers", "start")
gen_api(_docker, "POST", "containers", "stop")
gen_api(_docker, "POST", "containers", "restart")
gen_api(_docker, "POST", "containers", "kill")
gen_api(_docker, "POST", "containers", "update")
gen_api(_docker, "POST", "containers", "rename")
gen_api(_docker, "POST", "containers", "pause")
gen_api(_docker, "POST", "containers", "unpause")
gen_api(_docker, "POST", "containers", "update")
gen_api(_docker, "DELETE", "containers", "remove")
gen_api(_docker, "POST", "containers", "prune")
gen_api(_docker, "POST", "containers", "exec")
gen_api(_docker, "POST", "exec", "start")
gen_api(_docker, "POST", "exec", "resize")
gen_api(_docker, "GET", "exec", "inspect")
gen_api(_docker, "GET", "containers", "get_archive")
gen_api(_docker, "PUT", "containers", "put_archive")
gen_api(_docker, "GET", "containers", "export")
-- TODO: attch

gen_api(_docker, "GET", "images", "list")
gen_api(_docker, "POST", "images", "create")
gen_api(_docker, "GET", "images", "inspect")
gen_api(_docker, "GET", "images", "history")
gen_api(_docker, "POST", "images", "tag")
gen_api(_docker, "DELETE", "images", "remove")
gen_api(_docker, "GET", "images", "search")
gen_api(_docker, "POST", "images", "prune")
gen_api(_docker, "GET", "images", "get")
gen_api(_docker, "POST", "images", "load")

gen_api(_docker, "GET", "networks", "list")
gen_api(_docker, "GET", "networks", "inspect")
gen_api(_docker, "DELETE", "networks", "remove")
gen_api(_docker, "POST", "networks", "create")
gen_api(_docker, "POST", "networks", "connect")
gen_api(_docker, "POST", "networks", "disconnect")
gen_api(_docker, "POST", "networks", "prune")

gen_api(_docker, "GET", "volumes", "list")
gen_api(_docker, "GET", "volumes", "inspect")
gen_api(_docker, "DELETE", "volumes", "remove")
gen_api(_docker, "POST", "volumes", "create")

gen_api(_docker, "GET", nil, "events")
gen_api(_docker, "GET", nil, "version")
gen_api(_docker, "GET", nil, "info")
gen_api(_docker, "GET", nil, "_ping")
gen_api(_docker, "GET", nil, "df")

function _docker.new(options)
	local docker = {}
	local _options = options or {}

	docker.options = {
		socket_path = _options.socket_path or nil,
		host = _options.socket_path and "localhost" or _options.host,
		port = not _options.socket_path and _options.port or nil,
		tls = _options.tls or nil,
		tls_cacert = _options.tls and _options.tls_cacert or nil,
		tls_cert = _options.tls and _options.tls_cert or nil,
		tls_key = _options.tls and _options.tls_key or nil,
		version = _options.version or "v1.40",
		user_agent = _options.user_agent or "LuCI",
		protocol = _options.protocol or "HTTP/1.1",
		debug = _options.debug or false,
		debug_path = _options.debug and _options.debug_path or nil
	}

	setmetatable(
		docker,
		{
		__index = function(t, key)
			if _docker[key] ~= nil then
				return _docker[key]
			else
				return _docker.containers[key]
			end
		end
		}
	)

	setmetatable(
		docker.containers,
		{
		__index = function(t, key)
			if key == "options" then
				return docker.options
			end
		end
		}
	)

	setmetatable(
		docker.networks,
		{
		__index = function(t, key)
			if key == "options" then
				return docker.options
			end
		end
		}
	)

	setmetatable(
		docker.images,
		{
		__index = function(t, key)
			if key == "options" then
				return docker.options
			end
		end
		}
	)

	setmetatable(
		docker.volumes,
		{
		__index = function(t, key)
			if key == "options" then
				return docker.options
			end
		end
		}
	)

	setmetatable(
		docker.exec,
		{
		__index = function(t, key)
			if key == "options" then
				return docker.options
			end
		end
		}
	)

	return docker
end

return _docker
