import { Ok, Error } from "./gleam.mjs";
import { UnexpectedByte, UnexpectedEndOfInput } from "./gleam/json.mjs";

export function json_to_string(json) {
  return JSON.stringify(json);
}

export function object(entries) {
  return Object.fromEntries(entries);
}

export function identity(x) {
  return x;
}

export function array(list) {
  return list.toArray();
}

export function do_null() {
  return null;
}

export function decode(string) {
  try {
    const result = JSON.parse(string);
    return new Ok(result);
  } catch (err) {
    return new Error(getJsonDecodeError(err, string));
  }
}

export function getJsonDecodeError(stdErr, json) {
  if (isUnexpectedEndOfInput(stdErr)) return new UnexpectedEndOfInput();
  return toUnexpectedByteError(stdErr, json);
}

/**
 * Matches unexpected end of input messages in:
 * - Chromium (edge, chrome, node)
 * - Spidermonkey (firefox)
 * - JavascriptCore (safari)
 *
 * Note that Spidermonkey and JavascriptCore will both incorrectly report some
 * UnexpectedByte errors as UnexpectedEndOfInput errors. For example:
 *
 * @example
 * // in JavascriptCore
 * JSON.parse('{"a"]: "b"})
 * // => JSON Parse error: Expected ':' before value
 *
 * JSON.parse('{"a"')
 * // => JSON Parse error: Expected ':' before value
 *
 * // in Chromium (correct)
 * JSON.parse('{"a"]: "b"})
 * // => Unexpected token ] in JSON at position 4
 *
 * JSON.parse('{"a"')
 * // => Unexpected end of JSON input
 */
function isUnexpectedEndOfInput(err) {
  const unexpectedEndOfInputRegex =
    /((unexpected (end|eof))|(end of data)|(unterminated string)|(json( parse error|\.parse)\: expected '(\:|\}|\])'))/i;
  return unexpectedEndOfInputRegex.test(err.message);
}

/**
 * Converts a SyntaxError to an UnexpectedByte error based on the JS runtime.
 *
 * For Chromium, the unexpected byte and position are reported by the runtime.
 *
 * For JavascriptCore, only the unexpected byte is reported by the runtime, so
 * there is no way to know which position that character is in unless we then
 * parse the string again ourselves. So instead, the position is reported as 0.
 *
 * For Spidermonkey, the position is reported by the runtime as a line and column number
 * and the unexpected byte is found using those coordinates.
 *
 * @param {'chromium' | 'spidermonkey' | 'jscore'} runtime
 * @param {SyntaxError} err
 * @param {string} json
 * @returns {UnexpectedByte}
 */
function toUnexpectedByteError(err, json) {
  let converters = [
    v8UnexpectedByteError,
    oldV8UnexpectedByteError,
    jsCoreUnexpectedByteError,
    spidermonkeyUnexpectedByteError,
  ];

  for (let converter of converters) {
    let result = converter(err, json);
    if (result) return result;
  }

  return new UnexpectedByte("", 0);
}

/**
 * Matches unexpected byte messages in:
 * - V8 (edge, chrome, node)
 *
 * Matches the character but not the position as this is no longer reported by
 * V8. Boo!
 */
function v8UnexpectedByteError(err) {
  const regex = /unexpected token '(.)', ".+" is not valid JSON/i;
  const match = regex.exec(err.message);
  if (!match) return null;
  const byte = toHex(match[1]);
  return new UnexpectedByte(byte, -1);
}

/**
 * Matches unexpected byte messages in:
 * - V8 (edge, chrome, node)
 *
 * No longer works in current versions of V8.
 *
 * Matches the character and its position.
 */
function oldV8UnexpectedByteError(err) {
  const regex = /unexpected token (.) in JSON at position (\d+)/i;
  const match = regex.exec(err.message);
  if (!match) return null;
  const byte = toHex(match[1]);
  const position = Number(match[2]);
  return new UnexpectedByte(byte, position);
}

/**
 * Matches unexpected byte messages in:
 * - Spidermonkey (firefox)
 *
 * Matches the position in a 2d grid only and not the character.
 */
function spidermonkeyUnexpectedByteError(err, json) {
  const regex =
    /(unexpected character|expected .*) at line (\d+) column (\d+)/i;
  const match = regex.exec(err.message);
  if (!match) return null;
  const line = Number(match[2]);
  const column = Number(match[3]);
  const position = getPositionFromMultiline(line, column, json);
  const byte = toHex(json[position]);
  return new UnexpectedByte(byte, position);
}

/**
 * Matches unexpected byte messages in:
 * - JavascriptCore (safari)
 *
 * JavascriptCore only reports what the character is and not its position.
 */
function jsCoreUnexpectedByteError(err) {
  const regex = /unexpected (identifier|token) "(.)"/i;
  const match = regex.exec(err.message);
  if (!match) return null;
  const byte = toHex(match[2]);
  return new UnexpectedByte(byte, 0);
}

function toHex(char) {
  return "0x" + char.charCodeAt(0).toString(16).toUpperCase();
}

/**
 * Gets the position of a character in a flattened (i.e. single line) string
 * from a line and column number. Note that the position is 0-indexed and
 * the line and column numbers are 1-indexed.
 *
 * @param {number} line
 * @param {number} column
 * @param {string} string
 */
function getPositionFromMultiline(line, column, string) {
  if (line === 1) return column - 1;

  let currentLn = 1;
  let position = 0;
  string.split("").find((char, idx) => {
    if (char === "\n") currentLn += 1;
    if (currentLn === line) {
      position = idx + column;
      return true;
    }
    return false;
  });

  return position;
}
