import { stdout } from 'process';
import { LPNode, NamedAnd, NulLP, Token } from '../lp';
import Output, { AssignKind } from './Amm';
import Expr, { Ref } from './Expr';
import opcodes from './opcodes';
import Scope from './Scope';
import Stmt, { Dec, Exit, FnParam, MetaData } from './Stmt';
import Type, { FunctionType, TempConstrainOpts } from './Types';
import { DBG, TODO } from './util';

/*
This class should probably be used for constructing closures, and
inherit the `Metadata` of the containing function (if any). I think
a thin `Expr` wrapper is necessary, but otherwise it's pretty
straightforward and generic enough.
*/
export default class Fn {
  // null if it's an anonymous fn
  name: string | null;
  ast: LPNode;
  // the scope this function is defined in is the `par`
  scope: Scope;
  params: FnParam[];
  retTy: Type;
  body: Stmt[];
  exprFn: Expr;
  // not used by this class, but used by Statements
  metadata: MetaData;
  ty: FunctionType;

  get argNames(): string[] {
    return Object.keys(this.params);
  }

  constructor(
    ast: LPNode,
    scope: Scope,
    name: string | null,
    params: FnParam[],
    retTy: Type | null,
    body: Stmt[],
    metadata: MetaData = null,
  ) {
    this.ast = ast;
    this.scope = scope;
    this.name = name;
    this.params = params;
    this.retTy = retTy !== null ? retTy : Type.generate();
    this.body = body;
    this.metadata =
      metadata !== null ? metadata : new MetaData(scope, this.retTy);

    // sometimes a type isn't selected from a OneOf until later constraints
    // are passed. This means we have to call `cleanup` *at least* twice,
    // however `cleanup` tells us if more rounds are necessary so just use
    // this loop
    for (
      let ensureOnce = true;
      this.body.reduce(
        (carry, stmt) => stmt.cleanup(this.scope) || carry,
        ensureOnce,
      );
      ensureOnce = false
    );

    const tyAst = ((fnAst: LPNode) => {
      if (fnAst instanceof NulLP) {
        // assume it's for an opcode
        const compilerDefinition = '<compiler definition>';
        const makeToken = (tok: string) =>
          new Token(tok, compilerDefinition, -1, -1);
        return new NamedAnd(
          `opcode ${this.name}`,
          {
            opcode: makeToken('opcode'),
            _whitespace: makeToken(' '),
            opcodeName: makeToken(this.name),
          },
          compilerDefinition,
          -1,
          -1,
        );
      }
      return null;
    })(this.ast);
    this.ty = new FunctionType(
      tyAst,
      this.params.map((param) => param.ty),
      this.retTy,
    );
  }

  static fromFunctionsAst(ast: LPNode, scope: Scope): Fn {
    const fnSigScope = new Scope(scope);

    let retTy: Type;
    if (ast.get('optreturntype').has()) {
      const name = ast.get('optreturntype').get('fulltypename');
      retTy = Type.getFromTypename(name, fnSigScope, { isTyVar: true });
      if (retTy === null) {
        throw new Error(`Type not in scope: ${name.t.trim()}`);
      }
      // TODO: I had to re-enable type erasure - previously it was done with
      // `if (retTy.dup() !== null) throw new Error(...)` but this doesn't
      // work for `fn none(): Maybe<any> = noneM();` since the `any` in the
      // return part *does* cause `dup` to return a non-null Type. Type
      // erasure *may* be detected by seeing if the return type strictly
      // represents the superset of the type returned from the internal
      // `return` statement, ie given:
      // ```ln
      // fn foo(): any = true;
      // ```
      // the `any` in the return part of the signature is strictly a
      // superset of the `bool` type, so it definitely is type erasure,
      // while the `any` in the return part of the signature for `fn none`
      // is strictly equal to the `any` part returned by opcode `noneM`.
      // More investigation is necessary to determine if this is the
      // right way to detect that.
      //
      // If the syntax does evolve to require type parameters in functions,
      // then this can be solved: simply check to make sure that the type
      // doesn't `dup()` because the type variable shouldn't require a dup.
      // For example, using an imaginary Alan syntax for generic functions,
      // `fn foo<T>(): Maybe<T>` obviously doesn't contain type erasure:
      // the type `T` gets assigned from the caller's point of view. Meanwhile,
      // `fn foo(): Maybe<any>` would obviously type-erased because the
      // caller doesn't influence the type returned by `foo`.
    } else {
      retTy = Type.oneOf([Type.generate(), opcodes().get('void')]);
    }

    const metadata = new MetaData(scope, retTy);

    const name = ast.get('optname').has() ? ast.get('optname').get().t : null;
    const p: LPNode[] = [];
    const arglist = ast.get('optargs').get('arglist');
    if (arglist.has()) {
      p.push(arglist);
      if (arglist.get('cdr').has()) {
        p.push(...arglist.get('cdr').getAll());
      }
    }
    const params = p.map((paramAst) =>
      FnParam.fromArgAst(paramAst, metadata, fnSigScope),
    );

    let body = [];
    let bodyAsts: LPNode | LPNode[] = ast.get('fullfunctionbody');
    if (bodyAsts.has('functionbody')) {
      bodyAsts = bodyAsts
        .get('functionbody')
        .get('statements')
        .getAll()
        .map((s) => s.get('statement'));
      bodyAsts.forEach((ast) => body.push(...Stmt.fromAst(ast, metadata)));
    } else {
      bodyAsts = bodyAsts.get('assignfunction').get('assignables');
      let exitVal: Expr;
      [body, exitVal] = Expr.fromAssignablesAst(bodyAsts, metadata);
      if (exitVal instanceof Ref) {
        body.push(new Exit(bodyAsts, exitVal, retTy));
        retTy.constrain(exitVal.ty, scope);
      } else {
        const retVal = Dec.gen(exitVal, metadata);
        body.push(retVal);
        body.push(new Exit(bodyAsts, retVal.ref(), retTy));
        retTy.constrain(retVal.ty, scope);
      }
    }

    return new Fn(ast, new Scope(scope), name, params, retTy, body);
  }

