local balancer = require "kong.runloop.balancer"
local yield = require("kong.tools.yield").yield
local wasm = require "kong.plugins.prometheus.wasmx"


local kong = kong
local ngx = ngx
local get_phase = ngx.get_phase
local lower = string.lower
local ngx_timer_pending_count = ngx.timer.pending_count
local ngx_timer_running_count = ngx.timer.running_count
local get_all_upstreams = balancer.get_all_upstreams

if not balancer.get_all_upstreams then -- API changed since after Kong 2.5
  get_all_upstreams = require("kong.runloop.balancer.upstreams").get_all_upstreams
end

local CLUSTERING_SYNC_STATUS = require("kong.constants").CLUSTERING_SYNC_STATUS

local stream_available, stream_api = pcall(require, "kong.tools.stream_api")

local role = kong.configuration.role

local KONG_LATENCY_BUCKETS = { 1, 2, 5, 7, 10, 15, 20, 30, 50, 75, 100, 200, 500, 750, 1000, 3000, 6000 }
local UPSTREAM_LATENCY_BUCKETS = { 25, 50, 80, 100, 250, 400, 700, 1000, 2000, 5000, 10000, 30000, 60000 }
local AI_LLM_PROVIDER_LATENCY_BUCKETS = { 250, 500, 1000, 1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000, 10000, 30000, 60000 }

local IS_PROMETHEUS_ENABLED = false
local export_upstream_health_metrics = false

local metrics = {}
-- prometheus.lua instance
local prometheus
local node_id = kong.node.get_id()

-- use the same counter library shipped with Kong
package.loaded['prometheus_resty_counter'] = require("resty.counter")


local kong_subsystem = ngx.config.subsystem
local http_subsystem = kong_subsystem == "http"

