// Central place for telemetry
// TODO: Replace all of this with proper tracers and events

import {
  type Attributes,
  type AttributeValue,
  metrics,
} from "@opentelemetry/api"
import deepmerge from "deepmerge"
import { flatten } from "flat"
import {
  every,
  isArray,
  isBoolean,
  isFunction,
  isNull,
  isNumber,
  isObject,
  isPlainObject,
  isString,
  isUndefined,
  keys,
  mapValues,
} from "lodash-es"

import type { ZodError } from "zod"

type AttributesInput = Record<string, unknown>

function isAttributesInput(value: unknown): value is AttributesInput {
  return (
    isObject(value) &&
    !isArray(value) &&
    !isNull(value) &&
    keys(value).length > 0 &&
    every(keys(value), isString)
  )
}

/**
 * Checks if a given value is a valid OpenTelemetry `AttributeValue`.
 * An `AttributeValue` can be a `string`, `number`, `boolean`, or a homogenous
 * array containing only `null`, `undefined`, `string`, `number`, or `boolean`.
 *
 * @param value The value to check.
 * @returns `true` if the value is a valid `AttributeValue`, `false` otherwise.
 */
export function isValidAttributeValue(value: unknown): value is AttributeValue {
  if (isString(value) || isNumber(value) || isBoolean(value)) {
    return true
  }
  if (isArray(value)) {
    return every(
      value,
      (item) =>
        isNull(item) ||
        isUndefined(item) ||
        isString(item) ||
        isNumber(item) ||
        isBoolean(item)
    )
  }
  return false
}

/**
 * Sanitizes an input object, ensuring its values are valid OpenTelemetry
 * `AttributeValue` or `JSON.stringify()` representations as a fallback.
 * It recursively processes nested objects and flattens the final object to one
 * level deep with dot delimited keys for nested values.
 *
 * @param data The input object to sanitize.
 * @returns The resulting object.
 *
 * @example
 * ```typescript
 * import { sanitize } from '@/server/telemetry';
 *
 * const input = {
 *   key1: "Example",
 *   key2: 10,
 *   nested: {
 *     nestedKey1: "Value",
 *     nestedKey2: {
 *       nestedKey2Key1: true,
 *     },
 *   },
 * };
 *
 * const sanitized = sanitize(input);
 * console.log(sanitized);
 * // {
 * //   key1: "Example",
 * //   key2: 10,
 * //   "nested.nestedKey1": "Value",
 * //   "nested.nestedKey2.nestedKey2Key1": true,
 * // }
 * ```
 */
export function sanitize(data: AttributesInput): Attributes {
  if (!isPlainObject(data)) {
    throw new Error(`Input must be an object, got ${JSON.stringify(data)}`)
  }

  return flatten(
    mapValues(data, (value) => {
      if (isFunction(value)) {
        throw new Error("Cannot sanitize function")
      } else if (isValidAttributeValue(value)) {
        return value
      } else if (isAttributesInput(value)) {
        return sanitize(value)
      }

      return JSON.stringify(value)
    })
  )
}

/**
 * Creates an object that holds three OpenTelemetry counter instruments. One
 * that represents the counter itself, one for the success and one for any fail.
 * The object contains an `init method that acts as a factory to create the a
 * final object that holds methods to record different types of events one the
 * appropriate counter.
 *
 * @param meterName The name of the OpenTelemetry meter to create.
 * @param counterName The name of the counter instrument to create.
 * @returns An object with an `init` method that returns an object
 * with methods for recording counter events.
 *
 * @example
 *
 * See the codebase for reference usage.
 */
