const fs = require('fs/promises');
const path = require('path');
const { inspect } = require('util');
const chalk = require('chalk');

const declarationsPath = path.resolve(__dirname, './isaac-typescript-definitions/typings/');
const outPath = path.resolve(__dirname, './out/');
const overridePath = path.resolve(__dirname, './override/');

let classes = {};

let tsToLuaTypes = {
  undefined: 'nil',
  unknown: 'any',
}

function transpileType(type) {
  if (!type) return 'what';
  type = type.trim();

  if (type.startsWith('(') && type.endsWith(')')) type = type.slice(1, -1);

  if (tsToLuaTypes[type]) {
    return tsToLuaTypes[type];
  }

  if (type.endsWith('[]')) {
    return transpileType(type.slice(0, -2)) + '[]';
  }

  if (type.includes('|')) {
    return type.split('|').map(t => transpileType(t)).join(' | ');
  }

  if (type.startsWith('"')) {
    return 'string';
  }

  if (type.startsWith('Record<') && type.endsWith('>')) {
    return 'table<' + transpileType(type.split(',')[0].replace('Record<','')) + ', ' + transpileType(type.split(',')[1].replace('>','')) + '>';
  }
  if (type.startsWith('LuaTable<') && type.endsWith('>')) {
    return 'table<' + transpileType(type.split(',')[0].replace('LuaTable<','')) + ', ' + transpileType(type.split(',')[1].replace('>','')) + '>';
  }

  let match;

  if (match = /\((.*?)\) ?=> ?(.*)/.exec(type)) { // functions
    return `fun(${match[1] !== '' ? match[1].split(',').map(c => c.split(':')[0] + ':' + transpileType(c.split(':')[1])).join(',') : ''}): ${transpileType(match[2])}`;
  }

  if (match = /(?:Array)|(?:Map)<(.*?)>/.exec(type)) {
    return transpileType(match[1]) + '[]';
  }

  if (match = /(.*?)<(.*?)>/.exec(type)) { // any other weird type we just discard
    return transpileType(match[2]);
  }
  return type;
}

function transpile(parsed, indent, prefix) {
  let global = false;
  indent = indent || 0;
  const type = parsed[0];
  const d = parsed[1];
  let n = '\n' + '  '.repeat(indent); // newline
  switch (type) {
    case 'namespace':
      global = true;
    case 'interface':
      let contents = d.contents;

      if (d.extends) {
        if (classes[d.extends]) {
          contents.push(...classes[d.extends]);
        } else {
          throw new Error(`Class not found in class storage: ${d.extends}`)
        }
      }

      classes[d.name] = contents;
      return `---@class ${d.name}${d.extends ? ' : ' + d.extends : ''}${n}${contents.filter(e => e[0] === 'const').map(c => `---@field public ${c[1].name} ${transpileType(c[1].type)}${n}`).join('')}${global ? '' : '__class_'}${d.name} = {}${n}${contents.filter(e => e[0] !== 'const').map(c => transpile(c, indent, `${global ? '' : '__class_'}${d.name}`)).join(n)}\n`;
    case 'function':
      return `${d.arguments.map(p => `---@param ${p.name.replace('?', '')} ${transpileType(p.type)}${n}`).join('')}---@return ${transpileType(d.returns)}${n}function ${prefix ? prefix + ':' : ''}${d.name}(${d.arguments.map(a => a.name.replace('?', '')).join(', ')}) end`;
    case 'const':
      return `---@type ${transpileType(d.type)}${n}${prefix ? prefix + '.' : ''}${d.name} = nil`;
    case 'enum':
      return `${prefix ? prefix + '.' : ''}${d.name} = {${n}  ${d.contents.map(c => `${c.name} = ${c.value}`).join(`,${n}  `)}${n}}`;
  }

  return '';
}

function removeComments(code) {
  // remove /* */ style comments, for now
  code = code.replace(/\/\*(.*?)\*\//gs, '');
  code = code.split('\n').filter(c => !c.trim().startsWith('//')).join('\n');
  return code;
}

let parsedElements = 0;

/**
 * @param {string} code 
 */
function parse(code) {
  // console.log(code);

  // we're left with code containing "declare" statements
  // other code will pass stuff in here recursively, but the declare doesnt _really_ matter
  // were gonna end up with an ast of sorts anyways as a result, which we can then use instead of declares

  // sanitize stuff
  code = removeComments(code);

  let match;
  let elements = [];

  // look for interfaces
  let interfaceFreeCode = code;
  const interface = /interface (\w+)(?: extends (\w+))? ?{([^}]*)}/g;
  while ((match = interface.exec(code)) !== null) {
    parsedElements++;
    interfaceFreeCode = interfaceFreeCode.replace(match[0], '');
    elements.push(['interface', {name: match[1], extends: match[2], contents: parse(match[3])}]);
  }
  code = interfaceFreeCode;

  // look for namespaces
  let namespaceFreeCode = code;
  const namespace = /namespace (\w+) ?{([^}]*)}/g;
  while ((match = namespace.exec(code)) !== null) {
    parsedElements++;
    namespaceFreeCode = namespaceFreeCode.replace(match[0], '');
    elements.push(['namespace', {name: match[1], contents: parse(match[2])}]);
  }
  code = namespaceFreeCode;

  // look for functions
  let functionFreeCode = code;
  const functions = /(function)?\s(\w+)\s*\(([^;]*)\)(\s*:\s*([\w\[\]\s|<>,]+))?/g; // wow this sucks
  while ((match = functions.exec(code)) !== null) {
    parsedElements++;
    functionFreeCode = functionFreeCode.replace(match[0], '');
    let arguments = match[3].split(',').map(s => {
      const split = s.split(':').map(s => s.trim());
      return {type: split[1], name: split[0]}
    }).filter(c => c.type && c.name);
    elements.push(['function', {name: match[2], arguments: arguments, returns: match[5]}]);
  }
  code = functionFreeCode;

  // looks for enums
  let enumFreeCode = code;
  const enums = /enum (\w+) ?{([^}]*)}/g;
  while ((match = enums.exec(code)) !== null) {
    parsedElements++;
    enumFreeCode = enumFreeCode.replace(match[0], '');
    elements.push(['enum', {name: match[1], contents: match[2].split(',').filter(c => c.split('=').length === 2).map(c => {return {name: c.split('=')[0].trim(), value: c.split('=')[1].trim()}})}]);
  }
  code = enumFreeCode;

  // looks for constants / properties
  let constFreeCode = code;
  const consts = /(\w+)\s*:\s*(\w+)/g;
  while ((match = consts.exec(code)) !== null) {
    parsedElements++;
    constFreeCode = constFreeCode.replace(match[0], '');
    elements.push(['const', {name: match[1], type: match[2]}]);
  }
  code = constFreeCode;

  elements = elements.sort((a, b) => b[1].extends ? 1 : 0 - a[1].extends ? 1 : 0).reverse(); // for extending bullshit

  return elements;
}