local function init()
  local shm = "prometheus_metrics"
  if not ngx.shared[shm] then
    kong.log.err("prometheus: ngx shared dict 'prometheus_metrics' not found")
    return
  end

  prometheus = require("kong.plugins.prometheus.prometheus").init(shm, "kong_")

  -- global metrics
  metrics.connections = prometheus:gauge("nginx_connections_total",
    "Number of connections by subsystem",
    {"node_id", "subsystem", "state"},
    prometheus.LOCAL_STORAGE)
  metrics.nginx_requests_total = prometheus:gauge("nginx_requests_total",
      "Number of requests total", {"node_id", "subsystem"},
      prometheus.LOCAL_STORAGE)
  metrics.timers = prometheus:gauge("nginx_timers",
                                    "Number of nginx timers",
                                    {"state"},
                                    prometheus.LOCAL_STORAGE)
  metrics.db_reachable = prometheus:gauge("datastore_reachable",
                                          "Datastore reachable from Kong, " ..
                                          "0 is unreachable",
                                          nil,
                                          prometheus.LOCAL_STORAGE)
  if role == "data_plane" then
    metrics.cp_connected = prometheus:gauge("control_plane_connected",
                                            "Kong connected to control plane, " ..
                                            "0 is unconnected",
                                            nil,
                                            prometheus.LOCAL_STORAGE)
  end

  metrics.node_info = prometheus:gauge("node_info",
                                       "Kong Node metadata information",
                                       {"node_id", "version"},
                                       prometheus.LOCAL_STORAGE)
  metrics.node_info:set(1, {node_id, kong.version})
  -- only export upstream health metrics in traditional mode and data plane
  if role ~= "control_plane" then
    metrics.upstream_target_health = prometheus:gauge("upstream_target_health",
                                            "Health status of targets of upstream. " ..
                                            "States = healthchecks_off|healthy|unhealthy|dns_error, " ..
                                            "value is 1 when state is populated.",
                                            {"upstream", "target", "address", "state", "subsystem"},
                                            prometheus.LOCAL_STORAGE)
  end

  local memory_stats = {}
  memory_stats.worker_vms = prometheus:gauge("memory_workers_lua_vms_bytes",
                                             "Allocated bytes in worker Lua VM",
                                             {"node_id", "pid", "kong_subsystem"},
                                             prometheus.LOCAL_STORAGE)
  memory_stats.shms = prometheus:gauge("memory_lua_shared_dict_bytes",
                                             "Allocated slabs in bytes in a shared_dict",
                                             {"node_id", "shared_dict", "kong_subsystem"},
                                             prometheus.LOCAL_STORAGE)
  memory_stats.shm_capacity = prometheus:gauge("memory_lua_shared_dict_total_bytes",
                                                     "Total capacity in bytes of a shared_dict",
                                                     {"node_id", "shared_dict", "kong_subsystem"},
                                                     prometheus.LOCAL_STORAGE)

  local res = kong.node.get_memory_stats()
  for shm_name, value in pairs(res.lua_shared_dicts) do
    memory_stats.shm_capacity:set(value.capacity, { node_id, shm_name, kong_subsystem })
  end

  metrics.memory_stats = memory_stats

  -- per service/route
  if http_subsystem then
    metrics.status = prometheus:counter("http_requests_total",
                                        "HTTP status codes per consumer/service/route in Kong",
                                        {"service", "route", "code", "source", "workspace", "consumer"})
  else
    metrics.status = prometheus:counter("stream_sessions_total",
                                        "Stream status codes per service/route in Kong",
                                        {"service", "route", "code", "source", "workspace"})
  end
  metrics.kong_latency = prometheus:histogram("kong_latency_ms",
                                              "Latency added by Kong and enabled plugins " ..
                                              "for each service/route in Kong",
                                              {"service", "route", "workspace"},
                                              KONG_LATENCY_BUCKETS)
  metrics.upstream_latency = prometheus:histogram("upstream_latency_ms",
                                                  "Latency added by upstream response " ..
                                                  "for each service/route in Kong",
                                                  {"service", "route", "workspace"},
                                                  UPSTREAM_LATENCY_BUCKETS)


  if http_subsystem then
    metrics.total_latency = prometheus:histogram("request_latency_ms",
                                                 "Total latency incurred during requests " ..
                                                 "for each service/route in Kong",
                                                 {"service", "route", "workspace"},
                                                 UPSTREAM_LATENCY_BUCKETS)
  else
    metrics.total_latency = prometheus:histogram("session_duration_ms",
                                                 "latency incurred in stream session " ..
                                                 "for each service/route in Kong",
                                                 {"service", "route", "workspace"},
                                                 UPSTREAM_LATENCY_BUCKETS)
  end

  if http_subsystem then
    metrics.bandwidth = prometheus:counter("bandwidth_bytes",
                                          "Total bandwidth (ingress/egress) " ..
                                          "throughput in bytes",
                                          {"service", "route", "direction", "workspace","consumer"})
  else -- stream has no consumer
    metrics.bandwidth = prometheus:counter("bandwidth_bytes",
                                          "Total bandwidth (ingress/egress) " ..
                                          "throughput in bytes",
                                          {"service", "route", "direction", "workspace"})
  end

  -- AI mode
  metrics.ai_llm_requests = prometheus:counter("ai_llm_requests_total",
                                      "AI requests total per ai_provider in Kong",
                                      {"ai_provider", "ai_model", "cache_status", "vector_db", "embeddings_provider", "embeddings_model", "workspace"})

  metrics.ai_llm_cost = prometheus:counter("ai_llm_cost_total",
                                      "AI requests cost per ai_provider/cache in Kong",
                                      {"ai_provider", "ai_model", "cache_status", "vector_db", "embeddings_provider", "embeddings_model", "workspace"})

  metrics.ai_llm_tokens = prometheus:counter("ai_llm_tokens_total",
                                      "AI requests cost per ai_provider/cache in Kong",
                                      {"ai_provider", "ai_model", "cache_status", "vector_db", "embeddings_provider", "embeddings_model", "token_type", "workspace"})

  metrics.ai_llm_provider_latency = prometheus:histogram("ai_llm_provider_latency_ms",
                                      "LLM response Latency for each AI plugins per ai_provider in Kong",
                                      {"ai_provider", "ai_model", "cache_status", "vector_db", "embeddings_provider", "embeddings_model", "workspace"},
                                      AI_LLM_PROVIDER_LATENCY_BUCKETS)

  -- Hybrid mode status
  if role == "control_plane" then
    metrics.data_plane_last_seen = prometheus:gauge("data_plane_last_seen",
                                              "Last time data plane contacted control plane",
                                              {"node_id", "hostname", "ip"},
                                              prometheus.LOCAL_STORAGE)
    metrics.data_plane_config_hash = prometheus:gauge("data_plane_config_hash",
                                              "Config hash numeric value of the data plane",
                                              {"node_id", "hostname", "ip"},
                                              prometheus.LOCAL_STORAGE)

    metrics.data_plane_version_compatible = prometheus:gauge("data_plane_version_compatible",
                                              "Version compatible status of the data plane, 0 is incompatible",
                                              {"node_id", "hostname", "ip", "kong_version"},
                                              prometheus.LOCAL_STORAGE)
  elseif role == "data_plane" then
    local data_plane_cluster_cert_expiry_timestamp = prometheus:gauge(
      "data_plane_cluster_cert_expiry_timestamp",
      "Unix timestamp of Data Plane's cluster_cert expiry time",
      nil,
      prometheus.LOCAL_STORAGE)
    -- The cluster_cert doesn't change once Kong starts.
    -- We set this metrics just once to avoid file read in each scrape.
    local f = assert(io.open(kong.configuration.cluster_cert))
    local pem = assert(f:read("*a"))
    f:close()
    local x509 = require("resty.openssl.x509")
    local cert = assert(x509.new(pem, "PEM"))
    local not_after = assert(cert:get_not_after())
    data_plane_cluster_cert_expiry_timestamp:set(not_after)
  end
