// Scan API sources for definitions.
//
// Note the output is produced based on a world class parser, adherence to
// conventions, and a bit of guess work. Examples:
//
//  * We scan for top level module.exports statements, and determine what
//    is exported by looking at the source code only (i.e., we don't do
//    an eval). If exports include `Foo`, it probably is a class, whereas
//    if what is exported is `constants` it probably is prefixed by the
//    basename of the source file (e.g., `zlib`), unless that source file is
//    `buffer.js`, in which case the name is just `buf`.  unless the constant
//    is `kMaxLength`, in which case it is `buffer`.
//
//  * We scan for top level definitions for those exports, handling
//    most common cases (e.g., `X.prototype.foo =`, `X.foo =`,
//    `function X(...) {...}`). Over time, we expect to handle more
//    cases (example: ES2015 class definitions).

import child_process from 'child_process';
import fs from 'fs';
import path from 'path';

import * as acorn from '../../deps/acorn/acorn/dist/acorn.mjs';

// Run a command, capturing stdout, ignoring errors.
function execSync(command) {
  try {
    return child_process.execSync(
      command,
      { stdio: ['ignore', null, 'ignore'] }
    ).toString().trim();
  } catch {
    return '';
  }
}

// Determine origin repo and tag (or hash) of the most recent commit.
const localBranch = execSync('git name-rev --name-only HEAD');
const trackingRemote = execSync(`git config branch.${localBranch}.remote`);
const remoteUrl = execSync(`git config remote.${trackingRemote}.url`);
const repo = (remoteUrl.match(/(\w+\/\w+)\.git\r?\n?$/) ||
             ['', 'nodejs/node'])[1];

const hash = execSync('git log -1 --pretty=%H') || 'master';
const tag = execSync(`git describe --contains ${hash}`).split('\n')[0] || hash;

// Extract definitions from each file specified.
const definition = {};
const output = process.argv[2];
const inputs = process.argv.slice(3);
inputs.forEach((file) => {
  const basename = path.basename(file, '.js');

  // Parse source.
  const source = fs.readFileSync(file, 'utf8');
  const ast = acorn.parse(source, {
    allowReturnOutsideFunction: true,
    ecmaVersion: 'latest',
    locations: true,
  });
  const program = ast.body;

  // Build link
  const link =
    `https://github.com/${repo}/blob/${tag}/${path.relative('.', file).replace(/\\/g, '/')}`;

  // Scan for exports.
  const exported = { constructors: [], identifiers: [] };
  const indirect = {};
  program.forEach((statement) => {
    if (statement.type === 'ExpressionStatement') {
      const expr = statement.expression;
      if (expr.type !== 'AssignmentExpression') return;

      let lhs = expr.left;
      if (lhs.type !== 'MemberExpression') return;
      if (lhs.object.type === 'MemberExpression') lhs = lhs.object;
      if (lhs.object.name === 'exports') {
        const name = lhs.property.name;
        if (expr.right.type === 'FunctionExpression') {
          definition[`${basename}.${name}`] =
            `${link}#L${statement.loc.start.line}`;
        } else if (expr.right.type === 'Identifier') {
          if (expr.right.name === name) {
            indirect[name] = `${basename}.${name}`;
          }
        } else {
          exported.identifiers.push(name);
        }
      } else if (lhs.object.name === 'module') {
        if (lhs.property.name !== 'exports') return;

        let rhs = expr.right;
        while (rhs.type === 'AssignmentExpression') rhs = rhs.right;

        if (rhs.type === 'NewExpression') {
          exported.constructors.push(rhs.callee.name);
        } else if (rhs.type === 'ObjectExpression') {
          rhs.properties.forEach((property) => {
            if (property.value.type === 'Identifier') {
              exported.identifiers.push(property.value.name);
              if (/^[A-Z]/.test(property.value.name[0])) {
                exported.constructors.push(property.value.name);
              }
            }
          });
        } else if (rhs.type === 'Identifier') {
          exported.identifiers.push(rhs.name);
        }
      }
    } else if (statement.type === 'VariableDeclaration') {
      for (const decl of statement.declarations) {
        let init = decl.init;
        while (init && init.type === 'AssignmentExpression') init = init.left;
        if (!init || init.type !== 'MemberExpression') continue;
        if (init.object.name === 'exports') {
          definition[`${basename}.${init.property.name}`] =
            `${link}#L${statement.loc.start.line}`;
        } else if (init.object.name === 'module') {
          if (init.property.name !== 'exports') continue;
          exported.constructors.push(decl.id.name);
          definition[decl.id.name] = `${link}#L${statement.loc.start.line}`;
        }
      }
    }
  });

  // Scan for definitions matching those exports; currently supports:
  //
  //   ClassName.foo = ...;
  //   ClassName.prototype.foo = ...;
  //   function Identifier(...) {...};
  //   class Foo {...};
  //
  program.forEach((statement) => {
    if (statement.type === 'ExpressionStatement') {
      const expr = statement.expression;
      if (expr.type !== 'AssignmentExpression') return;
      if (expr.left.type !== 'MemberExpression') return;

      let object;
      if (expr.left.object.type === 'MemberExpression') {
        if (expr.left.object.property.name !== 'prototype') return;
        object = expr.left.object.object;
      } else if (expr.left.object.type === 'Identifier') {
        object = expr.left.object;
      } else {
        return;
      }

      if (!exported.constructors.includes(object.name)) return;

      let objectName = object.name;
      if (expr.left.object.type === 'MemberExpression') {
        objectName = objectName.toLowerCase();
        if (objectName === 'buffer') objectName = 'buf';
      }

      let name = expr.left.property.name;
      if (expr.left.computed) {
        name = `${objectName}[${name}]`;
      } else {
        name = `${objectName}.${name}`;
      }

      definition[name] = `${link}#L${statement.loc.start.line}`;

      if (expr.left.property.name === expr.right.name) {
        indirect[expr.right.name] = name;
      }

    } else if (statement.type === 'FunctionDeclaration') {
      const name = statement.id.name;
      if (!exported.identifiers.includes(name)) return;
      if (basename.startsWith('_')) return;
      definition[`${basename}.${name}`] =
        `${link}#L${statement.loc.start.line}`;

    } else if (statement.type === 'ClassDeclaration') {
      if (!exported.constructors.includes(statement.id.name)) return;
      definition[statement.id.name] = `${link}#L${statement.loc.start.line}`;

      const name = statement.id.name.slice(0, 1).toLowerCase() +
                  statement.id.name.slice(1);

      statement.body.body.forEach((defn) => {
        if (defn.type !== 'MethodDefinition') return;
        if (defn.kind === 'method') {
          definition[`${name}.${defn.key.name}`] =
            `${link}#L${defn.loc.start.line}`;
        } else if (defn.kind === 'constructor') {
          definition[`new ${statement.id.name}`] =
            `${link}#L${defn.loc.start.line}`;
        }
      });
    }
  });

  // Search for indirect references of the form ClassName.foo = foo;
  if (Object.keys(indirect).length > 0) {
    program.forEach((statement) => {
      if (statement.type === 'FunctionDeclaration') {
        const name = statement.id.name;
        if (indirect[name]) {
          definition[indirect[name]] = `${link}#L${statement.loc.start.line}`;
        }
      }
    });
  }
});

fs.writeFileSync(output, JSON.stringify(definition, null, 2), 'utf8');
