import * as os from 'os';
import * as process from 'process';

import { BSON, type Document, Int32, NumberUtils } from '../../bson';
import { MongoInvalidArgumentError } from '../../error';
import type { DriverInfo, MongoOptions } from '../../mongo_client';
import { fileIsAccessible } from '../../utils';

// eslint-disable-next-line @typescript-eslint/no-require-imports
const NODE_DRIVER_VERSION = require('../../../package.json').version;

/** @internal */
export function isDriverInfoEqual(info1: DriverInfo, info2: DriverInfo): boolean {
  /** for equality comparison, we consider "" as unset */
  const nonEmptyCmp = (s1: string | undefined, s2: string | undefined): boolean => {
    s1 ||= undefined;
    s2 ||= undefined;

    return s1 === s2;
  };

  return (
    nonEmptyCmp(info1.name, info2.name) &&
    nonEmptyCmp(info1.platform, info2.platform) &&
    nonEmptyCmp(info1.version, info2.version)
  );
}

/**
 * @internal
 * @see https://github.com/mongodb/specifications/blob/master/source/mongodb-handshake/handshake.md#hello-command
 */
export interface ClientMetadata {
  driver: {
    name: string;
    version: string;
  };
  os: {
    type: string;
    name?: NodeJS.Platform;
    architecture?: string;
    version?: string;
  };
  platform: string;
  application?: {
    name: string;
  };
  /** FaaS environment information */
  env?: {
    name?: 'aws.lambda' | 'gcp.func' | 'azure.func' | 'vercel';
    timeout_sec?: Int32;
    memory_mb?: Int32;
    region?: string;
    container?: {
      runtime?: string;
      orchestrator?: string;
    };
  };
}

/** @internal */
export class LimitedSizeDocument {
  private document = new Map();
  /** BSON overhead: Int32 + Null byte */
  private documentSize = 5;
  private maxSize: number;

  constructor(maxSize: number) {
    this.maxSize = maxSize;
  }

  /** Only adds key/value if the bsonByteLength is less than MAX_SIZE */
  public ifItFitsItSits(key: string, value: Record<string, any> | string): boolean {
    // The BSON byteLength of the new element is the same as serializing it to its own document
    // subtracting the document size int32 and the null terminator.
    const newElementSize = BSON.serialize(new Map().set(key, value)).byteLength - 5;

    if (newElementSize + this.documentSize > this.maxSize) {
      return false;
    }

    this.documentSize += newElementSize;

    this.document.set(key, value);

    return true;
  }

  toObject(): Document {
    return BSON.deserialize(BSON.serialize(this.document), {
      promoteLongs: false,
      promoteBuffers: false,
      promoteValues: false,
      useBigInt64: false
    });
  }
}

type MakeClientMetadataOptions = Pick<MongoOptions, 'appName'>;
/**
 * From the specs:
 * Implementors SHOULD cumulatively update fields in the following order until the document is under the size limit:
 * 1. Omit fields from `env` except `env.name`.
 * 2. Omit fields from `os` except `os.type`.
 * 3. Omit the `env` document entirely.
 * 4. Truncate `platform`. -- special we do not truncate this field
 */
