import { SentioProvider } from "./sentio-provider";
import { Breakpoint, InitArguments, Variables, Location, Stackframe, Trace } from "./types";
import * as Codec from "@truffle/codec";
const Interpreter = require("js-interpreter");
const DebugUtils =  require("@truffle/debug-utils");
const truffle_debugger = require("@truffle/debugger");
const selectors = require("@truffle/debugger").selectors;
let { data, evm, trace, session, sourcemapping, stacktrace, controller, txlog } = selectors;

export class Debugger {
  private static _instance: any;
  private truffle_debugger: any;
  private readonly sectionPrintouts = new Set(["bui", "glo", "con", "loc"]);

  private constructor() {

  }
  
  public initialized: boolean = false;

  public static get Instance(): Debugger {
    return this._instance || (this._instance = new this())
  }

  public async initialize(init_arguments: InitArguments) {
    const provider = new SentioProvider(init_arguments.providerURL, { keepAlive: false });
    await provider.init(init_arguments.txnHash, init_arguments.chainId);
    this.truffle_debugger = await truffle_debugger.forTx(init_arguments.txnHash, { provider: provider, compilations: init_arguments.shimmedCompilations, lightMode: true });
    await this.truffle_debugger.startFullMode();
    this.initialized = true;
  }

  public async reset() {
    if (this.truffle_debugger.view(session.status.loaded)) {
      await this.truffle_debugger.reset();
    }
  }

  public async stepOver() {
    await this.truffle_debugger.stepOver();
  }

  public async stepInto() {
    await this.truffle_debugger.stepInto();
  }

  public async stepOut() {
    await this.truffle_debugger.stepOut();
  }

  public async stepNext() {
    await this.truffle_debugger.stepNext();
  }

  public async advance(steps?: number) {
    if (steps) {
      await this.truffle_debugger.advance(steps);
    } else {
      await this.truffle_debugger.advance();
    }
  }

  public async continueUntilBreakpoint() {
    await this.truffle_debugger.continueUntilBreakpoint();
  }

  public async addBreakpoint(breakpoint: Breakpoint) {
    await this.truffle_debugger.addBreakpoint(this.determinBreakPoint(breakpoint));
  }

  public async removeBreakpoint(breakpoint: Breakpoint) {
    await this.truffle_debugger.removeBreakpoint(this.determinBreakPoint(breakpoint));
  }

  public async removeAllBreakpoints() {
    await this.truffle_debugger.removeAllBreakpoints();
  }

  public async runToEnd() {
    await this.truffle_debugger.runToEnd();
  }

  public async variables(): Promise<Variables> {
    const values = await this.truffle_debugger.variables({ indicateUnknown: true });
    const sections = this.truffle_debugger.view(data.current.identifiers.sections);

    const sectionVars: Variables = {
      builtin: [],
      global: [],
      contract: [],
      local: []
    };

    for (const [section, variables] of Object.entries(sections)) {
      // only check the first 3 characters of each name given in the input sectionPrintouts
      // since each section name defined in the constructor contains 3 characters
      const includeThisSection = this.sectionPrintouts.has(section.slice(0, 3));
      //@ts-ignore
      if (includeThisSection && variables.length > 0) {
        //@ts-ignore
        for (const variable of variables) {
          const value = values[variable];
          //@ts-ignore
          sectionVars[section].push({
            name: variable,
            raw: value
          });
        }
      }
    }

    return sectionVars;
  }

  public stackTrace(): Stackframe[] {
    let final: boolean = false;
    if (this.truffle_debugger.view(session.status.loaded)) {
      //print final report if finished & failed, intermediate if not
      if (
        this.truffle_debugger.view(trace.finished) &&
        !this.truffle_debugger.view(evm.transaction.status)
      ) {
        final = true; //print final stack trace
      } else {
        final = false; //intermediate call stack
      }
    }
    let report = final
      ? this.truffle_debugger.view(stacktrace.current.finalReport)
      : this.truffle_debugger.view(stacktrace.current.report);
    return report;
  }

  public currentLocation(): Location | undefined {
    if (this.truffle_debugger.view(trace.finishedOrUnloaded)) {
      return undefined;
    }
    const currentLocation = this.truffle_debugger.view(controller.current.location);
    const currentLines = currentLocation.sourceRange
      ? currentLocation.sourceRange.lines
      : null;
    const currentLength = currentLocation.sourceRange
      ? currentLocation.sourceRange.length
      : null;
    const currentSourceId = currentLocation.source
      ? currentLocation.source.id
      : null;
    const index = this.session.view(trace.index);

    // if there's no source
    if (currentSourceId === undefined) {
      return {
        lines: currentLines,
        length: currentLength,
        instructionIndex: index,
      }
    }

    //search sources for given string
    let sources = Object.values(
      this.truffle_debugger.view(sourcemapping.views.sources)
    );

    //we will indeed need the sources here, not just IDs
    let matchingSources = sources.filter(source =>
      // @ts-ignore
      source.id === currentSourceId
    );

    return {
      // @ts-ignore
      compilationId: matchingSources[0]?.compilationId,
      lines: currentLines,
      length: currentLength,
      // @ts-ignore
      sourcePath: matchingSources[0]?.sourcePath,
      instructionIndex: index,
    }
  }

