-- SPDX-License-Identifier: GPL-3.0-or-later

-- Load dependent modules
if not stats then modules.load('stats') end
if not bogus_log then modules.load('bogus_log') end

local ffi = require('ffi')
local cqueues = require('cqueues')
cqueues.socket = require('cqueues.socket')
assert(cqueues.VERSION >= 20150112)  -- fdopen changed semantics

-- This is a module that does the heavy lifting to provide an HTTP/2 enabled
-- server that supports TLS by default and provides endpoint for other modules
-- in order to enable them to export restful APIs and websocket streams.
-- One example is statistics module that can stream live metrics on the website,
-- or publish metrics on request for Prometheus scraper.
local http_server = require('http.server')
local http_headers = require('http.headers')
local http_websocket = require('http.websocket')
local http_util = require "http.util"
local has_mmdb, mmdb  = pcall(require, 'mmdb')

-- A sub-module for certificate management.
local tls_cert = require('kres_modules.http_tls_cert')

-- Module declaration
local M = {
	servers = {},
	configs = { _builtin = {} }  -- configuration templates
}

-- inherited by all configurations
M.configs._builtin._all = {
	cq = worker.bg_worker.cq,
	cert = 'self.crt',
	key = 'self.key',
	ephemeral = true,
	client_timeout = 5
}
-- log errors but do not throw
M.configs._builtin._all.onerror = function(myserver, context, op, err, errno) -- luacheck: ignore 212
	local msg = op .. ' on ' .. tostring(context) .. ' failed'
	if err then
		msg = msg .. ': ' .. tostring(err)
	end
	log_info(ffi.C.LOG_GRP_HTTP, msg)
end

-- M.config() without explicit "kind" modifies this
M.configs._all = {}

-- DoH
M.configs._builtin.doh_legacy = {}

-- management endpoint
M.configs._builtin.webmgmt = {}

-- Map extensions to MIME type
local mime_types = {
	js = 'application/javascript',
	css = 'text/css',
	tpl = 'text/html',
	ico = 'image/x-icon'
}

-- Preload static contents, nothing on runtime will touch the disk
local function pgload(relpath, modname)
	if not modname then modname = 'http' end
	local fp, err = io.open(string.format(
        '@modules_dir@/%s/%s', modname, relpath), 'r')
	if not fp then
		fp, err = io.open(string.format(
            '@modules_dir@/%s/static/%s', modname, relpath), 'r')
	end
	if not fp then error(err) end
	local data = fp:read('*all')
	fp:close()
	-- Guess content type
	local ext = relpath:match('[^\\.]+$')
	return {mime_types[ext] or 'text', data, nil, 86400}
end
M.page = pgload

-- Preloaded static assets
local pages = {
	'favicon.ico',
	'kresd.js',
	'kresd.css',
	'jquery.js',
	'd3.js',
	'topojson.js',
	'datamaps.world.min.js',
	'dygraph.min.js',
	'selectize.min.js',
	'selectize.bootstrap3.css',
	'bootstrap.min.js',
	'bootstrap.min.css',
	'bootstrap-theme.min.css',
	'glyphicons-halflings-regular.woff2',
}

-- Serve preloaded root page
local function serve_root()
	local data = pgload('main.tpl')[2]
	data = data
	        :gsub('{{ title }}', M.title or ('kresd @ ' .. hostname()))
	        :gsub('{{ host }}', hostname())
	return function (_, _)
		-- Render snippets
		local rsnippets = {}
		for _,v in pairs(M.snippets) do
			local sid = string.lower(string.gsub(v[1], ' ', '-'))
			table.insert(rsnippets, string.format('<section id="%s"><h2>%s</h2>\n%s</section>', sid, v[1], v[2]))
		end
		-- Return index page
		return data
		        :gsub('{{ snippets }}', table.concat(rsnippets, '\n'))
	end
end

-- Export HTTP service endpoints
M.configs._builtin.doh_legacy.endpoints = {}
M.configs._builtin.webmgmt.endpoints = {}
local mgmt_endpoints = M.configs._builtin.webmgmt.endpoints

mgmt_endpoints['/'] = {'text/html', serve_root()}

-- Export static pages
for _, pg in ipairs(pages) do
	mgmt_endpoints['/'..pg] = pgload(pg)
end

-- Export built-in prometheus interface
local prometheus = require('kres_modules.prometheus')
for k, v in pairs(prometheus.endpoints) do
	mgmt_endpoints[k] = v
end
M.prometheus = prometheus

