--[[
Curl Wrapper

all curl methods accepts

  url          = "The url to make the request to.", (string)
  query        = "url query, append after the url", (table)
  body         = "The request body" (string/filepath/table)
  auth         = "Basic request auth, 'user:pass', or {"user", "pass"}" (string/array)
  form         = "request form" (table)
  raw          = "any additonal curl args, it must be an array/list." (array)
  dry_run      = "whether to return the args to be ran through curl." (boolean)
  output       = "where to download something." (filepath)
  timeout      = "request timeout in mseconds" (number)
  http_version = "HTTP version to use: 'HTTP/0.9', 'HTTP/1.0', 'HTTP/1.1', 'HTTP/2', or 'HTTP/3'" (string)
  proxy        = "[protocol://]host[:port] Use this proxy" (string)
  insecure     = "Allow insecure server connections" (boolean)

and returns table:

  exit    = "The shell process exit code." (number)
  status  = "The https response status." (number)
  headers = "The https response headers." (array)
  body    = "The http response body." (string)

see test/plenary/curl_spec.lua for examples.

author = github.com/tami5
]]
--

local util, parse = {}, {}

-- Helpers --------------------------------------------------
-------------------------------------------------------------
local F = require("plenary.functional")
local J = require("plenary.job")
local P = require("plenary.path")
local compat = require("plenary.compat")

-- Utils ----------------------------------------------------
-------------------------------------------------------------

util.url_encode = function(str)
	if type(str) ~= "number" then
		str = str:gsub("\r?\n", "\r\n")
		str = str:gsub("([^%w%-%.%_%~ ])", function(c)
			return string.format("%%%02X", c:byte())
		end)
		str = str:gsub(" ", "+")
		return str
	else
		return str
	end
end

util.kv_to_list = function(kv, prefix, sep)
	return compat.flatten(F.kv_map(function(kvp)
		return { prefix, kvp[1] .. sep .. kvp[2] }
	end, kv))
end

util.kv_to_str = function(kv, sep, kvsep)
	return F.join(
		F.kv_map(function(kvp)
			return kvp[1] .. kvsep .. util.url_encode(kvp[2])
		end, kv),
		sep
	)
end

util.gen_dump_path = function()
	local path
	local id = string.gsub("xxxx4xxx", "[xy]", function(l)
		local v = (l == "x") and math.random(0, 0xf) or math.random(0, 0xb)
		return string.format("%x", v)
	end)
	if P.path.sep == "\\" then
		path = string.format("%s\\AppData\\Local\\Temp\\plenary_curl_%s.headers", os.getenv("USERPROFILE"), id)
	else
		local temp_dir = os.getenv("XDG_RUNTIME_DIR") or "/tmp"
		path = temp_dir .. "/plenary_curl_" .. id .. ".headers"
	end
	return { "-D", path }
end

-- Parsers ----------------------------------------------------
---------------------------------------------------------------

parse.headers = function(t)
	if not t then
		return
	end
	local upper = function(str)
		return string.gsub(" " .. str, "%W%l", string.upper):sub(2)
	end
	return util.kv_to_list(
		(function()
			local normilzed = {}
			for k, v in pairs(t) do
				normilzed[upper(k:gsub("_", "%-"))] = v
			end
			return normilzed
		end)(),
		"-H",
		": "
	)
end

parse.data_body = function(t)
	if not t then
		return
	end
	return util.kv_to_list(t, "-d", "=")
end

parse.raw_body = function(xs)
	if not xs then
		return
	end
	if type(xs) == "table" then
		return parse.data_body(xs)
	else
		return { "--data-raw", xs }
	end
end

parse.form = function(t)
	if not t then
		return
	end
	return util.kv_to_list(t, "-F", "=")
end

parse.curl_query = function(t)
	if not t then
		return
	end
	return util.kv_to_str(t, "&", "=")
end

parse.method = function(s)
	if not s then
		return
	end
	if s ~= "head" then
		return { "-X", string.upper(s) }
	else
		return { "-I" }
	end
end

parse.file = function(p)
	if not p then
		return
	end
	return { "--data-binary", "@" .. P.expand(P.new(p)) }
end

parse.auth = function(xs)
	if not xs then
		return
	end
	return { "-u", type(xs) == "table" and util.kv_to_str(xs, nil, ":") or xs }
end

parse.url = function(xs, q)
	if not xs then
		return
	end
	q = parse.curl_query(q)
	if type(xs) == "string" then
		return q and xs .. "?" .. q or xs
	elseif type(xs) == "table" then
		error("Low level URL definition is not supported.")
	end
