/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

import { EventEmitter } from 'events'

export interface FileAccessor {
  readFile(path: string): Promise<string>
}

export interface IDaffodilBreakpoint {
  id: number
  line: number
  verified: boolean
}

interface IStepInTargets {
  id: number
  label: string
}

interface IStackFrame {
  index: number
  name: string
  file: string
  line: number
  column?: number
}

interface IStack {
  count: number
  frames: IStackFrame[]
}

/**
 * A Daffodil runtime with minimal debugger functionality.
 */
export class DaffodilRuntime extends EventEmitter {
  // the initial (and one and only) file we are 'debugging'
  private _sourceFile: string = ''
  public get sourceFile() {
    return this._sourceFile
  }

  // the contents (= lines) of the one and only file
  private _sourceLines: string[] = []

  // This is the next line that will be 'executed'
  private _currentLine = 0
  private _currentColumn: number | undefined

  // maps from sourceFile to array of Daffodil breakpoints
  private _breakPoints = new Map<string, IDaffodilBreakpoint[]>()

  // since we want to send breakpoint events, we will assign an id to every event
  // so that the frontend can match events with breakpoints.
  private _breakpointId = 1

  private _breakAddresses = new Set<string>()

  private _noDebug = false

  private _namedException: string | undefined
  private _otherExceptions = false

  constructor(private _fileAccessor: FileAccessor) {
    super()
  }

  /**
   * Start executing the given program.
   */
  public async start(
    program: string,
    stopOnEntry: boolean,
    noDebug: boolean
  ): Promise<void> {
    this._noDebug = noDebug

    await this.loadSource(program)
    this._currentLine = -1

    await this.verifyBreakpoints(this._sourceFile)

    if (stopOnEntry) {
      // we step once
      this.step(false, 'stopOnEntry')
    } else {
      // we just start to run until we hit a breakpoint or an exception
      this.continue()
    }
  }

  /**
   * Continue execution to the end/beginning.
   */
  public continue(reverse = false) {
    this.run(reverse, undefined)
  }

  /**
   * Step to the next/previous non empty line.
   */
  public step(reverse = false, event = 'stopOnStep') {
    this.run(reverse, event)
  }

  /**
   * "Step into" for Daffodil Debug means: go to next character
   */
  public stepIn(targetId: number | undefined) {
    if (typeof targetId === 'number') {
      this._currentColumn = targetId
      this.sendEvent('stopOnStep')
    } else {
      if (typeof this._currentColumn === 'number') {
        if (
          this._currentColumn <= this._sourceLines[this._currentLine].length
        ) {
          this._currentColumn += 1
        }
      } else {
        this._currentColumn = 1
      }
      this.sendEvent('stopOnStep')
    }
  }

  /**
   * "Step out" for Daffodil Debug means: go to previous character
   */
  public stepOut() {
    if (typeof this._currentColumn === 'number') {
      this._currentColumn -= 1
      if (this._currentColumn === 0) {
        this._currentColumn = undefined
      }
    }
    this.sendEvent('stopOnStep')
  }

  public getStepInTargets(frameId: number): IStepInTargets[] {
    const line = this._sourceLines[this._currentLine].trim()

    // every word of the current line becomes a stack frame.
    const words = line.split(/\s+/)

    // return nothing if frameId is out of range
    if (frameId < 0 || frameId >= words.length) {
      return []
    }

    // pick the frame for the given frameId
    const frame = words[frameId]

    const pos = line.indexOf(frame)

    // make every character of the frame a potential "step in" target
    return frame.split('').map((c, ix) => {
      return {
        id: pos + ix,
        label: `target: ${c}`,
      }
    })
  }

  /**
   * Returns a fake 'stacktrace' where every 'stackframe' is a word from the current line.
   */
  public stack(startFrame: number, endFrame: number): IStack {
    const words = this._sourceLines[this._currentLine].trim().split(/\s+/)

    const frames = new Array<IStackFrame>()
    // every word of the current line becomes a stack frame.
    for (let i = startFrame; i < Math.min(endFrame, words.length); i++) {
      const name = words[i] // use a word of the line as the stackframe name
      const stackFrame: IStackFrame = {
        index: i,
        name: `${name}(${i})`,
        file: this._sourceFile,
        line: this._currentLine,
      }
      if (typeof this._currentColumn === 'number') {
        stackFrame.column = this._currentColumn
      }
      frames.push(stackFrame)
    }
    return {
      frames: frames,
      count: words.length,
    }
  }

  public getBreakpoints(path: string, line: number): number[] {
    const l = this._sourceLines[line]

    let sawSpace = true
    const bps: number[] = []
    for (let i = 0; i < l.length; i++) {
      if (l[i] !== ' ') {
        if (sawSpace) {
          bps.push(i)
          sawSpace = false
        }
      } else {
        sawSpace = true
      }
    }

    return bps
  }

  /*
   * Set breakpoint in file with given line.
   */
  public async setBreakPoint(
    path: string,
    line: number
  ): Promise<IDaffodilBreakpoint> {
    const bp: IDaffodilBreakpoint = {
      verified: false,
      line,
      id: this._breakpointId++,
    }
    let bps = this._breakPoints.get(path)
    if (!bps) {
      bps = new Array<IDaffodilBreakpoint>()
      this._breakPoints.set(path, bps)
    }
    bps.push(bp)

    await this.verifyBreakpoints(path)

    return bp
  }