end


local function init_worker()
  prometheus:init_worker()
end


local function configure(configs)
  IS_PROMETHEUS_ENABLED = false
  export_upstream_health_metrics = false
  local export_wasm_metrics = false

  if configs ~= nil then
    IS_PROMETHEUS_ENABLED = true

    for i = 1, #configs do
      -- `upstream_health_metrics` and `wasm_metrics` are global properties that
      -- are disabled by default but will be enabled if any plugin instance has
      -- explicitly enabled them

      if configs[i].upstream_health_metrics then
        export_upstream_health_metrics = true
      end

      if configs[i].wasm_metrics then
        export_wasm_metrics = true
      end

      -- no need for further iteration since everyhing is enabled
      if export_upstream_health_metrics and export_wasm_metrics then
        break
      end
    end
  end

  wasm.set_enabled(export_wasm_metrics)
end


-- Convert the MD5 hex string to its numeric representation
-- Note the following will be represented as a float instead of int64 since luajit
-- don't like int64. Good news is prometheus uses float instead of int64 as well
local function config_hash_to_number(hash_str)
  return tonumber("0x" .. hash_str)
end

-- Since in the prometheus library we create a new table for each diverged label
-- so putting the "more dynamic" label at the end will save us some memory
local labels_table_bandwidth = {0, 0, 0, 0, 0}
local labels_table_status = {0, 0, 0, 0, 0, 0}
local labels_table_latency = {0, 0, 0}
local upstream_target_addr_health_table = {
  { value = 0, labels = { 0, 0, 0, "healthchecks_off", ngx.config.subsystem } },
  { value = 0, labels = { 0, 0, 0, "healthy", ngx.config.subsystem } },
  { value = 0, labels = { 0, 0, 0, "unhealthy", ngx.config.subsystem } },
  { value = 0, labels = { 0, 0, 0, "dns_error", ngx.config.subsystem } },
}
-- ai
local labels_table_ai_llm_status = {0, 0, 0, 0, 0, 0, 0}
local labels_table_ai_llm_tokens = {0, 0, 0, 0, 0, 0, 0, 0}

local function set_healthiness_metrics(table, upstream, target, address, status, metrics_bucket)
  for i = 1, #table do
    table[i]['labels'][1] = upstream
    table[i]['labels'][2] = target
    table[i]['labels'][3] = address
    table[i]['value'] = (status == table[i]['labels'][4]) and 1 or 0
    metrics_bucket:set(table[i]['value'], table[i]['labels'])
  end
end


