import { compileJsSync } from "../compiler";
import { reservedIdentifiers } from "../constants";
import Obfuscator from "../obfuscator";
import { ObfuscateOrder } from "../order";
import { ComputeProbabilityMap } from "../probability";
import Template from "../templates/template";
import traverse, { walk } from "../traverse";
import {
  ArrayExpression,
  CallExpression,
  FunctionExpression,
  Identifier,
  Literal,
  Location,
  MemberExpression,
  NewExpression,
  Node,
  ReturnStatement,
  SpreadElement,
  ThisExpression,
  VariableDeclaration,
  VariableDeclarator,
} from "../util/gen";
import { getDefiningIdentifier, getIdentifierInfo } from "../util/identifiers";
import {
  getVarContext,
  isVarContext,
  isFunction,
  prepend,
} from "../util/insert";
import Transform from "./transform";

/**
 * Converts function to `new Function("..code..")` syntax as an alternative to `eval`. Eval is disabled in many environments.
 *
 * `new Function("..code..")` runs in an isolated context, meaning all local variables are undefined and throw errors.
 *
 * Rigorous checks are in place to only include pure functions.
 *
 * `flatten` can attempt to make function reference-less. Recommended to have flatten enabled with RGF.
 *
 * | Mode | Description |
 * | --- | --- |
 * | `"all"` | Applies to all scopes |
 * | `true` | Applies to the top level only |
 * | `false` | Feature disabled |
 */
export default class RGF extends Transform {
  constructor(o) {
    super(o, ObfuscateOrder.RGF);
  }

  match(object, parents) {
    return isVarContext(object) && object.type !== "ArrowFunctionExpression";
  }

