--
-- Licensed to the Apache Software Foundation (ASF) under one or more
-- contributor license agreements.  See the NOTICE file distributed with
-- this work for additional information regarding copyright ownership.
-- The ASF licenses this file to You under the Apache License, Version 2.0
-- (the "License"); you may not use this file except in compliance with
-- the License.  You may obtain a copy of the License at
--
--     http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--

--- AI provider base class.
-- Provides the request/response lifecycle shared by all AI providers.
-- Uses transport modules (http, sse, auth) for infrastructure concerns.

local _M = {}

local mt = {
    __index = _M
}

local core = require("apisix.core")
local plugin = require("apisix.plugin")
local url  = require("socket.url")
local sse  = require("apisix.plugins.ai-transport.sse")
local aws_eventstream = require("apisix.plugins.ai-transport.aws-eventstream")
local transport_http = require("apisix.plugins.ai-transport.http")
local transport_auth = require("apisix.plugins.ai-transport.auth")
local log_sanitize = require("apisix.utils.log-sanitize")
local protocols = require("apisix.plugins.ai-protocols")
local deep_merge = require("apisix.plugins.ai-proxy.merge").deep_merge
local ngx = ngx
local ngx_now = ngx.now
local require = require
local tonumber = tonumber

local table = table
local pairs = pairs
local type  = type
local math  = math
local ipairs = ipairs
local next = next
local setmetatable = setmetatable
local tostring = tostring

-- Streaming framings selectable via provider.streaming_framing.
-- Each module exposes split_buf(buf) -> (complete, remainder) and
-- decode(buf) -> array of events. The event shape is framing-specific;
-- the protocol's parse_sse_event must understand it.
local FRAMINGS = {
    sse = sse,
    ["aws-eventstream"] = aws_eventstream,
}


function _M.new(opt)
    return setmetatable(opt, mt)
end


-- Merge usage fields into ctx, preserving earlier non-zero values.
-- Anthropic streams send usage across multiple SSE events (message_start has
-- input/cache tokens, message_delta has output tokens), so we merge rather
-- than overwrite to avoid losing fields from earlier events.
local function merge_usage(ctx, parsed)
    if not ctx.ai_token_usage then
        ctx.ai_token_usage = parsed.usage
    else
        for k, v in pairs(parsed.usage) do
            if type(v) == "number" and v > 0 then
                ctx.ai_token_usage[k] = v
            end
        end
    end

    local raw = parsed.raw_usage or parsed.usage
    if not ctx.llm_raw_usage then
        ctx.llm_raw_usage = raw
    else
        for k, v in pairs(raw) do
            if type(v) == "number" and v > 0 then
                ctx.llm_raw_usage[k] = v
            end
        end
    end
end


