import { SourceLocation } from 'estree'
import * as es from 'estree'
import { SourceMapConsumer } from 'source-map'

import createContext from './createContext'
import { InterruptedError } from './errors/errors'
import { findDeclarationNode, findIdentifierNode } from './finder'
import { looseParse, parseWithComments } from './parser/utils'
import { getAllOccurrencesInScopeHelper, getScopeHelper } from './scope-refactoring'
import { setBreakpointAtLine } from './stdlib/inspector'
import {
  Chapter,
  Context,
  Error as ResultError,
  ExecutionMethod,
  Finished,
  ModuleContext,
  RecursivePartial,
  Result,
  SourceError,
  SVMProgram,
  Variant
} from './types'
import { assemble } from './vm/svml-assembler'
import { compileToIns } from './vm/svml-compiler'
export { SourceDocumentation } from './editors/ace/docTooltip'

import { CSEResultPromise, resumeEvaluate } from './cse-machine/interpreter'
import { ModuleNotFoundError } from './modules/errors'
import type { ImportOptions } from './modules/moduleTypes'
import preprocessFileImports from './modules/preprocessor'
import { validateFilePath } from './modules/preprocessor/filePaths'
import { getKeywords, getProgramNames, NameDeclaration } from './name-extractor'
import { htmlRunner, resolvedErrorPromise, sourceFilesRunner } from './runner'

export interface IOptions {
  steps: number
  stepLimit: number
  executionMethod: ExecutionMethod
  variant: Variant
  originalMaxExecTime: number
  useSubst: boolean
  isPrelude: boolean
  throwInfiniteLoops: boolean
  envSteps: number

  importOptions: ImportOptions

  /**
   * Set this to true if source file information should be
   * added when parsing programs into ASTs
   *
   * Set to null to let js-slang decide automatically
   */
  shouldAddFileName: boolean | null
}

// needed to work on browsers
if (typeof window !== 'undefined') {
  // @ts-ignore
  SourceMapConsumer.initialize({
    'lib/mappings.wasm': 'https://unpkg.com/source-map@0.7.3/lib/mappings.wasm'
  })
}

let verboseErrors: boolean = false

export function parseError(errors: SourceError[], verbose: boolean = verboseErrors): string {
  const errorMessagesArr = errors.map(error => {
    // FIXME: Either refactor the parser to output an ESTree-compliant AST, or modify the ESTree types.
    const filePath = error.location?.source ? `[${error.location.source}] ` : ''
    const line = error.location ? error.location.start.line : '<unknown>'
    const column = error.location ? error.location.start.column : '<unknown>'
    const explanation = error.explain()

    if (verbose) {
      // TODO currently elaboration is just tagged on to a new line after the error message itself. find a better
      // way to display it.
      const elaboration = error.elaborate()
      return line < 1
        ? `${filePath}${explanation}\n${elaboration}\n`
        : `${filePath}Line ${line}, Column ${column}: ${explanation}\n${elaboration}\n`
    } else {
      return line < 1 ? explanation : `${filePath}Line ${line}: ${explanation}`
    }
  })
  return errorMessagesArr.join('\n')
}

export function findDeclaration(
  code: string,
  context: Context,
  loc: { line: number; column: number }
): SourceLocation | null | undefined {
  const program = looseParse(code, context)
  if (!program) {
    return null
  }
  const identifierNode = findIdentifierNode(program, context, loc)
  if (!identifierNode) {
    return null
  }
  const declarationNode = findDeclarationNode(program, identifierNode)
  if (!declarationNode || identifierNode === declarationNode) {
    return null
  }
  return declarationNode.loc
}

export function getScope(
  code: string,
  context: Context,
  loc: { line: number; column: number }
): SourceLocation[] {
  const program = looseParse(code, context)
  if (!program) {
    return []
  }
  const identifierNode = findIdentifierNode(program, context, loc)
  if (!identifierNode) {
    return []
  }
  const declarationNode = findDeclarationNode(program, identifierNode)
  if (!declarationNode || declarationNode.loc == null || identifierNode !== declarationNode) {
    return []
  }

  return getScopeHelper(declarationNode.loc, program, identifierNode.name)
}

export function getAllOccurrencesInScope(
  code: string,
  context: Context,
  loc: { line: number; column: number }
): SourceLocation[] {
  const program = looseParse(code, context)
  if (!program) {
    return []
  }
  const identifierNode = findIdentifierNode(program, context, loc)
  if (!identifierNode) {
    return []
  }
  const declarationNode = findDeclarationNode(program, identifierNode)
  if (declarationNode == null || declarationNode.loc == null) {
    return []
  }
  return getAllOccurrencesInScopeHelper(declarationNode.loc, program, identifierNode.name)
}