  public currentStep(): any {
    return this.truffle_debugger.view(trace.step);
  }

  public async terminate() {
    this.truffle_debugger = null;
    this.initialized = false;
  }

  public async getCallTrace(): Promise<Trace[] | undefined> {
    this.reset();
    let finished: boolean;
    let functionDepth = 0;
    let currentCallDepth = 1;
    let prevOp;
    let prevLocation;
    let prevPc;
    let depthCallMap: Map<number, Trace[]> = new Map();
    do {
      await this.advance();
      const location = this.currentLocation();
      const step = this.session.view(trace.step);
      const op = step.op;
      const astNode = this.session.view(data.current.node);
      const index = this.session.view(trace.index);

      let currentCall: Trace | undefined = undefined;
      let parentCalls = depthCallMap.get(functionDepth - 1);
      let currentParentCall;
      if (parentCalls) {
        currentParentCall = parentCalls[parentCalls.length - 1];
      }

      if (currentCallDepth - 1 === step.depth) {
        // call ends
        functionDepth --;
      }

      // Handle the external call
      if (op == "CALL" || op == 'CALLCODE' || op == 'DELEGATECALL' || op == 'STATICCALL') {
        // var to = step.stack[step.stack.length - 2];
        // console.log(to);
        currentCall = {
          op: op,
          pc: step.pc,
          index: index,
          inputs: this.getInputs(this.session),
          location: location,
          calls: [],
          externalCall: true,
        };
      }

      // // fullfill the inputs at the function definition
      // if (astNode?.nodeType === 'FunctionDefinition' && currentParentCall && currentParentCall.functionName == null) {
      //   currentParentCall.functionName = astNode.name;
      //   currentParentCall.inputs = this.getInputs(this.session);
      // }

      // if (step.op === "JUMPDEST" && currentParentCall && (currentParentCall.inputs == null || currentParentCall.inputs.length === 0)) {
      //   currentParentCall.inputs = this.getInputs(this.session);
      // }

      // internal function call
      if (step.op == "JUMPDEST" && astNode?.nodeType === 'FunctionDefinition') {
        const thisFuncName = astNode.name;
        // if (currentParentCall == null || thisFuncName != currentParentCall?.functionName) {
          currentCall = {
            op: prevOp,
            pc: prevPc,
            index: index - 1,
            location: prevLocation,
            functionName: thisFuncName,
            inputs: this.getInputs(this.session),
            calls: [],
            externalCall: false,
          };
        // }
      }

      if (step.op === "JUMP") {
        // if (this.session.view(txlog.current.jumpDirection) === "i" && astNode.nodeType === 'FunctionCall') {
        //   currentCall = {
        //     op: op,
        //     pc: step.pc,
        //     index: index,
        //     location: location,
        //     functionName: astNode.expression.name ?? astNode.expression.memberName,
        //     inputs: this.getInputs(this.session),
        //     calls: [],
        //     externalCall: false
        //   };
        // } else 
        if (this.session.view(txlog.current.jumpDirection) === "o" &&
          (astNode?.nodeType === "FunctionDefinition" || astNode?.nodeType === "InlineAssembly")) {
          functionDepth --;
        }
      }

      if (currentCall && currentCall.op !== "JUMPI") {
        let existingSameLevelCalls = depthCallMap.get(functionDepth);
        if (existingSameLevelCalls) {
          depthCallMap.set(functionDepth ++, [...existingSameLevelCalls, currentCall]);
        } else {
          depthCallMap.set(functionDepth ++, [currentCall]);
        }
        if (currentParentCall) {
          currentParentCall.calls.push(currentCall);
        }
      }
      
      finished = this.session.view(controller.current.trace.finished);
      prevOp = op;
      prevPc = step.pc;
      prevLocation = location;
    } while (!finished);
    
    this.reset();
    return depthCallMap.get(0);
  }
  