  static fromFunctionbody(ast: LPNode, scope: Scope): Fn {
    scope = new Scope(scope);
    const body = [];
    const metadata = new MetaData(scope, opcodes().get('void'));
    ast
      .get('statements')
      .getAll()
      .map((s) => s.get('statement'))
      .forEach((ast) => body.push(...Stmt.fromAst(ast, metadata)));
    return new Fn(
      ast,
      scope,
      null,
      [],
      // TODO: should probably just be `Type.generate()`. That'll allow
      // eg `fn foo = 3;` to correctly assert that it's a function that
      // returns a number.
      opcodes().get('void'),
      body,
      metadata,
    );
  }

  asHandler(amm: Output, event: string) {
    const handlerParams = [];
    for (const param of this.params) {
      handlerParams.push([param.ammName, param.ty]);
    }
    amm.addHandler(event, handlerParams, this.retTy);
    let isReturned = false;
    for (let ii = 0; ii < this.body.length; ii++) {
      const stmt = this.body[ii];
      stmt.inline(amm);
      if (stmt instanceof Exit) {
        isReturned = true;
        if (ii !== this.body.length - 1) {
          throw new Error(
            `hmmmm... unreachable statements probably should've been caught earlier?`,
          );
        }
      }
    }
    if (!isReturned) {
      if (
        !this.retTy.compatibleWithConstraint(
          opcodes().get('void'),
          this.metadata.scope,
        )
      ) {
        throw new Error(`event handlers should not return values`);
      }
      amm.exit();
    }
  }

  // FIXME: it'll take a bit more work to do better inlining, but it *should* be possible
  // to have `inline` load all of the amm code into a new `Stmt[]` which is then iterated
  // at the handler level to do optimizations and such, similar to the `Microstatement[]`
  // that was loaded but using the same JS objects(?) and the optimizations should only
  // be further inlining...
  // FIXME: another option is to convert to SSA form (talked a bit about in Amm.ts) and then
  // perform optimizations from there. This *might* require the `Stmt[]` array from above
  // *or* we can do it in Amm.ts using only strings (although that might be harder)
  // FIXME: a 3rd option is to make amm itself only SSA and perform the the "register
  // selection" in the ammtox stage. This might be the best solution, since it's the most
  // flexible regardless of the backend, and amm is where that diverges.
  // FIXME: this can also all probably be done by revamping AMM generation to use a visitor
  // pattern, followed by a `cleanup` phase (as is done here and in Stmt.ts and Expr.ts).
  inline(
    amm: Output,
    args: Ref[],
    kind: AssignKind,
    name: string,
    ty: Type,
    callScope: Scope,
  ) {
    if (args.length !== this.params.length) {
      throw new Error(`function call argument mismatch`);
    }
    this.params.forEach((param, ii) => param.assign(args[ii], this.scope));
    this.retTy.tempConstrain(ty, callScope);
    // console.log('----- inlining', this.name);
    // stdout.write('rty: ');
    // console.dir(this.retTy, { depth: 4 });
    // console.dir(this.body, { depth: 8 });
    for (let ii = 0; ii < this.body.length; ii++) {
      const stmt = this.body[ii];
      if (stmt instanceof Exit) {
        if (ii !== this.body.length - 1) {
          throw new Error(
            `got a return at a bad time (should've been caught already?)`,
          );
        }
        if (ty.eq(opcodes().get('void'))) {
          break;
        }
        const refCall = ty.isFixed() ? 'reff' : 'refv';
        amm.assign(kind, name, ty, refCall, [stmt.ret.ammName]);
        break;
      }
      stmt.inline(amm);
    }
    this.retTy.resetTemp();
    this.params.forEach((param) => param.unassign());
  }