  /*
   * Clear breakpoint in file with given line.
   */
  public clearBreakPoint(
    path: string,
    line: number
  ): IDaffodilBreakpoint | undefined {
    const bps = this._breakPoints.get(path)
    if (bps) {
      const index = bps.findIndex((bp) => bp.line === line)
      if (index >= 0) {
        const bp = bps[index]
        bps.splice(index, 1)
        return bp
      }
    }
    return undefined
  }

  /*
   * Clear all breakpoints for file.
   */
  public clearBreakpoints(path: string): void {
    this._breakPoints.delete(path)
  }

  /*
   * Set data breakpoint.
   */
  public setDataBreakpoint(address: string): boolean {
    if (address) {
      this._breakAddresses.add(address)
      return true
    }
    return false
  }

  public setExceptionsFilters(
    namedException: string | undefined,
    otherExceptions: boolean
  ): void {
    this._namedException = namedException
    this._otherExceptions = otherExceptions
  }

  /*
   * Clear all data breakpoints.
   */
  public clearAllDataBreakpoints(): void {
    this._breakAddresses.clear()
  }

  // private methods

  private async loadSource(file: string): Promise<void> {
    if (this._sourceFile !== file) {
      this._sourceFile = file
      const contents = await this._fileAccessor.readFile(file)
      this._sourceLines = contents.split(/\r?\n/)
    }
  }

  /**
   * Run through the file.
   * If stepEvent is specified only run a single step and emit the stepEvent.
   */
  private run(reverse = false, stepEvent?: string) {
    if (reverse) {
      for (let ln = this._currentLine - 1; ln >= 0; ln--) {
        if (this.fireEventsForLine(ln, stepEvent)) {
          this._currentLine = ln
          this._currentColumn = undefined
          return
        }
      }
      // no more lines: stop at first line
      this._currentLine = 0
      this._currentColumn = undefined
      this.sendEvent('stopOnEntry')
    } else {
      for (
        let ln = this._currentLine + 1;
        ln < this._sourceLines.length;
        ln++
      ) {
        if (this.fireEventsForLine(ln, stepEvent)) {
          this._currentLine = ln
          this._currentColumn = undefined
          return true
        }
      }
      // no more lines: run to end
      this.sendEvent('end')
    }
  }

  private async verifyBreakpoints(path: string): Promise<void> {
    if (this._noDebug) {
      return
    }

    const bps = this._breakPoints.get(path)
    if (bps) {
      await this.loadSource(path)
      bps.forEach((bp) => {
        if (!bp.verified && bp.line < this._sourceLines.length) {
          const srcLine = this._sourceLines[bp.line].trim()

          // if a line is empty or starts with '+' we don't allow to set a breakpoint but move the breakpoint down
          if (srcLine.length === 0 || srcLine.indexOf('+') === 0) {
            bp.line++
          }
          // if a line starts with '-' we don't allow to set a breakpoint but move the breakpoint up
          if (srcLine.indexOf('-') === 0) {
            bp.line--
          }
          // don't set 'verified' to true if the line contains the word 'lazy'
          // in this case the breakpoint will be verified 'lazy' after hitting it once.
          if (srcLine.indexOf('lazy') < 0) {
            bp.verified = true
            this.sendEvent('breakpointValidated', bp)
          }
        }
      })
    }
  }

  /**
   * Fire events if line has a breakpoint or the word 'exception' or 'exception(...)' is found.
   * Returns true if execution needs to stop.
   */
  private fireEventsForLine(ln: number, stepEvent?: string): boolean {
    if (this._noDebug) {
      return false
    }

    const line = this._sourceLines[ln].trim()

    // if 'log(...)' found in source -> send argument to debug console
    const matches = /log\((.*)\)/.exec(line)
    if (matches && matches.length === 2) {
      this.sendEvent('output', matches[1], this._sourceFile, ln, matches.index)
    }

    // if a word in a line matches a data breakpoint, fire a 'dataBreakpoint' event
    const words = line.split(' ')
    for (const word of words) {
      if (this._breakAddresses.has(word)) {
        this.sendEvent('stopOnDataBreakpoint')
        return true
      }
    }

    // if pattern 'exception(...)' found in source -> throw named exception
    const matches2 = /exception\((.*)\)/.exec(line)
    if (matches2 && matches2.length === 2) {
      const exception = matches2[1].trim()
      if (this._namedException === exception) {
        this.sendEvent('stopOnException', exception)
        return true
      } else {
        if (this._otherExceptions) {
          this.sendEvent('stopOnException', undefined)
          return true
        }
      }
    } else {
      // if word 'exception' found in source -> throw exception
      if (line.indexOf('exception') >= 0) {
        if (this._otherExceptions) {
          this.sendEvent('stopOnException', undefined)
          return true
        }
      }
    }

    // is there a breakpoint?
    const breakpoints = this._breakPoints.get(this._sourceFile)
    if (breakpoints) {
      const bps = breakpoints.filter((bp) => bp.line === ln)
      if (bps.length > 0) {
        // send 'stopped' event
        this.sendEvent('stopOnBreakpoint')

        // the following shows the use of 'breakpoint' events to update properties of a breakpoint in the UI
        // if breakpoint is not yet verified, verify it now and send a 'breakpoint' update event
        if (!bps[0].verified) {
          bps[0].verified = true
          this.sendEvent('breakpointValidated', bps[0])
        }
        return true
      }
    }

    // non-empty line
    if (stepEvent && line.length > 0) {
      this.sendEvent(stepEvent)
      return true
    }

    // nothing interesting found -> continue
    return false
  }

  private sendEvent(event: string, ...args: any[]) {
    setImmediate((_) => {
      this.emit(event, ...args)
    })
  }
}