-- Export built-in trace interface
local http_trace = require('kres_modules.http_trace')
for k, v in pairs(http_trace.endpoints) do
	mgmt_endpoints[k] = v
end
M.trace = http_trace

M.configs._builtin.doh_legacy.endpoints = {}
local http_doh = require('kres_modules.http_doh')
for k, v in pairs(http_doh.endpoints) do
	mgmt_endpoints[k] = v
	M.configs._builtin.doh_legacy.endpoints[k] = v
end
M.doh = http_doh

-- Export HTTP service page snippets
M.snippets = {}

-- Serve known requests, for methods other than GET
-- the endpoint must be a closure and not a preloaded string
local function serve(endpoints, h, stream)
	local hsend = http_headers.new()
	local path = h:get(':path')
	local entry = endpoints[path]
	if not entry then -- Accept top-level path match
		entry = endpoints[path:match '^/[^/?]*']
	end
	-- Unpack MIME and data
	local data, mime, ttl, any_origin, err
	if entry then
		mime = entry[1]
		data = entry[2]
		ttl = entry[4]
		any_origin = entry[5]
	end
	-- Get string data out of service endpoint
	if type(data) == 'function' then
		local set_mime, set_ttl
		data, err, set_mime, set_ttl = data(h, stream)
		-- Override default endpoint mime/TTL
		if set_mime then mime = set_mime end
		if set_ttl then ttl = set_ttl end
		-- Handler doesn't provide any data
		if data == false then return end
		if type(data) == 'number' then return tostring(data), err end
	-- Methods other than GET require handler to be closure
	elseif h:get(':method') ~= 'GET' then
		return '501', ''
	end
	if type(data) == 'table' then data = tojson(data) end
	if not mime or type(data) ~= 'string' then
		return '404', ''
	else
		-- Serve content type appropriately
		hsend:append(':status', '200')
		hsend:append('content-type', mime)
		hsend:append('content-length', tostring(#data))
		if ttl then
			hsend:append('cache-control', string.format('max-age=%d', ttl))
		end
		if any_origin then
			hsend:append('access-control-allow-origin', '*')
		end
		assert(stream:write_headers(hsend, false))
		assert(stream:write_chunk(data, true))
	end
end

-- Web server service closure
local function route(endpoints)
	assert(type(endpoints) == 'table', 'endpoints are not a table, is it a botched template?')
	return function (_, stream)
		-- HTTP/2: We're only permitted to send in open/half-closed (remote)
		local connection = stream.connection
		if connection.version >= 2 then
			if stream.state ~= 'open' and stream.state ~= 'half closed (remote)' then
				return
			end
		end
		-- Start reading headers
		local h = assert(stream:get_headers())
		local m = h:get(':method')
		local path = h:get(':path')

		-- Upgrade connection to WebSocket
		local ws = http_websocket.new_from_stream(stream, h)
		if ws then
			log_info(ffi.C.LOG_GRP_HTTP, '%s %s HTTP/%s web socket open',
				m, path, tostring(connection.version))
			assert(ws:accept { protocols = {'json'} })
			-- Continue streaming results to client
			local ep = endpoints[path]
			local cb = ep[3]
			if cb then
				cb(h, ws)
			end
			ws:close()
			log_info(ffi.C.LOG_GRP_HTTP, '%s %s HTTP/%s web socket closed',
				m, path, tostring(connection.version))
			return
		else
			local ok, err, reason = http_util.yieldable_pcall(serve, endpoints, h, stream)
			if not ok or err then
				err = err or '500'
				log_info(ffi.C.LOG_GRP_HTTP, '%s %s HTTP/%s %s %s',
					m, path, tostring(connection.version), err, reason or '')
				-- Method is not supported
				local hsend = http_headers.new()
				hsend:append(':status', err)
				if reason then
					assert(stream:write_headers(hsend, false))
					assert(stream:write_chunk(reason, true))
				else
					assert(stream:write_headers(hsend, true))
				end
			else
				log_info(ffi.C.LOG_GRP_HTTP, '%s %s HTTP/%s 200',
					m, path, tostring(connection.version))
			end

		end
	end
end

-- @function Merge dictionaries, nil is like empty dict.
-- Values from right-hand side dictionaries take precedence.
local function mergeconf(...)
	local merged = {}
	local ntables = select('#', ...)
	local tables = {...}
	for i = 1, ntables do
		local intable = tables[i]
		if intable ~= nil then
			assert(type(intable) == 'table', 'cannot merge non-tables')
			for key, val in pairs(intable) do
				merged[key] = val
			end
		end
	end
	return merged
end

-- @function Listen on given socket
-- using configuration for specific "kind" of HTTP server
local function add_socket(fd, kind, addr_str)
	assert(M.servers[fd] == nil, 'socket is already served by an HTTP instance')
	local conf = mergeconf(M.configs._builtin._all, M.configs._builtin[kind],
						M.configs._all, M.configs[kind])
	conf.socket = cqueues.socket.fdopen({ fd = fd, reuseport = true, reuseaddr = true })
	if conf.tls ~= false then -- Create a TLS context, either from files or new.
		if conf.ephemeral then
			if not M.ephem_state then
				M.ephem_state = { servers = M.servers }
				tls_cert.ephemeral_state_maintain(M.ephem_state, conf.cert, conf.key)
			end
			conf.ctx = M.ephem_state.ctx
		else
			local certs, key = tls_cert.load(conf.cert, conf.key)
			conf.ctx = tls_cert.new_tls_context(certs, key)
		end
		assert(conf.ctx)
	end
	-- Compose server handler
	local routes = route(conf.endpoints)
	conf.onstream = routes
	-- Create TLS context and start listening
	local s, err = http_server.new(conf)
	-- Manually call :listen() so that we are bound before calling :localname()
	if s then
		err = select(2, s:listen())
	end
	if err then
		panic('failed to listen on %s: %s', addr_str, err)
	end
	M.servers[fd] = {kind = kind, server = s, config = conf}
end

-- @function Stop listening on given socket
local function remove_socket(fd)
	local instance = M.servers[fd]
	assert(instance, 'HTTP module is not listening on given socket')

	instance.server:close()
	M.servers[fd] = nil
end

-- @function Listen for config changes from net.listen()/net.close()
local function cb_socket(...)
	local added, endpoint, addr_str = unpack({...})
	endpoint = ffi.cast('struct endpoint **', endpoint)[0]
	local kind = ffi.string(endpoint.flags.kind)
	local socket = endpoint.fd
	if added then
		return add_socket(socket, kind, addr_str)
	else
		return remove_socket(socket)
	end
end

-- @function Init module
function M.init()
	net.register_endpoint_kind('doh_legacy', cb_socket)
	net.register_endpoint_kind('webmgmt', cb_socket)
end

-- @function Cleanup module
function M.deinit()
	for fd, _ in pairs(M.servers) do
		remove_socket(fd)
	end
	tls_cert.ephemeral_state_destroy(M.ephem_state)
	net.register_endpoint_kind('doh_legacy')
	net.register_endpoint_kind('webmgmt')
end

-- @function Configure module, i.e. store new configuration template
-- kind = socket type (doh_legacy/webmgmt)
function M.config(conf, kind)
	if conf == nil and kind == nil then
		-- default module config, nothing to do
		return
	end

	kind = kind or '_all'
	assert(type(kind) == 'string')

	local operation
	-- builtins cannot be removed or added
	if M.configs._builtin[kind] then
		operation = 'modify'
		conf = conf or {}
	elseif M.configs[kind] then  -- config on an existing user template
		if conf then	operation = 'modify'
		else		operation = 'delete' end
	else  -- config for not-yet-existing template
		if conf then	operation = 'add'
		else		panic('[http] endpoint kind "%s" does not exist, '
					.. 'nothing to delete', kind) end
	end

	if operation == 'modify' or operation == 'add' then
		assert(type(conf) == 'table', 'config { cert = "...", key = "..." }')

		if conf.cert then
			conf.ephemeral = false
			if not conf.key then
				panic('[http] certificate provided, but missing key')
			end
			-- test if it can be loaded or not
			tls_cert.load(conf.cert, conf.key)
		end
		if conf.geoip then
			if has_mmdb then
				M.geoip = mmdb.open(conf.geoip)
			else
				error('[http] mmdblua library not found, please remove GeoIP configuration')
			end
		end
	end

	for _, instance in pairs(M.servers) do
		-- modification cannot be implemented as
		-- remove_socket + add_socket because remove closes the socket
		if instance.kind == kind or kind == '_all' then
			panic('unable to modify configuration for '
				.. 'endpoint kind "%s" because it is in '
				.. 'use, use net.close() first', kind)
		end
	end

	if operation == 'add' then
		net.register_endpoint_kind(kind, cb_socket)
	elseif operation == 'delete' then
		net.register_endpoint_kind(kind)
	end
	M.configs[kind] = conf
end

return M
