/* eslint-disable @typescript-eslint/no-unnecessary-type-parameters */
import assert from 'node:assert'
import type {
  IncomingHttpHeaders,
  IncomingMessage,
  OutgoingHttpHeaders,
  OutgoingMessage,
} from 'node:http'

import { getRouterInfo } from '@mwcp/share'
import type { Context as WebContext } from '@mwcp/share'
import {
  SpanKind,
  SpanStatusCode,
  createContextKey,
  propagation,
} from '@opentelemetry/api'
import type { Attributes, Context as TraceContext, Span } from '@opentelemetry/api'
import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'
import type { Headers as UndiciHeaders } from 'undici'

import { AttrNames } from './types.js'
import type { Config } from './types.js'


const defaultProperty: PropertyDescriptor = {
  configurable: true,
  enumerable: true,
  writable: false,
}


/**
 * span key
 */
const SPAN_KEY = createContextKey('OpenTelemetry Context Key SPAN')
/**
 * Return the span if one exists
 *
 * @param context context to get span from
 */
export function getSpan(context: TraceContext): Span | undefined {
  return context.getValue(SPAN_KEY) as Span | undefined
}
/**
 * Set the span on a context
 *
 * @param context context to use as parent
 * @param span span to set active
 */
export function setSpan(context: TraceContext, span: Span): TraceContext {
  return context.setValue(SPAN_KEY, span)
}
/**
 * Remove current span stored in the context
 *
 * @param context context to delete span from
 */
export function deleteSpan(context: TraceContext): TraceContext {
  return context.deleteValue(SPAN_KEY)
}

export function isSpanEnded(span: Span): boolean {
  // @ts-expect-error
  if (typeof span.ended === 'boolean') {
    // @ts-expect-error

    return span.ended as boolean
  }

  /* c8 ignore start */
  if (typeof span.isRecording === 'function') {
    return ! span.isRecording()
  }
  throw new Error('span.ended() and span.isRecording() are not functions, we cannot determine if the span is ended')
  /* c8 ignore stop */
}

/**
 * Parse status code from HTTP response.
 */
export function parseResponseStatus(kind: SpanKind, statusCode?: number): SpanStatusCode {
  const upperBound = kind === SpanKind.CLIENT ? 400 : 500
  // 1xx, 2xx, 3xx are OK on client and server
  // 4xx is OK on server
  if (statusCode && statusCode >= 100 && statusCode < upperBound) {
    return SpanStatusCode.UNSET
  }

  // All other codes are error
  return SpanStatusCode.ERROR
}


/**
 * Adds attributes for request content-length and content-encoding HTTP headers
 * @param { IncomingMessage } Request object whose headers will be analyzed
 * @param { Attributes } Attributes object to be modified
 */
function setRequestContentLengthAttribute(
  request: IncomingMessage,
  attributes: Attributes,
): void {

  const length = getContentLength(request.headers)
  if (length === null) { return }

  if (isCompressed(request.headers)) {
    attributes['http.request_content_length'] = length
  }
  else {
    attributes['http.request_content_length_uncompressed'] = length
  }
}

/**
 * Adds attributes for response content-length and content-encoding HTTP headers
 * @param { IncomingMessage } Response object whose headers will be analyzed
 * @param { Attributes } SpanAttributes object to be modified
 */
export function setResponseContentLengthAttribute(
  response: IncomingMessage,
  attributes: Attributes,
): void {

  const length = getContentLength(response.headers)
  if (length === null) { return }

  if (isCompressed(response.headers)) {
    attributes['http.response_content_length'] = length
  }
  else {
    attributes['http.response_content_length_uncompressed'] = length
  }
}

function getContentLength(headers: OutgoingHttpHeaders | IncomingHttpHeaders): number | null {
  const contentLengthHeader = headers['content-length']
  if (contentLengthHeader === undefined) { return null }

  const contentLength = parseInt(contentLengthHeader as string, 10)
  if (Number.isNaN(contentLength)) { return null }

  return contentLength
}

function isCompressed(headers: OutgoingHttpHeaders | IncomingHttpHeaders): boolean {
  const encoding = headers['content-encoding']

  return !! encoding && encoding !== 'identity'
}


/**
 * Returns attributes related to the kind of HTTP protocol used
 * @param {string} [kind] Kind of HTTP protocol used: "1.0", "1.1", "2", "SPDY" or "QUIC".
 */
function getAttributesFromHttpKind(kind?: string): Attributes {
  const attributes: Attributes = {}
  if (kind) {
    attributes['http.flavor'] = kind
    if (kind.toUpperCase() === 'QUIC') {
      attributes['net.transport'] = 'ip_udp'
    }
    else {
      attributes['net.transport'] = 'ip_tcp'
    }
  }
  return attributes
}