export async function makeClientMetadata(
  driverInfoList: DriverInfo[],
  { appName = '' }: MakeClientMetadataOptions
): Promise<ClientMetadata> {
  const metadataDocument = new LimitedSizeDocument(512);

  // Add app name first, it must be sent
  if (appName.length > 0) {
    const name =
      Buffer.byteLength(appName, 'utf8') <= 128
        ? appName
        : Buffer.from(appName, 'utf8').subarray(0, 128).toString('utf8');
    metadataDocument.ifItFitsItSits('application', { name });
  }

  const driverInfo = {
    name: 'nodejs',
    version: NODE_DRIVER_VERSION
  };

  // This is where we handle additional driver info added after client construction.
  for (const { name: n = '', version: v = '' } of driverInfoList) {
    if (n.length > 0) {
      driverInfo.name = `${driverInfo.name}|${n}`;
    }
    if (v.length > 0) {
      driverInfo.version = `${driverInfo.version}|${v}`;
    }
  }

  if (!metadataDocument.ifItFitsItSits('driver', driverInfo)) {
    throw new MongoInvalidArgumentError(
      'Unable to include driverInfo name and version, metadata cannot exceed 512 bytes'
    );
  }

  let runtimeInfo = getRuntimeInfo();
  // This is where we handle additional driver info added after client construction.
  for (const { platform = '' } of driverInfoList) {
    if (platform.length > 0) {
      runtimeInfo = `${runtimeInfo}|${platform}`;
    }
  }

  if (!metadataDocument.ifItFitsItSits('platform', runtimeInfo)) {
    throw new MongoInvalidArgumentError(
      'Unable to include driverInfo platform, metadata cannot exceed 512 bytes'
    );
  }

  // Note: order matters, os.type is last so it will be removed last if we're at maxSize
  const osInfo = new Map()
    .set('name', os.platform())
    .set('architecture', os.arch())
    .set('version', os.release())
    .set('type', os.type());

  if (!metadataDocument.ifItFitsItSits('os', osInfo)) {
    for (const key of osInfo.keys()) {
      osInfo.delete(key);
      if (osInfo.size === 0) break;
      if (metadataDocument.ifItFitsItSits('os', osInfo)) break;
    }
  }

  const faasEnv = getFAASEnv();
  if (faasEnv != null) {
    if (!metadataDocument.ifItFitsItSits('env', faasEnv)) {
      for (const key of faasEnv.keys()) {
        faasEnv.delete(key);
        if (faasEnv.size === 0) break;
        if (metadataDocument.ifItFitsItSits('env', faasEnv)) break;
      }
    }
  }
  return await addContainerMetadata(metadataDocument.toObject() as ClientMetadata);
}

let dockerPromise: Promise<boolean>;
type ContainerMetadata = NonNullable<NonNullable<ClientMetadata['env']>['container']>;
/** @internal */
async function getContainerMetadata(): Promise<ContainerMetadata> {
  dockerPromise ??= fileIsAccessible('/.dockerenv');
  const isDocker = await dockerPromise;

  const { KUBERNETES_SERVICE_HOST = '' } = process.env;
  const isKubernetes = KUBERNETES_SERVICE_HOST.length > 0 ? true : false;

  const containerMetadata: ContainerMetadata = {};

  if (isDocker) containerMetadata.runtime = 'docker';
  if (isKubernetes) containerMetadata.orchestrator = 'kubernetes';

  return containerMetadata;
}

/**
 * @internal
 * Re-add each metadata value.
 * Attempt to add new env container metadata, but keep old data if it does not fit.
 */
async function addContainerMetadata(originalMetadata: ClientMetadata): Promise<ClientMetadata> {
  const containerMetadata = await getContainerMetadata();
  if (Object.keys(containerMetadata).length === 0) return originalMetadata;

  const extendedMetadata = new LimitedSizeDocument(512);

  const extendedEnvMetadata: NonNullable<ClientMetadata['env']> = {
    ...originalMetadata?.env,
    container: containerMetadata
  };

  for (const [key, val] of Object.entries(originalMetadata)) {
    if (key !== 'env') {
      extendedMetadata.ifItFitsItSits(key, val);
    } else {
      if (!extendedMetadata.ifItFitsItSits('env', extendedEnvMetadata)) {
        // add in old data if newer / extended metadata does not fit
        extendedMetadata.ifItFitsItSits('env', val);
      }
    }
  }

  if (!('env' in originalMetadata)) {
    extendedMetadata.ifItFitsItSits('env', extendedEnvMetadata);
  }

  return extendedMetadata.toObject() as ClientMetadata;
}

/**
 * Collects FaaS metadata.
 * - `name` MUST be the last key in the Map returned.
 */