local function log(message, serialized)
  if not metrics then
    kong.log.err("prometheus: can not log metrics because of an initialization "
            .. "error, please make sure that you've declared "
            .. "'prometheus_metrics' shared dict in your nginx template")
    return
  end

  local service_name = ""
  if message and message.service then
    service_name = message.service.name or message.service.host
  end

  local route_name
  if message and message.route then
    route_name = message.route.name or message.route.id
  else
    return
  end

  local consumer = ""
  if http_subsystem then
    if message and serialized.consumer ~= nil then
      consumer = serialized.consumer
    end
  else
    consumer = nil -- no consumer in stream
  end

  local workspace = message.workspace_name or ""
  if serialized.ingress_size or serialized.egress_size then
    labels_table_bandwidth[1] = service_name
    labels_table_bandwidth[2] = route_name
    labels_table_bandwidth[4] = workspace
    labels_table_bandwidth[5] = consumer

    local ingress_size = serialized.ingress_size
    if ingress_size and ingress_size > 0 then
      labels_table_bandwidth[3] = "ingress"
      metrics.bandwidth:inc(ingress_size, labels_table_bandwidth)
    end

    local egress_size = serialized.egress_size
    if egress_size and egress_size > 0 then
      labels_table_bandwidth[3] = "egress"
      metrics.bandwidth:inc(egress_size, labels_table_bandwidth)
    end
  end

  if serialized.status_code then
    labels_table_status[1] = service_name
    labels_table_status[2] = route_name
    labels_table_status[3] = serialized.status_code

    if kong.response.get_source() == "service" then
      labels_table_status[4] = "service"
    else
      labels_table_status[4] = "kong"
    end

    labels_table_status[5] = workspace
    labels_table_status[6] = consumer

    metrics.status:inc(1, labels_table_status)
  end

  if serialized.latencies then
    labels_table_latency[1] = service_name
    labels_table_latency[2] = route_name
    labels_table_latency[3] = workspace

    if http_subsystem then
      local request_latency = serialized.latencies.request
      if request_latency and request_latency >= 0 then
        metrics.total_latency:observe(request_latency, labels_table_latency)
      end

      local upstream_latency = serialized.latencies.proxy
      if upstream_latency ~= nil and upstream_latency >= 0 then
        metrics.upstream_latency:observe(upstream_latency, labels_table_latency)
      end

    else
      local session_latency = serialized.latencies.session
      if session_latency and session_latency >= 0 then
        metrics.total_latency:observe(session_latency, labels_table_latency)
      end
    end

    local kong_proxy_latency = serialized.latencies.kong
    if kong_proxy_latency ~= nil and kong_proxy_latency >= 0 then
      metrics.kong_latency:observe(kong_proxy_latency, labels_table_latency)
    end
  end

  if serialized.ai_metrics then
    -- prtically, serialized.ai_metrics stores namespaced metrics for at most three use cases
    -- proxy: everything going through the proxy path
    -- ai-request-transformer:
    -- ai-response-transformer: uses LLM to decorade the request/response, but the proxying traffic doesn't go to LLM
    for use_case, ai_metrics in pairs(serialized.ai_metrics) do
      kong.log.debug("ingesting ai_metrics for use_case: ", use_case)

      local cache_status = ai_metrics.cache and ai_metrics.cache.cache_status or ""
      local vector_db = ai_metrics.cache and ai_metrics.cache.vector_db or ""
      local embeddings_provider = ai_metrics.cache and ai_metrics.cache.embeddings_provider or ""
      local embeddings_model = ai_metrics.cache and ai_metrics.cache.embeddings_model or ""

      labels_table_ai_llm_status[1] = ai_metrics.meta and ai_metrics.meta.provider_name or ""
      labels_table_ai_llm_status[2] = ai_metrics.meta and ai_metrics.meta.request_model or ""
      labels_table_ai_llm_status[3] = cache_status
      labels_table_ai_llm_status[4] = vector_db
      labels_table_ai_llm_status[5] = embeddings_provider
      labels_table_ai_llm_status[6] = embeddings_model
      labels_table_ai_llm_status[7] = workspace
      metrics.ai_llm_requests:inc(1, labels_table_ai_llm_status)

      if ai_metrics.usage and ai_metrics.usage.cost and ai_metrics.usage.cost > 0 then
        metrics.ai_llm_cost:inc(ai_metrics.usage.cost, labels_table_ai_llm_status)
      end

      if ai_metrics.meta and ai_metrics.meta.llm_latency and ai_metrics.meta.llm_latency >= 0 then
        metrics.ai_llm_provider_latency:observe(ai_metrics.meta.llm_latency, labels_table_ai_llm_status)
      end

      if ai_metrics.cache and ai_metrics.cache.fetch_latency and ai_metrics.cache.fetch_latency >= 0 then
        metrics.ai_cache_fetch_latency:observe(ai_metrics.cache.fetch_latency, labels_table_ai_llm_status)
      end

      if ai_metrics.cache and ai_metrics.cache.embeddings_latency and ai_metrics.cache.embeddings_latency >= 0 then
        metrics.ai_cache_embeddings_latency:observe(ai_metrics.cache.embeddings_latency, labels_table_ai_llm_status)
      end

      labels_table_ai_llm_tokens[1] = ai_metrics.meta and ai_metrics.meta.provider_name or ""
      labels_table_ai_llm_tokens[2] = ai_metrics.meta and ai_metrics.meta.request_model or ""
      labels_table_ai_llm_tokens[3] = cache_status
      labels_table_ai_llm_tokens[4] = vector_db
      labels_table_ai_llm_tokens[5] = embeddings_provider
      labels_table_ai_llm_tokens[6] = embeddings_model
      labels_table_ai_llm_tokens[8] = workspace

      if ai_metrics.usage and ai_metrics.usage.prompt_tokens and ai_metrics.usage.prompt_tokens > 0 then
        labels_table_ai_llm_tokens[7] = "prompt_tokens"
        metrics.ai_llm_tokens:inc(ai_metrics.usage.prompt_tokens, labels_table_ai_llm_tokens)
      end

      if ai_metrics.usage and ai_metrics.usage.completion_tokens and ai_metrics.usage.completion_tokens > 0 then
        labels_table_ai_llm_tokens[7] = "completion_tokens"
        metrics.ai_llm_tokens:inc(ai_metrics.usage.completion_tokens, labels_table_ai_llm_tokens)
      end

      if ai_metrics.usage and ai_metrics.usage.total_tokens and ai_metrics.usage.total_tokens > 0 then
        labels_table_ai_llm_tokens[7] = "total_tokens"
        metrics.ai_llm_tokens:inc(ai_metrics.usage.total_tokens, labels_table_ai_llm_tokens)
      end
    end
  end
