import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';

import './go-runtime.js';

const wasmUrl = new URL('./parser.wasm', import.meta.url);
const moduleId = '@axiomhq/apl-spec/wasm-parser';
const emptyTree = Object.freeze({ kind: 'Error', children: [], from: -1, to: -1 });

let parserPromise;
let runtimeFailure;

function createMissingAssetError(cause) {
  const error = new Error(
    `Missing WASM asset for ${moduleId}: ${fileURLToPath(wasmUrl)}`,
  );
  error.code = 'APL_WASM_ASSET_MISSING';
  error.cause = cause;
  return error;
}

async function loadWasmBytes() {
  try {
    return await readFile(wasmUrl);
  } catch (error) {
    if (error && typeof error === 'object' && error.code === 'ENOENT') {
      throw createMissingAssetError(error);
    }
    throw error;
  }
}

function getGoConstructor() {
  if (typeof globalThis.Go !== 'function') {
    throw new Error(`Go runtime shim was not initialized for ${moduleId}`);
  }
  return globalThis.Go;
}

async function waitForGlobalFunction(name) {
  for (let attempt = 0; attempt < 50; attempt += 1) {
    if (typeof globalThis[name] === 'function') {
      return globalThis[name];
    }
    await new Promise((resolve) => setTimeout(resolve, 0));
  }

  throw new Error(`Go WASM parser did not expose ${name}`);
}

function isAstNode(value) {
  return !!value && typeof value === 'object' && typeof value.kind === 'string';
}

function pickText(value) {
  for (const key of ['name', 'value', 'type', 'alias', 'visualization']) {
    const candidate = value[key];
    if (
      typeof candidate === 'string' ||
      typeof candidate === 'number' ||
      typeof candidate === 'boolean'
    ) {
      return String(candidate);
    }
  }

  return undefined;
}

function toAstNode(value) {
  const children = [];

  for (const [key, child] of Object.entries(value)) {
    if (key === 'kind' || key === 'op' || key === 'from' || key === 'to') {
      continue;
    }

    if (Array.isArray(child)) {
      for (const item of child) {
        if (isAstNode(item)) {
          children.push(toAstNode(item));
        }
      }
      continue;
    }

    if (isAstNode(child)) {
      children.push(toAstNode(child));
    }
  }

  const node = {
    kind: value.kind,
    children,
    from: typeof value.from === 'number' ? value.from : -1,
    to: typeof value.to === 'number' ? value.to : -1,
  };

  const text = pickText(value);
  if (text !== undefined && text.length > 0) {
    node.text = text;
  }

  if (typeof value.op === 'string' && value.op.length > 0) {
    node.operator = value.op;
  }

  return node;
}

function lineColumnToOffset(source, line, column) {
  const targetLine = Math.max(1, line);
  const targetColumn = Math.max(1, column);

  let currentLine = 1;
  let currentColumn = 1;

  for (let index = 0; index < source.length; index += 1) {
    if (currentLine === targetLine && currentColumn === targetColumn) {
      return index;
    }

    if (source[index] === '\n') {
      currentLine += 1;
      currentColumn = 1;
    } else {
      currentColumn += 1;
    }
  }

  return source.length;
}

function toParseError(query, failure) {
  const message = failure instanceof Error ? failure.message : String(failure);
  const match = message.match(/line:\s*(\d+),\s*col:\s*(\d+):\s*(.+)$/);

  if (!match) {
    return {
      from: 0,
      to: Math.min(query.length, 1),
      message,
    };
  }

  const [, lineText, columnText, detail] = match;
  const from = lineColumnToOffset(query, Number(lineText), Number(columnText));
  const tokenMatch = detail.match(/unexpected token (?:"([^"]*)"|'([^']*)')/);
  const token = tokenMatch?.[1] ?? tokenMatch?.[2] ?? '';
  const span = token && token !== 'EOF' ? token.length : 1;

  return {
    from,
    to: Math.min(query.length, from + span),
    message: detail,
  };
}

function buildParseResult(query, rawResult) {
  if (rawResult instanceof Error) {
    return {
      accepts: false,
      errors: [toParseError(query, rawResult)],
      tree: emptyTree,
    };
  }

  if (rawResult && typeof rawResult === 'object' && typeof rawResult.accepts === 'boolean') {
    return {
      accepts: rawResult.accepts,
      errors: Array.isArray(rawResult.errors)
        ? rawResult.errors.map((error) => ({
            from: Number(error?.from ?? 0),
            to: Number(error?.to ?? 0),
            message: String(error?.message ?? ''),
          }))
        : [],
      tree: isAstNode(rawResult.tree) ? toAstNode(rawResult.tree) : emptyTree,
    };
  }

  return {
    accepts: true,
    errors: [],
    tree: toAstNode(rawResult),
  };
}

async function createParser() {
  const Go = getGoConstructor();
  const go = new Go();
  const wasmBytes = await loadWasmBytes();
  const { instance } = await WebAssembly.instantiate(wasmBytes, go.importObject);

  go.run(instance).catch((error) => {
    runtimeFailure = error;
  });

  const parseApl = await waitForGlobalFunction('ParseAPL');

  return Object.freeze({
    async parse(query) {
      if (runtimeFailure) {
        throw runtimeFailure;
      }

      if (typeof query !== 'string') {
        throw new TypeError(`Expected query to be a string, received ${typeof query}`);
      }

      try {
        return buildParseResult(query, parseApl(query));
      } catch (error) {
        return {
          accepts: false,
          errors: [toParseError(query, error)],
          tree: emptyTree,
        };
      }
    },
  });
}

export async function init() {
  parserPromise ??= createParser().catch((error) => {
    parserPromise = undefined;
    throw error;
  });

  return parserPromise;
}

export async function parse(query) {
  const parser = await init();
  return parser.parse(query);
}