  transform(contextObject, contextParents) {
    return () => {
      var isGlobal = contextObject.type == "Program";

      var value = ComputeProbabilityMap(this.options.rgf, (x) => x, isGlobal);
      if (value !== "all" && !isGlobal) {
        return;
      }

      var collect: {
        location: Location;
        references: Set<string>;
        name?: string;
      }[] = [];
      var queue: Location[] = [];
      var names = new Map<string, number>();
      var definingNodes = new Map<string, Node>();

      walk(contextObject, contextParents, (object, parents) => {
        if (
          object !== contextObject &&
          isFunction(object) &&
          !object.$requiresEval &&
          !object.async &&
          !object.generator &&
          getVarContext(parents[0], parents.slice(1)) === contextObject
        ) {
          var defined = new Set<string>(),
            referenced = new Set<string>();

          var isBound = false;

          walk(object.body, [object, ...parents], (o, p) => {
            if (
              o.type == "Identifier" &&
              !reservedIdentifiers.has(o.name) &&
              !this.options.globalVariables.has(o.name)
            ) {
              var info = getIdentifierInfo(o, p);
              if (!info.spec.isReferenced) {
                return;
              }
              if (info.spec.isDefined) {
                defined.add(o.name);
              } else {
                referenced.add(o.name);
              }
            }

            if (o.type == "ThisExpression" || o.type == "Super") {
              isBound = true;
            }
          });

          if (!isBound) {
            defined.forEach((identifier) => {
              referenced.delete(identifier);
            });

            object.params.forEach((param) => {
              referenced.delete(param.name);
            });

            collect.push({
              location: [object, parents],
              references: referenced,
              name: object.id?.name,
            });
          }
        }
      });

      if (!collect.length) {
        return;
      }

      var miss = 0;
      var start = collect.length * 2;

      while (true) {
        var hit = false;

        collect.forEach(
          ({ name, references: references1, location: location1 }) => {
            if (!references1.size && name) {
              collect.forEach((o) => {
                if (
                  o.location[0] !== location1[0] &&
                  o.references.size &&
                  o.references.delete(name)
                ) {
                  // console.log(collect);

                  hit = true;
                }
              });
            }
          }
        );
        if (hit) {
          miss = 0;
        } else {
          miss++;
        }

        if (miss > start) {
          break;
        }
      }

      queue = [];
      collect.forEach((o) => {
        if (!o.references.size) {
          var [object, parents] = o.location;

          queue.push([object, parents]);
          if (
            object.type == "FunctionDeclaration" &&
            typeof object.id.name === "string"
          ) {
            var index = names.size;

            names.set(object.id.name, index);
            definingNodes.set(object.id.name, object.id);
          }
        }
      });

      if (!queue.length) {
        return;
      }

      var referenceArray = this.generateIdentifier();

      walk(contextObject, contextParents, (o, p) => {
        if (o.type == "Identifier" && !reservedIdentifiers.has(o.name)) {
          var index = names.get(o.name);
          if (typeof index === "number") {
            var info = getIdentifierInfo(o, p);
            if (info.spec.isReferenced && !info.spec.isDefined) {
              var location = getDefiningIdentifier(o, p);
              if (location) {
                var pointingTo = location[0];
                var shouldBe = definingNodes.get(o.name);

                // console.log(pointingTo, shouldBe);

                if (pointingTo == shouldBe) {
                  this.log(o.name, "->", `${referenceArray}[${index}]`);

                  this.replace(
                    o,
                    FunctionExpression(
                      [],
                      [
                        ReturnStatement(
                          CallExpression(
                            MemberExpression(
                              Identifier(referenceArray),
                              Literal(index),
                              true
                            ),
                            [
                              Identifier(referenceArray),
                              SpreadElement(Identifier("arguments")),
                            ]
                          )
                        ),
                      ]
                    )
                  );
                }
              }
            }
          }
        }
      });

      var arrayExpression = ArrayExpression([]);
      var variableDeclaration = VariableDeclaration([
        VariableDeclarator(Identifier(referenceArray), arrayExpression),
      ]);

      prepend(contextObject, variableDeclaration);

      queue.forEach(([object, parents]) => {
        var name = object?.id?.name;
        var hasName = !!name;
        var params = object.params.map((x) => x.name) || [];

        var embeddedName = name || this.getPlaceholder();

        // Since `new Function` is completely isolated, create an entire new obfuscator and run remaining transformations.
        // RGF runs early and needs completed code before converting to a string.
        // (^ the variables haven't been renamed yet)
        var obfuscator = new Obfuscator({
          ...this.options,
          rgf: false,
          globalVariables: new Set([
            ...this.options.globalVariables,
            referenceArray,
          ]),
          lock: {
            integrity: false,
          },
          eval: false,
          hideInitializingCode: false,
        });
        var transforms = Object.values(obfuscator.transforms).filter(
          (x) => x.priority > this.priority
        );

        var embeddedFunction = {
          ...object,
          type: "FunctionDeclaration",
          id: Identifier(embeddedName),
        };

        var tree = {
          type: "Program",
          body: [
            embeddedFunction,
            ReturnStatement(
              CallExpression(
                MemberExpression(
                  Identifier(embeddedName),
                  Identifier("call"),
                  false
                ),
                [
                  ThisExpression(),
                  SpreadElement(
                    Template(
                      `Array.prototype.slice.call(arguments, 1)`
                    ).single().expression
                  ),
                ]
              )
            ),
          ],
        };

        (tree as any).__hiddenDeclarations = VariableDeclaration(
          VariableDeclarator(referenceArray)
        );
        (tree as any).__hiddenDeclarations.hidden = true;
        (tree as any).__hiddenDeclarations.declarations[0].id.hidden = true;

        transforms.forEach((transform) => {
          transform.apply(tree);
        });

        // Find eval callbacks
        traverse(tree, (o, p) => {
          if (o.$eval) {
            return () => {
              o.$eval(o, p);
            };
          }
        });

        var toString = compileJsSync(tree, this.options);

        var newFunction = NewExpression(Identifier("Function"), [
          Literal(referenceArray),
          Literal(toString),
        ]);

        if (hasName) {
          arrayExpression.elements[names.get(name)] = newFunction;

          if (Array.isArray(parents[0])) {
            parents[0].splice(parents[0].indexOf(object), 1);
          } else {
            this.error(
              new Error(
                "Error deleting function declaration: " +
                  parents.map((x) => x.type).join(",")
              )
            );
          }
        } else {
          this.replace(object, newFunction);
        }
      });
    };
  }
}