async function recursiveReaddir(rpath, p, fileNames) {
  p = p || '';
  fileNames = (fileNames || []).slice();
  const files = await fs.readdir(rpath);
  for (const f of files) {
    const stat = await fs.lstat(path.join(rpath, f));
    if (stat.isDirectory() || stat.isSymbolicLink()) {
      fileNames.push(...await recursiveReaddir(path.join(rpath, f), p + f + '/', fileNames));
    } else {
      fileNames.push(p + f);
    }
  }

  return fileNames;
}

(async () => {
  let timeParsing = 0;
  let timeTotal = 0;

  try {
    const d = await fs.lstat(outPath)
    if (!d.isDirectory()) throw new Error(`${outPath} exists, and isn't a directory!`);
  } catch(err) {
    if (err.code === 'ENOENT') {
      await fs.mkdir(outPath);
    } else {
      console.error(err);
      process.exit();
    }
  }

  let errored = [];
  let errorReason = {};

  const files = await recursiveReaddir(declarationsPath);
  const filesAmt = files.length;
  let i = 0;
  for (const f of files) {
    i++;
    const filePath = path.resolve(declarationsPath, f);

    console.log(` ${chalk.cyanBright(f)}`);

    const override = path.resolve(overridePath, f.replace('.d.ts', '.lua'));
    const luaFilename = f.replace('.d.ts', '.lua').split('/').join('_');
    try {
      if (await fs.stat(override)) {
        console.log('  file exists in override/, ignoring');
        await fs.copyFile(override, path.resolve(outPath, luaFilename));
        continue;
      }
    } catch(err) {}

    const file = await fs.readFile(filePath, 'utf8');

    let startParse = Date.now();
    parsedElements = 0;
    const parsed = parse(file);
    console.log(`  parsed ${chalk.magentaBright(parsedElements)} objects from ${chalk.cyanBright(f)}`);
    timeParsing += Date.now() - startParse;

    if (parsed.length === 0) {
      console.log(`${chalk.redBright('!')} no elements parsed, ${chalk.gray('not writing anything')}`);
      continue;
    }
    if (parsed.length > 10) {
      console.log(`${chalk.redBright('!')} over 10 top-level objects were parsed - this may be a bad idea`);
    }

    let transpiled;
    try {
      transpiled = parsed.map(p => transpile(p)).join('\n').trim();
      errored = errored.filter(e => e !== f);
    } catch(err) {
      errorReason[f] = err;
      if (errored.length > files.length - i) {
        console.error(`${chalk.redBright('!')} failed resolving extends!`);
        errored = [...new Set(errored)];
        console.error(`${chalk.redBright('!')} abandoned ${chalk.magentaBright(errored.length)} files:`);
        let longest = errored.reduce((a, b) => Math.max(a.length || a || 0, b.length));
        errored.forEach(e => console.error(`   - ${chalk.cyanBright(e)}${' '.repeat(longest - e.length + 2)}${chalk.gray(' → ' + errorReason[e].message.replace('Class not found in class storage: ', ''))}`));
        break;
      }

      console.error(`${chalk.yellowBright('!')} ${err}`);
      console.error(`${chalk.yellowBright('!')} pushing to end of queue`);
      files.push(f);
      errored.push(f);

      timeTotal += Date.now() - startParse;

      continue;
    }
    console.log(`  transpiled w/ final length of ${chalk.magentaBright(transpiled.length + ' chars')}`);
    timeTotal += Date.now() - startParse;

    if (transpiled.length === 0) {
      console.log(`${chalk.redBright('!')} nothing transpiled, ${chalk.gray('not writing anything')}`);
      continue;
    }

    //console.log(inspect(parsed, false, 10));
    await fs.writeFile(path.resolve(outPath, luaFilename), transpiled);
    //console.log(`  wrote ${chalk.cyanBright(luaFilename)}`);
  }

  console.log(`\nfinished transpiling ${chalk.magentaBright(filesAmt)} ${chalk.gray(`+ ${files.length - filesAmt}`)} files in ${chalk.magentaBright(timeTotal + 'ms')} avg ${chalk.magentaBright((Math.floor(timeTotal / filesAmt * 100) / 100) + 'ms')} ${chalk.gray(`(${Math.floor(timeParsing/timeTotal * 1000 + 0.5) / 10}% spent parsing)`)}`);
  console.log(`total class cache size: ${chalk.magentaBright(Object.keys(classes).length + ' classes')}`)
  console.log(`check ${chalk.cyanBright(outPath)}`);
})();