  // TODO: this can be done with just FnType in Types.ts - just a refactor away :)
  resultTyFor(
    argTys: Type[],
    expectResTy: Type,
    scope: Scope,
    tcOpts?: TempConstrainOpts,
  ): [Type[], Type] | null {
    let res: [Type[], Type] | null = null;
    const isDbg = false;
    try {
      this.params.forEach((param, ii) => {
        isDbg && stdout.write('==> constraining param ty ');
        isDbg && console.dir(param.ty, { depth: 4 });
        isDbg && stdout.write('to ');
        isDbg && console.dir(argTys[ii], { depth: 4 });
        param.ty.tempConstrain(argTys[ii], scope, tcOpts);
        isDbg && stdout.write('now: ');
        isDbg && console.dir(param.ty, { depth: 4 });
      });
      isDbg &&
        console.log('constraining ret ty', this.retTy, 'to', expectResTy);
      this.retTy.tempConstrain(expectResTy, scope, tcOpts);
      isDbg && console.log('now:', this.retTy);
      isDbg && console.log('oh and expected is now', expectResTy);
      const instanceOpts = { interfaceOk: true, forSameDupIface: [] };
      res = [
        this.params.map((param) => param.ty.instance(instanceOpts)),
        this.retTy.instance(instanceOpts),
      ];
    } catch (e) {
      // do nothing: the args aren't applicable to the params so
      // we return null (`res` is already `null`) and we need to
      // ensure the param tys have `resetTemp` called on them.
      // However, if the Error message starts with `TODO`, then
      // print the error since it's for debugging purposes
      const msg = e.message as string;
      if (msg.startsWith('TODO')) {
        // users should be encouraged to report these. Also probably
        // best to just make a custom Error type for TODOs
        console.log(
          'warning: came across TODO from Types.ts when getting result ty for a function:',
        );
        console.group();
        console.log(msg);
        console.groupEnd();
      }
    }
    this.params.forEach((param) => param.ty.resetTemp());
    this.retTy.resetTemp();
    argTys.map((ty) => ty.resetTemp());
    expectResTy.resetTemp();
    return res;
  }
}

// circular dependency issue when this is defined in opcodes.ts :(
export class OpcodeFn extends Fn {
  constructor(
    name: string,
    argDecs: { [name: string]: string },
    retTyName: string,
    __opcodes: Scope,
  ) {
    const tyScope = new Scope(__opcodes);
    const params = Object.entries(argDecs).map(([name, tyName]) => {
      return new FnParam(
        new NulLP(),
        name,
        Type.getFromTypename(tyName, tyScope, { isTyVar: true }),
      );
    });
    const retTy = Type.getFromTypename(retTyName, tyScope, { isTyVar: true });
    if (retTy === null || !(retTy instanceof Type)) {
      throw new Error('not a type');
    }
    super(new NulLP(), __opcodes, name, params, retTy, []);
    __opcodes.put(name, [this]);
  }

  asHandler(_amm: Output, _event: string) {
    // should this be allowed?
    TODO('opcodes as event listener???');
  }

  inline(
    amm: Output,
    args: Ref[],
    kind: AssignKind,
    assign: string,
    ty: Type,
    callScope: Scope,
  ) {
    this.retTy.tempConstrain(ty, callScope);
    amm.assign(
      kind,
      assign,
      ty,
      this.name,
      args.map((ref) => ref.ammName),
    );
    this.retTy.resetTemp();
  }
}