end

parse.accept_header = function(s)
	if not s then
		return
	end
	return { "-H", "Accept: " .. s }
end

parse.http_version = function(s)
	if not s then
		return
	end
	if s == "HTTP/0.9" or s == "HTTP/1.0" or s == "HTTP/1.1" or s == "HTTP/2" or s == "HTTP/3" then
		s = s:lower()
		s = s:gsub("/", "")
		return { "--" .. s }
	else
		error("Unknown HTTP version.")
	end
end

-- Parse Request -------------------------------------------
------------------------------------------------------------
parse.request = function(opts)
	if opts.body then
		local b = opts.body
		local silent_is_file = function()
			local status, result = pcall(P.is_file, P.new(b))
			return status and result
		end
		opts.body = nil
		if type(b) == "table" then
			opts.data = b
		elseif silent_is_file() then
			opts.in_file = b
		elseif type(b) == "string" then
			opts.raw_body = b
		end
	end
	local result = { "-sSL", opts.dump }
	local append = function(v)
		if v then
			table.insert(result, v)
		end
	end

	if opts.insecure then
		table.insert(result, "--insecure")
	end
	if opts.proxy then
		table.insert(result, { "--proxy", opts.proxy })
	end
	if opts.compressed then
		table.insert(result, "--compressed")
	end
	append(parse.method(opts.method))
	append(parse.headers(opts.headers))
	append(parse.accept_header(opts.accept))
	append(parse.raw_body(opts.raw_body))
	append(parse.data_body(opts.data))
	append(parse.form(opts.form))
	append(parse.file(opts.in_file))
	append(parse.auth(opts.auth))
	append(parse.http_version(opts.http_version))
	append(opts.raw)
	if opts.output then
		table.insert(result, { "-o", opts.output })
	end
	table.insert(result, parse.url(opts.url, opts.query))
	return compat.flatten(result), opts
end

-- Parse response ------------------------------------------
------------------------------------------------------------
parse.response = function(lines, dump_path, code)
	local headers = P.readlines(dump_path)
	local status = nil
	local processed_headers = {}

	-- Process headers in a single pass
	for _, line in ipairs(headers) do
		local status_match = line:match("^HTTP/%S*%s+(%d+)")
		if status_match then
			status = tonumber(status_match)
		elseif line ~= "" then
			table.insert(processed_headers, line)
		end
	end

	local body = F.join(lines, "\n")
	vim.loop.fs_unlink(dump_path)

	return {
		status = status or 0,
		headers = processed_headers,
		body = body,
		exit = code,
	}
end

local request = function(specs)
	local response = {}
	local args, opts = parse.request(vim.tbl_extend("force", {
		compressed = package.config:sub(1, 1) ~= "\\",
		dry_run = false,
		dump = util.gen_dump_path(),
	}, specs))

	if opts.dry_run then
		return args
	end

	local job_opts = {
		command = vim.g.plenary_curl_bin_path or "curl",
		args = args,
	}

	if opts.stream then
		job_opts.on_stdout = opts.stream
	end

	job_opts.on_exit = function(j, code)
		if code ~= 0 then
			local stderr = vim.inspect(j:stderr_result())
			local message =
				string.format("%s %s - curl error exit_code=%s stderr=%s", opts.method, opts.url, code, stderr)
			if opts.on_error then
				return opts.on_error({
					message = message,
					stderr = stderr,
					exit = code,
				})
			else
				error(message)
			end
		end
		local output = parse.response(j:result(), opts.dump[2], code)
		if opts.callback then
			return opts.callback(output)
		else
			response = output
		end
	end
	local job = J:new(job_opts)

	if opts.callback or opts.stream then
		job:start()
		return job
	else
		local timeout = opts.timeout or 10000
		job:sync(timeout)
		return response
	end
end

-- Main ----------------------------------------------------
------------------------------------------------------------
return (function()
	local partial = function(method)
		return function(url, opts)
			local spec = {}
			opts = opts or {}
			if type(url) == "table" then
				opts = url
				spec.method = method
			else
				spec.url = url
				spec.method = method
			end
			opts = method == "request" and opts or (vim.tbl_extend("keep", opts, spec))
			return request(opts)
		end
	end
	return {
		get = partial("get"),
		post = partial("post"),
		put = partial("put"),
		head = partial("head"),
		patch = partial("patch"),
		delete = partial("delete"),
		request = partial("request"),
	}
end)()