  public async evalExpression(expression: string): Promise<any> {
    let variables = await this.truffle_debugger.variables({ indicateUnknown: true });

    //if we're just dealing with a single variable, handle that case
    //separately (so that we can do things in a better way for that
    //case)
    let variable = expression.trim();
    if (variable in variables) {
      return variables[variable];
    }

    // converts all !<...> expressions to JS-valid selector requests
    const preprocessSelectors = (expr: string) => {
      const regex = /!<([^>]+)>/g;
      const select = "$"; // expect repl context to have this func
      const replacer = (_: any, selector: any) => `${select}("${selector}")`;

      return expr.replace(regex, replacer);
    };

    //HACK
    //if we're not in the single-variable case, we'll need to do some
    //things to Javascriptify our variables so that the JS syntax for
    //using them is closer to the Solidity syntax
    let context = Codec.Format.Utils.Inspect.unsafeNativizeVariables(variables);

    //HACK -- we can't use "this" as a variable name, so we're going to
    //find an available replacement name, and then modify the context
    //and expression appropriately
    let pseudoThis = "_this";
    while (pseudoThis in context) {
      pseudoThis = "_" + pseudoThis;
    }
    //in addition to pseudoThis, which replaces this, we also have
    //pseudoPseudoThis, which replaces pseudoThis in order to ensure
    //that any uses of pseudoThis yield an error instead of showing this
    let pseudoPseudoThis = "thereisnovariableofthatname";
    while (pseudoPseudoThis in context) {
      pseudoPseudoThis = "_" + pseudoPseudoThis;
    }
    context = DebugUtils.cleanThis(context, pseudoThis);
    let expr = expression.replace(
      //those characters in [] are the legal JS variable name characters
      //note that pseudoThis contains no special characters
      new RegExp("(?<![a-zA-Z0-9_$])" + pseudoThis + "(?![a-zA-Z0-9_$])"),
      pseudoPseudoThis
    );
    expr = expr.replace(
      //those characters in [] are the legal JS variable name characters
      /(?<![a-zA-Z0-9_$])this(?![a-zA-Z0-9_$])/,
      pseudoThis
    );
    //note that pseudoThis contains no dollar signs to screw things up

    expr = preprocessSelectors(expr);

    try {
      return this.safelyEvaluateWithSelectors(expr, context);
    } catch (e) {
      throw new Error(e);
    }
  }

  public get session() {
    return this.truffle_debugger;
  }

  //evaluates expression with the variables in context,
  //but also has `$` as a variable that is the select function
  private safelyEvaluateWithSelectors(expression: string, context: { [s: string]: unknown; } | ArrayLike<unknown>) {
    const select = (expr: string) => {
      let selector, result;

      try {
        selector = expr
          .split(".")
          .reduce((sel, next) => (next.length ? sel[next] : sel), selectors);
      } catch (_) {
        throw new Error(`Unknown selector: ${expr}`);
      }

      // throws its own exception
      result = this.truffle_debugger.view(selector);

      return result;
    };

    let interpreter;
    interpreter = new Interpreter(expression, function (
      interpreter: any,
      globalObject: any
    ) {
      //first let's set up our select function (which will be called $)
      interpreter.setProperty(
        globalObject,
        "$",
        interpreter.createNativeFunction((selectorName: any) => {
          return interpreter.nativeToPseudo(select(selectorName));
        })
      );
      //now let's set up the variables
      for (const [variable, value] of Object.entries(context)) {
        try {
          //note: circular objects wll raise an exception here and get excluded.
          interpreter.setProperty(
            globalObject,
            variable,
            interpreter.nativeToPseudo(value)
          );
        } catch (_) {
          //just omit things that don't work
        }
      }
    });
    interpreter.run();
    return interpreter.pseudoToNative(interpreter.value);
  }

  private determinBreakPoint(breakPoint: Breakpoint) {
    //search sources for given string
    let sources = Object.values(
      this.truffle_debugger.view(sourcemapping.views.sources)
    );
    //we will indeed need the sources here, not just IDs
    let matchingSources = sources.filter(source =>
      // @ts-ignore
      source.sourcePath === breakPoint.sourcePath && source.compilationId === breakPoint.compilationId
    );

    return {
        // @ts-ignore
        sourceId: matchingSources[0].id,
        line: breakPoint.line
      }
  }

  private getInputs(session: any) {
    const userDefinedTypes = session.view(data.views.userDefinedTypes);
    const state = session.view(data.current.state);
    const mappingKeys = session.view(data.views.mappingKeys);
    const allocations = session.view(data.info.allocations);
    const contexts = session.view(data.views.contexts);
    const currentContext = session.view(data.current.context);
    const internalFunctionsTable = session.view(
      data.current.functionsByProgramCounter
    );
  
    const inputAllocations = session.view(
      txlog.current.inputParameterAllocations
    );
    let variables: any[] = [];
  
    if (inputAllocations) {
      const compilationId = session.view(txlog.current.compilationId);
      //can't do a yield* inside a map, have to do this loop manually
      for (let { name, definition, pointer } of inputAllocations) {
        const decoder = Codec.decodeVariable(
          definition,
          pointer,
          {
            userDefinedTypes,
            state,
            mappingKeys,
            allocations,
            contexts,
            currentContext,
            internalFunctionsTable
          },
          compilationId
        );
        variables.push({ name, value: decoder.next().value });
      }
    }
  
    return variables;
  }
}