export function createCounter(meterName: string, counterName: string) {
  const meter = metrics.getMeter(meterName)

  const fullName = `${meterName}.${counterName}`

  const counter = meter.createCounter(fullName)
  const success = meter.createCounter(`${fullName}-success`)
  const fail = meter.createCounter(`${fullName}-fail`)

  return {
    /**
     * Initializes the counter event handlers with a set of base attributes.
     * These attributes will be included in all recorded events.
     *
     * @param baseAttrs - The base attributes to associate with the counter. Defaults to an empty object.
     * @returns An object with methods to record specific counter events.
     */
    init(baseAttrs: AttributesInput = {}) {
      return {
        /**
         * Records an event for the main counter.
         *
         * @param attrs - Additional attributes specific to this 'start' event. Defaults to an empty object.
         */
        start(attrs: AttributesInput = {}) {
          const mergedAttrs = deepmerge.all<AttributesInput>([baseAttrs, attrs])
          const finalAttrs = sanitize(mergedAttrs)

          counter.add(1, finalAttrs)
          console.info(`[${fullName}] start:`, finalAttrs)
        },

        /**
         * Records an event for the success counter.
         *
         * @param attrs - Additional attributes specific to this 'success' event. Defaults to an empty object.
         */
        success(attrs: AttributesInput = {}) {
          const mergedAttrs = deepmerge.all<AttributesInput>([baseAttrs, attrs])
          const finalAttrs = sanitize(mergedAttrs)

          success.add(1, finalAttrs)
          console.info(`[${fullName}] success:`, finalAttrs)
        },

        /**
         * Records an event of type `data_error` for the fail counter.
         * Used when some dependent data could not be resolved during the
         * operation. Note that "no data" also exists and might be more
         * appropriate in certain situations.
         *
         * @param errorMsg - A message describing the data error.
         * @param attrs - Additional attributes specific to this 'dataError' event. Defaults to an empty object.
         */
        dataError(errorMsg: string, attrs: AttributesInput = {}) {
          const mergedAttrs = deepmerge.all<AttributesInput>([
            baseAttrs,
            attrs,
            {
              error_type: "data_error",
              error: errorMsg,
            },
          ])
          const finalAttrs = sanitize(mergedAttrs)

          fail.add(1, finalAttrs)
          console.error(`[${fullName}] dataError:`, finalAttrs)
        },

        /**
         * Records an event of type `not_found` for the fail counter.
         * Used when some dependent data could not be found during the operation.
         * Note that when there is an error resolving the data, the
         * `dataError` method might be more appropriate.
         *
         * @param attrs - Additional attributes specific to this 'noDataError' event. Defaults to an empty object.
         */
        noDataError(attrs: AttributesInput = {}) {
          const mergedAttrs = deepmerge.all<AttributesInput>([
            baseAttrs,
            attrs,
            {
              error_type: "not_found",
            },
          ])
          const finalAttrs = sanitize(mergedAttrs)

          fail.add(1, finalAttrs)
          console.error(`[${fullName}] noDataError:`, finalAttrs)
        },

        /**
         * Records an event of type `validation_error` for the fail counter.
         * Used when a Zod schema fails validation.
         *
         * @param zodError - The {@link ZodError} object representing the validation error.
         */
        validationError(zodError: ZodError) {
          const mergedAttrs = deepmerge.all<AttributesInput>([
            baseAttrs,
            {
              error_type: "validation_error",
              error: zodError.format(),
            },
          ])
          const finalAttrs = sanitize(mergedAttrs)

          fail.add(1, finalAttrs)
          console.error(`[${fullName}] validationError:`, finalAttrs)
        },

        /**
         * Records an event of type `http_error` for the fail counter.
         * Used when a `fetch(...)` call fails. **Note**: This method must be
         * `await`ed as it is asynchronous!
         * The given {@link Response} must be unprocessed and will be cloned
         * to avoid interfering with its consumption outside this function.
         *
         * @param response - The HTTP {@link Response} object.
         */
        async httpError(response: Response) {
          const res = response.clone()
          const text = await res.text()

          const mergedAttrs = deepmerge.all<AttributesInput>([
            baseAttrs,
            {
              error_type: "http_error",
              error: {
                status: res.status,
                statusText: res.statusText,
                text,
              },
            },
          ])
          const finalAttrs = sanitize(mergedAttrs)

          fail.add(1, finalAttrs)
          console.error(`[${fullName}] httpError:`, finalAttrs)
        },

        /**
         * Records an event of type `error` for the fail counter.
         * Used when an error is thrown or an exception is caught, or as a
         * general-purpose way to record an 'error' on the fail counter.
         *
         * @param err - An optional error object or message associated with the
         * failure. Usually an instance of {@link Error} or a string.
         */
        fail(err?: unknown) {
          let msg = "unknown"

          if (err && err instanceof Error) {
            msg = err.message
          } else if (typeof err === "string") {
            msg = err
          }

          const mergedAttrs = deepmerge.all<AttributesInput>([
            baseAttrs,
            {
              error_type: "error",
              error: msg,
            },
          ])
          const finalAttrs = sanitize(mergedAttrs)

          fail.add(1, finalAttrs)
          console.error(`[${fullName}] fail:`, finalAttrs)
        },
      }
    },
  }
}