export function getFAASEnv(): Map<string, string | Int32> | null {
  const {
    AWS_EXECUTION_ENV = '',
    AWS_LAMBDA_RUNTIME_API = '',
    FUNCTIONS_WORKER_RUNTIME = '',
    K_SERVICE = '',
    FUNCTION_NAME = '',
    VERCEL = '',
    AWS_LAMBDA_FUNCTION_MEMORY_SIZE = '',
    AWS_REGION = '',
    FUNCTION_MEMORY_MB = '',
    FUNCTION_REGION = '',
    FUNCTION_TIMEOUT_SEC = '',
    VERCEL_REGION = ''
  } = process.env;

  const isAWSFaaS =
    AWS_EXECUTION_ENV.startsWith('AWS_Lambda_') || AWS_LAMBDA_RUNTIME_API.length > 0;
  const isAzureFaaS = FUNCTIONS_WORKER_RUNTIME.length > 0;
  const isGCPFaaS = K_SERVICE.length > 0 || FUNCTION_NAME.length > 0;
  const isVercelFaaS = VERCEL.length > 0;

  // Note: order matters, name must always be the last key
  const faasEnv = new Map();

  // When isVercelFaaS is true so is isAWSFaaS; Vercel inherits the AWS env
  if (isVercelFaaS && !(isAzureFaaS || isGCPFaaS)) {
    if (VERCEL_REGION.length > 0) {
      faasEnv.set('region', VERCEL_REGION);
    }

    faasEnv.set('name', 'vercel');
    return faasEnv;
  }

  if (isAWSFaaS && !(isAzureFaaS || isGCPFaaS || isVercelFaaS)) {
    if (AWS_REGION.length > 0) {
      faasEnv.set('region', AWS_REGION);
    }

    if (
      AWS_LAMBDA_FUNCTION_MEMORY_SIZE.length > 0 &&
      Number.isInteger(+AWS_LAMBDA_FUNCTION_MEMORY_SIZE)
    ) {
      faasEnv.set('memory_mb', new Int32(AWS_LAMBDA_FUNCTION_MEMORY_SIZE));
    }

    faasEnv.set('name', 'aws.lambda');
    return faasEnv;
  }

  if (isAzureFaaS && !(isGCPFaaS || isAWSFaaS || isVercelFaaS)) {
    faasEnv.set('name', 'azure.func');
    return faasEnv;
  }

  if (isGCPFaaS && !(isAzureFaaS || isAWSFaaS || isVercelFaaS)) {
    if (FUNCTION_REGION.length > 0) {
      faasEnv.set('region', FUNCTION_REGION);
    }

    if (FUNCTION_MEMORY_MB.length > 0 && Number.isInteger(+FUNCTION_MEMORY_MB)) {
      faasEnv.set('memory_mb', new Int32(FUNCTION_MEMORY_MB));
    }

    if (FUNCTION_TIMEOUT_SEC.length > 0 && Number.isInteger(+FUNCTION_TIMEOUT_SEC)) {
      faasEnv.set('timeout_sec', new Int32(FUNCTION_TIMEOUT_SEC));
    }

    faasEnv.set('name', 'gcp.func');
    return faasEnv;
  }

  return null;
}

/**
 * @internal
 * This type represents the global Deno object and the minimal type contract we expect it to satisfy.
 */
declare const Deno: { version?: { deno?: string } } | undefined;

/**
 * @internal
 * This type represents the global Bun object and the minimal type contract we expect it to satisfy.
 */
declare const Bun: { (): void; version?: string } | undefined;

/**
 * @internal
 * Get current JavaScript runtime platform
 *
 * NOTE: The version information fetching is intentionally written defensively
 * to avoid having a released driver version that becomes incompatible
 * with a future change to these global objects.
 */
function getRuntimeInfo(): string {
  const endianness = NumberUtils.isBigEndian ? 'BE' : 'LE';
  if ('Deno' in globalThis) {
    const version = typeof Deno?.version?.deno === 'string' ? Deno?.version?.deno : '0.0.0-unknown';

    return `Deno v${version}, ${endianness}`;
  }

  if ('Bun' in globalThis) {
    const version = typeof Bun?.version === 'string' ? Bun?.version : '0.0.0-unknown';

    return `Bun v${version}, ${endianness}`;
  }

  return `Node.js ${process.version}, ${endianness}`;
}