export function hasDeclaration(
  code: string,
  context: Context,
  loc: { line: number; column: number }
): boolean {
  const program = looseParse(code, context)
  if (!program) {
    return false
  }
  const identifierNode = findIdentifierNode(program, context, loc)
  if (!identifierNode) {
    return false
  }
  const declarationNode = findDeclarationNode(program, identifierNode)
  if (declarationNode == null || declarationNode.loc == null) {
    return false
  }

  return true
}

/**
 * Gets names present within a string of code
 * @param code Code to parse
 * @param line Line position of the cursor
 * @param col Column position of the cursor
 * @param context Evaluation context
 * @returns `[NameDeclaration[], true]` if suggestions should be displayed, `[[], false]` otherwise
 */
export async function getNames(
  code: string,
  line: number,
  col: number,
  context: Context
): Promise<[NameDeclaration[], boolean]> {
  const [program, comments] = parseWithComments(code)

  if (!program) {
    return [[], false]
  }
  const cursorLoc: es.Position = { line, column: col }

  const [progNames, displaySuggestions] = await getProgramNames(program, comments, cursorLoc)
  const keywords = getKeywords(program, cursorLoc, context)
  return [progNames.concat(keywords), displaySuggestions]
}

export async function runInContext(
  code: string,
  context: Context,
  options: RecursivePartial<IOptions> = {}
): Promise<Result> {
  const defaultFilePath = '/default.js'
  const files: Partial<Record<string, string>> = {}
  files[defaultFilePath] = code
  return runFilesInContext(files, defaultFilePath, context, options)
}

// this is the first entrypoint for all source files.
// as such, all mapping functions required by alternate languages
// should be defined here.
export async function runFilesInContext(
  files: Partial<Record<string, string>>,
  entrypointFilePath: string,
  context: Context,
  options: RecursivePartial<IOptions> = {}
): Promise<Result> {
  for (const filePath in files) {
    const filePathError = validateFilePath(filePath)
    if (filePathError !== null) {
      context.errors.push(filePathError)
      return resolvedErrorPromise
    }
  }

  let result: Result
  if (context.chapter === Chapter.HTML) {
    const code = files[entrypointFilePath]
    if (code === undefined) {
      context.errors.push(new ModuleNotFoundError(entrypointFilePath))
      return resolvedErrorPromise
    }
    result = await htmlRunner(code, context, options)
  } else {
    // FIXME: Clean up state management so that the `parseError` function is pure.
    //        This is not a huge priority, but it would be good not to make use of
    //        global state.
    ;({ result, verboseErrors } = await sourceFilesRunner(
      p => Promise.resolve(files[p]),
      entrypointFilePath,
      context,
      {
        ...options,
        shouldAddFileName: options.shouldAddFileName ?? Object.keys(files).length > 1
      }
    ))
  }

  return result
}

export function resume(result: Result): Finished | ResultError | Promise<Result> {
  if (result.status === 'finished' || result.status === 'error') {
    return result
  }
  const value = resumeEvaluate(result.context)
  return CSEResultPromise(result.context, value)
}

export function interrupt(context: Context) {
  const globalEnvironment = context.runtime.environments[context.runtime.environments.length - 1]
  context.runtime.environments = [globalEnvironment]
  context.runtime.isRunning = false
  context.errors.push(new InterruptedError(context.runtime.nodes[0]))
}

export function compile(
  code: string,
  context: Context,
  vmInternalFunctions?: string[]
): Promise<SVMProgram | undefined> {
  const defaultFilePath = '/default.js'
  const files: Partial<Record<string, string>> = {}
  files[defaultFilePath] = code
  return compileFiles(files, defaultFilePath, context, vmInternalFunctions)
}

export async function compileFiles(
  files: Partial<Record<string, string>>,
  entrypointFilePath: string,
  context: Context,
  vmInternalFunctions?: string[]
): Promise<SVMProgram | undefined> {
  for (const filePath in files) {
    const filePathError = validateFilePath(filePath)
    if (filePathError !== null) {
      context.errors.push(filePathError)
      return undefined
    }
  }

  const preprocessResult = await preprocessFileImports(
    p => Promise.resolve(files[p]),
    entrypointFilePath,
    context,
    { shouldAddFileName: Object.keys(files).length > 1 }
  )

  if (!preprocessResult.ok) {
    return undefined
  }

  try {
    return compileToIns(preprocessResult.program, undefined, vmInternalFunctions)
  } catch (error) {
    context.errors.push(error)
    return undefined
  }
}

export { createContext, Context, ModuleContext, Result, setBreakpointAtLine, assemble }