/**
 * Returns incoming request attributes scoped to the request data
 */
export async function getIncomingRequestAttributesFromWebContext(
  ctx: WebContext,
  config: Config,
): Promise<Attributes> {

  const routerInfo = await getRouterInfo(ctx)

  const attrs: Attributes = {
    ['http.host']: ctx.host,
    ['http.method']: ctx.method || 'GET',
    [ATTR_HTTP_ROUTE]: routerInfo?.fullUrl ?? 'unknown',
    ['http.scheme']: ctx.protocol,
    ['http.server_name']: config.serviceName ?? 'unknown',
    ['http.target']: ctx.path || '/',
    ['http.url']: ctx.href,
    ['net.host.name']: ctx.hostname,
    [AttrNames.ServiceName]: config.serviceName ?? 'unknown',
    [AttrNames.ServiceVersion]: config.serviceVersion ?? 'unknown',
    // [AttrNames.ServicePid]: process.pid,
  }

  let httpKindAttributes = {}

  if (typeof ctx[AttrNames.traceId] === 'string') {
    attrs[AttrNames.traceId] = ctx[AttrNames.traceId]
  }

  const { req } = ctx
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  if (req) {
    const userAgent = req.headers['user-agent']
    const ips = req.headers['x-forwarded-for']

    if (typeof ips === 'string') {
      attrs['http.client_ip'] = ips.split(',')[0]
    }

    if (typeof userAgent !== 'undefined') {
      attrs['http.user_agent'] = userAgent
    }

    // Object.defineProperty(attrs, 'http.request.header.content_type', {
    //   enumerable: true,
    //   writable: false,
    //   value: headers['content-type'],
    // })
    setRequestContentLengthAttribute(req, attrs)

    httpKindAttributes = getAttributesFromHttpKind(req.httpVersion)
  }

  return Object.assign(attrs, httpKindAttributes)
}


/**
 * Key format `http.{request|response}.header.{name}`
 * @param headersKeyMap Map<low-key, normalized-key>
 */
export function genAttributesFromHeader(
  type: 'request' | 'response',
  headersKeyMap: Map<string, string>,
  getHeader: (key: string) => string | number | undefined | string[],
): Attributes | undefined {

  const attrs: Attributes = {}

  for (const [lower, normalized] of headersKeyMap) {
    const data = getHeader(lower)
    if (typeof data === 'undefined' || data === '') { continue }

    const value = Array.isArray(data) ? data : data
    const key = `http.${type}.header.${normalized}`
    Object.defineProperty(attrs, key, {
      enumerable: true,
      writable: false,
      value,
    })
  }

  if (Object.keys(attrs).length) {
    return attrs
  }
  return
}

/**
 * Returns map<lower string, lower and normalized string>
 */
export type NormalizedKeyMap = Map<string, string>

export function normalizeHeaderKey(inputKeys: string[]): NormalizedKeyMap {
  const keys: [string, string][] = inputKeys.map((header) => {
    const str = header.toLowerCase()
    return [str, str.replace(/-/ug, '_')]
  })

  const ret = new Map(keys)
  return ret
}


export function propagateOutgoingHeader(
  traceContext: TraceContext,
  message: OutgoingMessage,
): void {

  const headers = {}
  propagation.inject(traceContext, headers)

  Object.entries(headers).forEach(([key, val]) => {
    if (typeof val === 'string' || typeof val === 'number' || Array.isArray(val)) {
      message.setHeader(key, val)
    }
  })
}

/**
 * Skip if header already exists
 */
export function propagateHeader<T extends Headers | UndiciHeaders = Headers>(
  traceContext: TraceContext,
  headers: T,
): void {

  const tmp = {}
  propagation.inject(traceContext, tmp)

  Object.entries(tmp).forEach(([key, val]) => {
    const curr = headers.get(key)
    if (typeof curr !== 'undefined' && curr !== null) { return }

    if (typeof val === 'string' || typeof val === 'number') {
      headers.set(key, val.toString())
    }
    else if (Array.isArray(val)) {
      headers.set(key, val.join(','))
    }
  })
}

/**
   *
   * @param headersKey if omit then use inner prepared headers key
   */