--- Build HTTP request parameters from driver config and extra_opts.
-- @return table params HTTP parameters ready for transport_http.request()
-- @return string|nil err Error message
function _M.build_request(self, ctx, conf, request_body, opts)
    local body_changed = false

    -- Protocol conversion (when a converter bridges client→target protocol)
    local converter = ctx.ai_converter
    if converter and converter.convert_request then
        local converted, err = converter.convert_request(request_body, ctx)
        if not converted then
            return nil, err or "invalid protocol", 400
        end
        request_body = converted
        body_changed = true
    end

    -- Inject target-protocol-specific parameters (e.g. stream_options for OpenAI).
    -- This runs after conversion so it covers both passthrough and convert scenarios.
    local target_protocol = ctx.ai_target_protocol
    if target_protocol then
        local target_proto = protocols.get(target_protocol)
        if target_proto and target_proto.prepare_outgoing_request then
            if target_proto.prepare_outgoing_request(request_body) then
                body_changed = true
            end
        end
    end

    core.log.info("request extra_opts to LLM server: ",
                  core.json.delay_encode(log_sanitize.redact_extra_opts(opts), true))

    -- Auth: GCP token
    local auth = opts.auth or {}
    local token
    if auth.gcp then
        local access_token, err = transport_auth.fetch_gcp_access_token(ctx, opts.name,
                                        auth.gcp)
        if not access_token then
            return nil, "failed to get gcp access token: " .. (err or "unknown")
        end
        token = access_token
    end

    -- Parse endpoint URL
    local endpoint = opts.endpoint
    local parsed_url
    if endpoint then
        parsed_url = url.parse(endpoint)
    end

    local scheme = parsed_url and parsed_url.scheme or "https"
    local host = parsed_url and parsed_url.host
                 or opts.target_host or self.host
    local port = parsed_url and parsed_url.port
    if not port then
        if scheme == "https" then
            port = 443
        else
            port = 80
        end
    end

    local query_params = auth.query and core.table.clone(auth.query) or {}

    if type(parsed_url) == "table" and parsed_url.query and #parsed_url.query > 0 then
        local args_tab = core.string.decode_args(parsed_url.query)
        if type(args_tab) == "table" then
            core.table.merge(query_params, args_tab)
        end
    end

    local path
    if parsed_url and parsed_url.path then
        path = parsed_url.path
    else
        path = opts.target_path
    end

    if not path then
        -- Provider's path callback returned nil and override.endpoint did not
        -- supply one. For providers whose path depends on the model (bedrock,
        -- vertex-ai), this happens when neither options.model nor body.model
        -- is set.
        return nil, "could not resolve upstream path: ensure the route or "
            .. "request body specifies a model, or that override.endpoint "
            .. "includes a path", 400
    end

    local headers = transport_http.construct_forward_headers(auth.header or {}, ctx)
    if opts.host_header then
        headers["Host"] = opts.host_header
    end
    if token then
        headers["authorization"] = "Bearer " .. token
    end

    -- Protocol converter header transformation (e.g. Anthropic → OpenAI headers)
    if ctx.ai_converter and ctx.ai_converter.convert_headers then
        ctx.ai_converter.convert_headers(headers)
    end

    local params = {
        method = "POST",
        scheme = scheme,
        headers = headers,
        ssl_verify = conf.ssl_verify,
        path = path,
        query = query_params,
        host = host,
        port = port,
        ssl_server_name = opts.ssl_server_name
                          or parsed_url and parsed_url.host
                          or opts.target_host or self.host,
    }

    -- Inject model options (flat overwrite)
    if opts.model_options then
        for opt, val in pairs(opts.model_options) do
            if request_body[opt] ~= nil then
                core.log.info("model_options overwriting request field '", opt, "'")
            end
            request_body[opt] = val
            body_changed = true
        end
    end

    -- Apply llm_options via provider capability hook (always force-overwrites)
    if opts.override_llm_options then
        local cap = self.capabilities and self.capabilities[ctx.ai_target_protocol]
        if cap and cap.rewrite_request_body then
            cap.rewrite_request_body(request_body, opts.override_llm_options, true)
            if next(opts.override_llm_options) ~= nil then
                body_changed = true
            end
        end
    end

    -- Apply per-target-protocol request body override (deep merge)
    if opts.request_body_override_map then
        local patch = opts.request_body_override_map[ctx.ai_target_protocol]
        if patch then
            core.log.info("applying request_body override for target protocol '",
                          ctx.ai_target_protocol, "'")
            request_body = deep_merge(request_body, patch, opts.request_body_force_override)
            body_changed = true
        end
    end

    if self.remove_model and request_body.model ~= nil then
        request_body.model = nil
        body_changed = true
    end

    if body_changed then
        ctx.ai_request_body_changed = true
    end

    if not ctx.ai_request_body_changed then
        if ctx.ai_raw_request_body == nil then
            ctx.ai_raw_request_body = core.request.get_body()
        end
        if type(ctx.ai_raw_request_body) == "string" then
            params.body = ctx.ai_raw_request_body
        else
            params.body = request_body
        end
    else
        params.body = request_body
    end

    -- AWS SigV4 signing (must be last — signs the finalized body)
    if self.aws_sigv4 and auth.aws then
        local auth_aws = require("apisix.plugins.ai-transport.auth-aws")
        local region = opts.conf and opts.conf.region
        if not region then
            return nil, "missing region for AWS SigV4 signing "
                .. "(provider_conf.region required for bedrock)"
        end
        local sign_err = auth_aws.sign_request(params, auth.aws, region)
        if sign_err then
            return nil, "failed to sign AWS request: " .. sign_err
        end
    end

    return params
end