end

local function metric_data(write_fn)
  if not prometheus or not metrics then
    kong.log.err("prometheus: plugin is not initialized, please make sure ",
                 " 'prometheus_metrics' shared dict is present in nginx template")
    return kong.response.exit(500, { message = "An unexpected error occurred" })
  end

  local nginx_statistics = kong.nginx.get_statistics()
  metrics.connections:set(nginx_statistics['connections_accepted'], { node_id, kong_subsystem, "accepted" })
  metrics.connections:set(nginx_statistics['connections_handled'], { node_id, kong_subsystem, "handled" })
  metrics.connections:set(nginx_statistics['total_requests'], { node_id, kong_subsystem, "total" })
  metrics.connections:set(nginx_statistics['connections_active'], { node_id, kong_subsystem, "active" })
  metrics.connections:set(nginx_statistics['connections_reading'], { node_id, kong_subsystem, "reading" })
  metrics.connections:set(nginx_statistics['connections_writing'], { node_id, kong_subsystem, "writing" })
  metrics.connections:set(nginx_statistics['connections_waiting'], { node_id, kong_subsystem,"waiting" })

  metrics.nginx_requests_total:set(nginx_statistics['total_requests'], { node_id, kong_subsystem })

  if http_subsystem then -- only export those metrics once in http as they are shared
    metrics.timers:set(ngx_timer_running_count(), {"running"})
    metrics.timers:set(ngx_timer_pending_count(), {"pending"})

    -- db reachable?
    local ok, err = kong.db.connector:connect()
    if ok then
      metrics.db_reachable:set(1)

    else
      metrics.db_reachable:set(0)
      kong.log.err("prometheus: failed to reach database while processing",
                  "/metrics endpoint: ", err)
    end

    if role == "data_plane" then
      local cp_reachable = ngx.shared.kong:get("control_plane_connected")
      if cp_reachable then
        metrics.cp_connected:set(1)
      else
        metrics.cp_connected:set(0)
      end
    end
  end

  local phase = get_phase()

  -- only export upstream health metrics in traditional mode and data plane
  if role ~= "control_plane" and export_upstream_health_metrics then
    -- erase all target/upstream metrics, prevent exposing old metrics
    metrics.upstream_target_health:reset()

    -- upstream targets accessible?
    local upstreams_dict = get_all_upstreams()
    for key, upstream_id in pairs(upstreams_dict) do
      -- long loop maybe spike proxy request latency, so we
      -- need yield to avoid blocking other requests
      -- kong.tools.yield.yield(true)
      yield(true, phase)
      local _, upstream_name = key:match("^([^:]*):(.-)$")
      upstream_name = upstream_name and upstream_name or key
      -- based on logic from kong.db.dao.targets
      local health_info, err = balancer.get_upstream_health(upstream_id)
      if err then
        kong.log.err("failed getting upstream health: ", err)
      end

      if health_info then
        for target_name, target_info in pairs(health_info) do
          if target_info ~= nil and target_info.addresses ~= nil and
            #target_info.addresses > 0 then
            -- healthchecks_off|healthy|unhealthy
            for i = 1, #target_info.addresses do
              local address = target_info.addresses[i]
              local address_label = address.ip .. ":" .. address.port
              local status = lower(address.health)
              set_healthiness_metrics(upstream_target_addr_health_table, upstream_name, target_name, address_label, status, metrics.upstream_target_health)
            end
          else
            -- dns_error
            set_healthiness_metrics(upstream_target_addr_health_table, upstream_name, target_name, '', 'dns_error', metrics.upstream_target_health)
          end
        end
      end
    end
  end

  -- memory stats
  local res = kong.node.get_memory_stats()
  for shm_name, value in pairs(res.lua_shared_dicts) do
    metrics.memory_stats.shms:set(value.allocated_slabs, { node_id, shm_name, kong_subsystem })
  end
  for i = 1, #res.workers_lua_vms do
    metrics.memory_stats.worker_vms:set(res.workers_lua_vms[i].http_allocated_gc,
                                        { node_id, res.workers_lua_vms[i].pid, kong_subsystem })
  end

  -- Hybrid mode status
  if role == "control_plane" then
    -- Cleanup old metrics
    metrics.data_plane_last_seen:reset()
    metrics.data_plane_config_hash:reset()
    metrics.data_plane_version_compatible:reset()

    for data_plane, err in kong.db.clustering_data_planes:each() do
      if err then
        kong.log.err("failed to list data planes: ", err)
        goto next_data_plane
      end

      local labels = { data_plane.id, data_plane.hostname, data_plane.ip }

      metrics.data_plane_last_seen:set(data_plane.last_seen, labels)
      metrics.data_plane_config_hash:set(config_hash_to_number(data_plane.config_hash), labels)

      labels[4] = data_plane.version
      local compatible = 1

      if data_plane.sync_status == CLUSTERING_SYNC_STATUS.KONG_VERSION_INCOMPATIBLE
        or data_plane.sync_status == CLUSTERING_SYNC_STATUS.PLUGIN_SET_INCOMPATIBLE
        or data_plane.sync_status == CLUSTERING_SYNC_STATUS.PLUGIN_VERSION_INCOMPATIBLE then

        compatible = 0
      end
      metrics.data_plane_version_compatible:set(compatible, labels)

::next_data_plane::
    end
  end

  -- notify the function if prometheus plugin is enabled,
  -- so that it can avoid exporting unnecessary metrics if not
  prometheus:metric_data(write_fn, not IS_PROMETHEUS_ENABLED)
  wasm.metrics_data()
end

local function collect()
  ngx.header["Content-Type"] = "text/plain; charset=UTF-8"

  metric_data()

  -- only gather stream metrics if stream_api module is available
  -- and user has configured at least one stream listeners
  if stream_available and #kong.configuration.stream_listeners > 0 then
    local res, err = stream_api.request("prometheus", "")
    if err then
      kong.log.err("failed to collect stream metrics: ", err)
    else
      ngx.print(res)
    end
  end
end

local function get_prometheus()
  if not prometheus then
    kong.log.err("prometheus: plugin is not initialized, please make sure ",
                     " 'prometheus_metrics' shared dict is present in nginx template")
  end
  return prometheus
end

return {
  init        = init,
  init_worker = init_worker,
  configure   = configure,
  log         = log,
  metric_data = metric_data,
  collect     = collect,
  get_prometheus = get_prometheus,
}
