const jsonQuery = require('json-query');
const { get } = require('@usebruno/query');

const JS_KEYWORDS = `
  break case catch class const continue debugger default delete do
  else export extends false finally for function if import in instanceof
  new null return super switch this throw true try typeof var void while with
  undefined let static yield arguments of
`
  .split(/\s+/)
  .filter((word) => word.length > 0);

/**
 * Creates a function from a JavaScript expression
 *
 * When the function is called, the variables used in this expression are picked up from the context
 *
 * ```js
 * res.data.pets.map(pet => pet.name.toUpperCase())
 *
 * function(__bruno__functionInnerContext) {
 *   const { res, pet } = __bruno__functionInnerContext;
 *   return res.data.pets.map(pet => pet.name.toUpperCase())
 * }
 * ```
 */
const compileJsExpression = (expr) => {
  // get all dotted identifiers (foo, bar.baz, .baz)
  const matches = expr.match(/([\w\.$]+)/g) ?? [];

  // get valid js identifiers (foo, bar)
  const vars = new Set(
    matches
      .filter((match) => /^[a-zA-Z$_]/.test(match)) // starts with valid js identifier (foo.bar)
      .map((match) => match.split('.')[0]) // top level identifier (foo)
      .filter((name) => !JS_KEYWORDS.includes(name)) // exclude js keywords
  );

  // globals such as Math
  const globals = [...vars].filter((name) => name in globalThis);

  const code = {
    vars: [...vars].join(', '),
    // pick global from context or globalThis
    globals: globals.map((name) => ` ${name} = ${name} ?? globalThis.${name};`).join('')
  };

  // param name that is unlikely to show up as a var in an expression
  const param = `__bruno__functionInnerContext`;
  const body = `let { ${code.vars} } = ${param}; ${code.globals}; return ${expr}`;

  return new Function(param, body);
};

const internalExpressionCache = new Map();

const evaluateJsExpression = (expression, context) => {
  let fn = internalExpressionCache.get(expression);
  if (fn == null) {
    internalExpressionCache.set(expression, (fn = compileJsExpression(expression)));
  }
  return fn(context);
};

const evaluateJsTemplateLiteral = (templateLiteral, context) => {
  if (!templateLiteral || !templateLiteral.length || typeof templateLiteral !== 'string') {
    return templateLiteral;
  }

  templateLiteral = templateLiteral.trim();

  if (templateLiteral === 'true') {
    return true;
  }

  if (templateLiteral === 'false') {
    return false;
  }

  if (templateLiteral === 'null') {
    return null;
  }

  if (templateLiteral === 'undefined') {
    return undefined;
  }

  if (templateLiteral.startsWith('"') && templateLiteral.endsWith('"')) {
    return templateLiteral.slice(1, -1);
  }

  if (templateLiteral.startsWith('\'') && templateLiteral.endsWith('\'')) {
    return templateLiteral.slice(1, -1);
  }

  if (!isNaN(templateLiteral)) {
    const number = Number(templateLiteral);
    // Check if the number is too high. Too high number might get altered, see #1000
    if (number > Number.MAX_SAFE_INTEGER) {
      return templateLiteral;
    }
    return number;
  }

  templateLiteral = '`' + templateLiteral + '`';

  return evaluateJsExpression(templateLiteral, context);
};

const createResponseParser = (response = {}) => {
  const res = (expr, ...fns) => {
    return get(response.data, expr, ...fns);
  };

  res.status = response.status;
  res.statusText = response.statusText;
  res.headers = response.headers;
  res.body = response.data;
  res.responseTime = response.responseTime;
  res.url = response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null;

  res.jq = (expr) => {
    const output = jsonQuery(expr, { data: response.data });
    return output ? output.value : null;
  };

  return res;
};

/**
 * Objects that are created inside developer mode execution context result in an serialization error when sent to the renderer process
 * Error sending from webFrameMain:  Error: Failed to serialize arguments
 *    at s.send (node:electron/js2c/browser_init:169:631)
 *    at g.send (node:electron/js2c/browser_init:165:2156)
 * How to reproduce
 *    Remove the cleanJson fix and execute the below post response script
 *    bru.setVar("a", {b:3});
 * Todo: Find a better fix
 *
 * serializes typedArrays by using Buffer to handle most binary cases
 * // TODO: reaper, replace with `devalue` after evaluating all cases, current setup is
 * more of a hotfix
 */
const cleanJson = (data) => {
  const typedArrays = [
    // Baseline typed arrays
    Int8Array,
    Uint8Array,
    Uint8ClampedArray,
    Int16Array,
    Uint16Array,
    Int32Array,
    Uint32Array,
    Float32Array,
    Float64Array,
    BigInt64Array,
    BigUint64Array,

    // Baseline 2025 Newly available
    'Float16Array' in globalThis ? Float16Array : null
  ].filter(Boolean);
  const binaryNames = typedArrays.map((d) => d.name);

  const seen = new WeakSet();

  const replacer = (key, value) => {
    if (typeof value === 'object' && value !== null) {
      if (seen.has(value)) {
        return '[Circular Reference]';
      }
      seen.add(value);

      // instanceof + [[Class]] cover same-realm; duck-type fallback for cross-realm/cross-context Error-like objects
      if (value instanceof Error || Object.prototype.toString.call(value) === '[object Error]' || (typeof value.message === 'string' && typeof value.stack === 'string')) {
        const error = {};
        // name/message are often on prototype; ensure they're in the output
        error.name = value.name;
        error.message = value.message;
        Object.getOwnPropertyNames(value).forEach((prop) => {
          error[prop] = value[prop];
        });
        return error;
      }

      const isBinary = typedArrays.find((d) => value instanceof d);
      if (isBinary) {
        return {
          __cleanJSONType: isBinary.name,
          __cleanJSONValue: Buffer.from(value.buffer).toJSON()
        };
      }
    }
    return value;
  };

  const reviver = (key, value) => {
    if (typeof value !== 'object' || value === null) {
      return value;
    }
    if ('__cleanJSONType' in value && '__cleanJSONValue' in value) {
      const matchedName = binaryNames.find((d) => value.__cleanJSONType === d);
      if (!matchedName) return value;
      const binConstructor = typedArrays.find((d) => d.name === matchedName);

      return binConstructor.from(Buffer.from(value.__cleanJSONValue));
    }
    return value;
  };

  try {
    return JSON.parse(JSON.stringify(data, replacer), reviver);
  } catch (e) {
    return data;
  }
};

const cleanCircularJson = (data) => {
  try {
    // Handle circular references by keeping track of seen objects
    const seen = new WeakSet();

    const replacer = (key, value) => {
      // Skip non-objects and null
      if (typeof value !== 'object' || value === null) {
        return value;
      }

      // Detect circular reference
      if (seen.has(value)) {
        return '[Circular Reference]';
      }

      seen.add(value);
      return value;
    };

    return JSON.parse(JSON.stringify(data, replacer));
  } catch (e) {
    return data;
  }
};

module.exports = {
  evaluateJsExpression,
  evaluateJsTemplateLiteral,
  createResponseParser,
  internalExpressionCache,
  cleanJson,
  cleanCircularJson
};