--- Parse a non-streaming response body.
-- Converts the response (if converter exists), then extracts usage/text
-- using the client protocol module.
-- @param client_proto table The protocol module for the client's protocol
-- @param converter table|nil The converter module (if protocol conversion needed)
-- @param conf table|nil Plugin configuration (used for response size limits)
-- @return table|nil Parsed and optionally converted response body
-- @return string|nil Error
function _M.parse_response(self, ctx, res, client_proto, converter, conf)
    local headers = res.headers

    -- Pre-check Content-Length against max_response_bytes when the upstream
    -- advertises it. For responses without Content-Length (chunked), we read
    -- the body in bounded chunks below and enforce the cap incrementally.
    local max_bytes = conf and conf.max_response_bytes
    if max_bytes then
        local content_length = tonumber(headers["Content-Length"])
        if content_length and content_length > max_bytes then
            core.log.warn("aborting AI response: Content-Length ", content_length,
                          " exceeds max_response_bytes ", max_bytes)
            if res._httpc then
                res._httpc:close()
                res._httpc = nil
            end
            return nil, "max_response_bytes exceeded", 502
        end
    end

    local raw_res_body, err
    if max_bytes then
        -- Read in chunks so a runaway chunked upstream cannot force the
        -- worker to buffer arbitrarily many bytes before the cap trips.
        local body_reader = res.body_reader
        if not body_reader then
            -- Defensive: if no reader, fall back to read_body() and accept
            -- that the cap is only post-facto for this path.
            raw_res_body, err = res:read_body()
        else
            local parts = {}
            local total = 0
            while true do
                local chunk, read_err = body_reader()
                if read_err then
                    err = read_err
                    break
                end
                if not chunk then
                    break
                end
                total = total + #chunk
                if total > max_bytes then
                    core.log.warn("aborting AI response: body size exceeds",
                                  " max_response_bytes ", max_bytes,
                                  " (read ", total, " bytes)")
                    if res._httpc then
                        res._httpc:close()
                        res._httpc = nil
                    end
                    res._upstream_bytes = total
                    return nil, "max_response_bytes exceeded", 502
                end
                parts[#parts + 1] = chunk
            end
            if not err then
                raw_res_body = table.concat(parts)
            end
        end
    else
        raw_res_body, err = res:read_body()
    end
    if not raw_res_body then
        core.log.warn("failed to read response body: ", err)
        return nil, err
    end
    res._upstream_bytes = #raw_res_body
    ngx.status = res.status
    ctx.var.llm_time_to_first_token = math.floor((ngx_now() - ctx.llm_request_start_time) * 1000)
    ctx.var.apisix_upstream_response_time = ctx.var.llm_time_to_first_token

    local res_body, decode_err = core.json.decode(raw_res_body, { null_as_nil = true })
    if decode_err then
        core.log.warn("failed to decode response from ai service, err: ", decode_err,
            ", it will cause token usage not available")
        plugin.lua_response_filter(ctx, headers, raw_res_body)
        return nil
    end

    -- Convert response body to client format (converter works downstream of protocol)
    if converter and converter.convert_response then
        local new_body, conv_err = converter.convert_response(res_body, ctx)
        if not new_body and conv_err then
            core.log.error("failed to convert response: ", conv_err)
            return nil, conv_err
        end
        if new_body then
            res_body = new_body
            local raw, encode_err = core.json.encode(res_body)
            if not raw then
                core.log.error("failed to encode response body: ", encode_err)
                return nil, encode_err
            end
            raw_res_body = raw
        end
    end

    -- Extract usage and text using client protocol (works on client format)
    core.log.info("got token usage from ai service: ", core.json.delay_encode(res_body.usage))
    ctx.ai_token_usage = {}
    local usage, raw_usage = client_proto.extract_usage(res_body)
    if usage then
        ctx.llm_raw_usage = raw_usage
        ctx.ai_token_usage = usage
    end
    ctx.var.llm_prompt_tokens = ctx.ai_token_usage.prompt_tokens or 0
    ctx.var.llm_completion_tokens = ctx.ai_token_usage.completion_tokens or 0

    local response_text = client_proto.extract_response_text(res_body)
    if response_text then
        ctx.var.llm_response_text = response_text
    end

    plugin.lua_response_filter(ctx, headers, raw_res_body)
    return res_body
end


--- Process streaming response.
-- Uses target protocol for event parsing and converter (if present) for
-- transforming events to client format. The wire framing (SSE vs AWS
-- EventStream binary) is selected by provider.streaming_framing; the
-- protocol module's parse_sse_event must understand the resulting event
-- shape.
-- @param target_proto table The protocol module for the provider's native protocol
-- @param converter table|nil The converter module (if protocol conversion needed)
-- @param conf table|nil Plugin configuration (used for stream duration and size limits)
function _M.parse_streaming_response(self, ctx, res, target_proto, converter, conf)
    local framing = FRAMINGS[self.streaming_framing or "sse"]
    if not framing then
        return 500, "unknown streaming framing: " .. tostring(self.streaming_framing)
    end
    local body_reader = res.body_reader
    local contents = {}
    local sse_state = { is_first = true }
    -- SSE framing buffer: accumulate chunks with table.insert to avoid
    -- allocating a new string on every append; reset to {remainder} after
    -- each split so the table never grows beyond two elements.
    -- Initialized with "" so the fast-path (sse_parts[1] == "") activates
    -- immediately on the first chunk, avoiding an unnecessary table.concat.
    local sse_parts = {""}
    -- Track whether any output was sent to the client.
    -- When a converter is active but the upstream returns a different SSE format,
    -- all events may be skipped and no output produced, leaving the response
    -- uncommitted and causing nginx to fall through to the balancer phase.
    local output_sent = false

    -- Runaway-upstream safeguards. Both are opt-in; unset means no cap.
    local max_duration_ms = conf and conf.max_stream_duration_ms
    local max_bytes = conf and conf.max_response_bytes
    local deadline
    if max_duration_ms then
        deadline = ctx.llm_request_start_time + max_duration_ms / 1000
    end
    local bytes_read = 0

    -- streaming_flush_interval_ms controls both flush strategy and the thread:
    --   == 0          : no thread; lua_response_filter flushes synchronously
    --                   per chunk via ngx.flush(true), guaranteeing immediate
    --                   client delivery.
    --   >  0 (default: 10): background thread calls ngx.flush(false) every N ms;
    --                   lua_response_filter skips per-chunk flush for maximum
    --                   throughput. Useful when the upstream bursts multiple
    --                   tokens at once.
    local flush_interval_ms = conf and conf.streaming_flush_interval_ms or 0
    -- async_flush: true when the interval thread is responsible for flushing
    local async_flush = flush_interval_ms > 0
    -- needs_flush is set to true immediately after dispatching a chunk so the
    -- thread always flushes exactly the data that has been written.  Cleared
    -- before ngx.flush() so any new chunks written during the flush yield are
    -- picked up on the next interval rather than silently dropped.
    local needs_flush = false
    local flush_thread
    local flush_err
    if async_flush then
        local interval_s = flush_interval_ms / 1000
        local spawn_err
        flush_thread, spawn_err = ngx.thread.spawn(function()
            while true do
                ngx.sleep(interval_s)
                if needs_flush then
                    needs_flush = false
                    local ok, err = ngx.flush(false)
                    if not ok then
                        flush_err = err
                        return
                    end
                    core.log.debug("ai-proxy: flush_thread periodic flush")
                end
            end
        end)
        if not flush_thread then
            core.log.error("failed to spawn flush thread: ", spawn_err)
            async_flush = false
        end
    end

    local function abort_on_disconnect(flush_err)
        core.log.info("client disconnected during AI streaming, ",
                      "aborting upstream read: ", flush_err)
        if flush_thread then
            ngx.thread.kill(flush_thread)
            flush_thread = nil
        end
        if res._httpc then
            res._httpc:close()
            res._httpc = nil
        end
        res._upstream_bytes = bytes_read
        ctx.var.apisix_upstream_response_time = math.floor(
            (ngx_now() - ctx.llm_request_start_time) * 1000)
        ctx.var.llm_request_done = true
    end

    -- Use a local flag instead of reading ctx.var on every chunk.
    local first_token_set = false

    while true do
        if flush_err then
            abort_on_disconnect(flush_err)
            return
        end

        local chunk, err = body_reader()
        if err then
            ctx.var.apisix_upstream_response_time = math.floor(
                (ngx_now() - ctx.llm_request_start_time) * 1000)
            core.log.warn("failed to read response chunk: ", err)
            res._upstream_bytes = bytes_read
            if flush_thread then
                ngx.thread.kill(flush_thread)
                flush_thread = nil
            end
            return transport_http.handle_error(err)
        end
        if not chunk then
            local sse_rem = table.concat(sse_parts)
            if #sse_rem > 0 then
                core.log.warn("dropping incomplete stream frame at EOF, size: ",
                              #sse_rem)
            end

            res._upstream_bytes = bytes_read
            ctx.var.apisix_upstream_response_time = math.floor(
                (ngx_now() - ctx.llm_request_start_time) * 1000)
            if converter and not output_sent then
                if flush_thread then
                    ngx.thread.kill(flush_thread)
                end
                local msg = "streaming response completed without producing "
                            .. "any output; the upstream likely returned a "
                            .. "different stream format than the converter expects"
                core.log.error(msg)
                return 502, msg
            end
            -- Final sync flush: ensure the last async-queued bytes reach the client.
            -- flush_err means client already disconnected; skip to avoid a noisy log.
            if flush_thread then
                ngx.thread.kill(flush_thread)
                flush_thread = nil
            end
            if not flush_err then
                ngx.flush(true)
            end
            return
        end

        bytes_read = bytes_read + #chunk

        if not first_token_set then
            ctx.var.llm_time_to_first_token = math.floor(
                (ngx_now() - ctx.llm_request_start_time) * 1000)
            first_token_set = true
        end

        -- Skip table.concat when there is no carry-over remainder (common case).
        -- sse_parts is reset to {remainder} after each iteration; when the
        -- previous chunk ended on a boundary, sse_parts[1] == "" and we can
        -- hand the new chunk directly to decode_buf without allocating a concat.
        local candidate
        if sse_parts[1] == "" then
            candidate = chunk
        else
            sse_parts[#sse_parts + 1] = chunk
            candidate = table.concat(sse_parts)
        end

        -- One-pass split + decode: finds all complete SSE events and the
        -- trailing remainder in a single forward scan (no PCRE, no double scan).
        local events, remainder = framing.decode_buf(candidate)
        local max_remainder = framing.max_remainder or 1024 * 1024
        if #remainder > max_remainder then
            core.log.warn("stream remainder exceeded ", max_remainder, " bytes, resetting")
            remainder = ""
        end
        sse_parts = {remainder}
        ctx.llm_response_contents_in_chunk = {}
        local converted_chunks = {}

        for _, event in ipairs(events) do
            -- Target protocol parses the provider's SSE format
            local parsed = target_proto.parse_sse_event(event, ctx, sse_state)
            if not parsed or parsed.type == "skip" then
                goto CONTINUE
            end

            -- Converter transforms parsed events to client format (downstream)
            if converter and converter.convert_sse_events then
                local converted = converter.convert_sse_events(parsed, ctx, sse_state)
                if converted then
                    for _, ce in ipairs(converted) do
                        table.insert(converted_chunks, sse.encode(ce))
                    end
                end
            end

            if parsed.texts then
                for _, text in ipairs(parsed.texts) do
                    core.table.insert(contents, text)
                    core.table.insert(ctx.llm_response_contents_in_chunk, text)
                end
            end

            if parsed.usage then
                core.log.info("got token usage from ai service: ",
                                    core.json.delay_encode(parsed.raw_usage or parsed.usage))
                merge_usage(ctx, parsed)
                ctx.var.llm_prompt_tokens = ctx.ai_token_usage.prompt_tokens
                ctx.var.llm_completion_tokens = ctx.ai_token_usage.completion_tokens
                ctx.var.llm_response_text = table.concat(contents, "")
            end

            if parsed.type == "done" or parsed.type == "usage_and_done" then
                ctx.var.llm_request_done = true
            end

            ::CONTINUE::
        end

        -- Dispatch chunk downstream.  Plugins run per-chunk so body_filter
        -- hooks (e.g. content moderation) receive every SSE event individually.
        -- no_flush=true when the interval thread handles flushing; otherwise
        -- no_flush=nil + wait=true for synchronous per-chunk delivery guarantee.
        local no_flush = async_flush or nil
        if converter then
            for _, c in ipairs(converted_chunks) do
                local ok, flush_err = plugin.lua_response_filter(
                    ctx, res.headers, c, no_flush, true)
                if not ok then
                    abort_on_disconnect(flush_err)
                    return
                end
                output_sent = true
            end
        else
            local ok, flush_err = plugin.lua_response_filter(
                ctx, res.headers, chunk, no_flush, true)
            if not ok then
                abort_on_disconnect(flush_err)
                return
            end
            output_sent = true
        end
        -- Let the interval flush thread know there is unflushed output.
        if async_flush then
            needs_flush = true
        end

        -- Enforce runaway-upstream safeguards after processing the chunk.
        -- Checked post-flush so clients still see any bytes we already emitted.
        local limit_hit
        if deadline and ngx_now() >= deadline then
            limit_hit = "max_stream_duration_ms"
        elseif max_bytes and bytes_read > max_bytes then
            limit_hit = "max_response_bytes"
        end
        if limit_hit then
            if flush_thread then
                ngx.thread.kill(flush_thread)
                flush_thread = nil
            end
            local duration_ms = math.floor((ngx_now() -
                                            ctx.llm_request_start_time) * 1000)
            core.log.warn("aborting AI stream: ", limit_hit, " exceeded;",
                          " bytes=", bytes_read,
                          " duration_ms=", duration_ms,
                          " route_id=", ctx.var.route_id or "")
            -- Force-close upstream so we don't pool a half-drained connection.
            if res._httpc then
                res._httpc:close()
                res._httpc = nil
            end
            -- Signal downstream filters (e.g. moderation plugins that defer
            -- work until request completion) that no more content is coming.
            ctx.var.apisix_upstream_response_time = duration_ms
            ctx.var.llm_request_done = true
            res._upstream_bytes = bytes_read
            if output_sent then
                -- Client has already received partial SSE; stop feeding chunks.
                -- nginx will close the downstream connection at end of content
                -- phase. Clients detect incomplete responses via the absence
                -- of a protocol-specific terminator (e.g. OpenAI [DONE],
                -- Anthropic message_stop, Responses response.completed).
                return
            end
            -- No bytes flushed yet (e.g. converter skipped all events so far).
            -- Surface as 504 for duration (timeout-like) or 502 for size-limit
            -- (bad gateway response), so on_error / fallback policies can
            -- distinguish the failure modes.
            local status = limit_hit == "max_stream_duration_ms" and 504 or 502
            return status, limit_hit .. " exceeded"
        end

        -- WORKAROUND, not a real fix: yield to the nginx scheduler so other
        -- coroutines on this worker (health checks, concurrent requests) can
        -- run. body_reader() and ngx.flush() do not yield when the upstream
        -- socket already has data buffered or the downstream client drains
        -- immediately, so under bursty SSE upstreams this loop can monopolize
        -- the worker CPU. ngx.sleep(0) only prevents a single request from
        -- monopolizing the worker; it does not bound per-stream CPU time, add
        -- backpressure, or time out stalled streams. See #13256 for a proper
        -- solution.
        ngx.sleep(0)

    end
end


--- Non-streaming LLM request client.
-- Sends a request to the LLM service and returns the raw response body.
-- Used by plugins that need to call an LLM as a sidecar (e.g., ai-request-rewrite).
-- Does NOT invoke protocol modules, plugin response filters, or SSE parsing.
-- @return number|nil HTTP status code
-- @return string|nil Raw response body (JSON string)
-- @return string|nil Error message
function _M.request(self, ctx, conf, request_table, extra_opts)
    local params, err = self:build_request(ctx, conf, request_table, extra_opts)
    if not params then
        core.log.error("failed to build request: ", err)
        return 500, nil, err
    end

    core.log.info("sending sidecar request to LLM server: ",
                  core.json.delay_encode(log_sanitize.redact_params(params), true))

    local res, req_err = transport_http.request(params, conf.timeout)
    if not res then
        core.log.warn("failed to send request to LLM server: ", req_err)
        return transport_http.handle_error(req_err), nil, req_err
    end

    local raw_body, read_err = res:read_body()

    if conf.keepalive then
        transport_http.set_keepalive(res, conf.keepalive_timeout, conf.keepalive_pool)
    end

    if not raw_body then
        return 500, nil, "failed to read response body: " .. (read_err or "")
    end

    return res.status, raw_body
end


return _M