export function setSpanWithRequestHeaders(
  span: Span,
  requestHeadersMap: Map<string, string> | undefined,
  getHeader: (key: string) => string | number | undefined | string[],
  headersKey?: string[],
): void {

  const keyMap = headersKey
    ? normalizeHeaderKey(headersKey)
    : requestHeadersMap

  if (! keyMap?.size) { return }

  const attrs = genAttributesFromHeader('request', keyMap, getHeader)
  if (attrs) {
    span.setAttributes(attrs)
  }
}

interface AddSpanEventWithIncomingRequestDataOptions {
  headers?: Headers | OutgoingHttpHeaders | UndiciHeaders
  query?: object
  /** request data */
  requestBody: unknown
  span: Span
}
/**
 * String of JSON.stringify limited to 2048 characters
 */
export function addSpanEventWithIncomingRequestData(options: AddSpanEventWithIncomingRequestDataOptions): void {
  const { span, requestBody: data, headers, query } = options

  const attrs: Attributes = {}

  if (query) {
    if (typeof query === 'object' && Object.keys(query).length) {
      let value = 'object could not be stringified to JSON'
      try {
        value = truncateString(JSON.stringify(query, null, 2))
      }
      catch (err) {
        void err
      }
      Object.defineProperty(attrs, AttrNames.Http_Request_Query, {
        ...defaultProperty,
        value,
      })
    }
  }

  if (data && Object.keys(data).length) {
    let value = 'object could not be stringified to JSON'
    try {
      value = truncateString(JSON.stringify(data, null, 2))
    }
    catch (err) {
      void err
    }
    Object.defineProperty(attrs, AttrNames.Http_Request_Body, {
      ...defaultProperty,
      value,
    })
  }

  if (headers && typeof headers.get === 'function') {
    Object.defineProperty(attrs, 'http.request.header.content_length', {
      ...defaultProperty,
      value: headers.get('content-length'),
    })
    Object.defineProperty(attrs, 'http.request.header.content_type', {
      ...defaultProperty,
      value: headers.get('content-type'),
    })
  }

  if (Object.keys(attrs).length) {
    span.addEvent(AttrNames.Incoming_Request_data, attrs)
  }
}

export interface AddSpanEventWithOutgoingResponseDataOptions {
  /** return data */
  body: unknown
  headers?: Headers | OutgoingHttpHeaders | UndiciHeaders
  span: Span
  /** response status code */
  status: number
}
/**
 * String of JSON.stringify limited to 2048 characters
 */
export function addSpanEventWithOutgoingResponseData(options: AddSpanEventWithOutgoingResponseDataOptions): void {
  const { span, body, headers, status } = options

  const attrs: Attributes = {}

  let value = ''
  if (typeof body === 'object') {
    try {
      value = truncateString(JSON.stringify(body, null, 2))
    }
    catch {
      value = 'object could not be stringified to JSON'
    }
  }
  else if (typeof body === 'string') {
    value = body ? truncateString(body) : ''
  }
  // else {
  //   value = body ? truncateString(body.toString()) : ''
  // }
  Object.defineProperty(attrs, AttrNames.Http_Response_Body, {
    ...defaultProperty,
    value,
  })

  Object.defineProperty(attrs, AttrNames.Http_Response_Code, {
    ...defaultProperty,
    value: status,
  })

  if (headers && typeof headers.get === 'function') {
    Object.defineProperty(attrs, 'http.response.header.content_length', {
      ...defaultProperty,
      value: headers.get('content-length'),
    })
    Object.defineProperty(attrs, 'http.response.header.content_type', {
      ...defaultProperty,
      value: headers.get('content-type'),
    })
  }

  if (Object.keys(attrs).length) {
    span.addEvent(AttrNames.Outgoing_Response_data, attrs)
  }
}

export function truncateString(str: string, maxLength = 2048): string {
  if (str && str.length > maxLength) {
    return str.slice(0, maxLength) + '... LENGTH: ' + str.length.toString() + ' bytes'
  }
  return str
}

export interface GenRequestSpanNameOptions {
  /** ctx.request?.protocol */
  protocol: string
  /** ctx.method */
  method: string
  route: string
}

/**
 * Generate span name from request
 * @example
 * - 'HTTP GET /api/v1/user'
 * - 'RPC /helloworld.Greeter/SayHello'
 */
export function genRequestSpanName(options: GenRequestSpanNameOptions, maxLength = 128): string {
  const { protocol, method, route } = options
  assert(protocol, 'protocol is required')
  assert(method, 'method is required')

  if (protocol === 'grpc') {
    const spanName = `RPC ${route}`
    return spanName.slice(0, maxLength)
  }
  const spanName = `${protocol.toLocaleUpperCase()} ${method.toUpperCase()} ${route}`
  return spanName.slice(0, maxLength)
}

