// Portions adapted from google/go-jsonnet v0.21.0 (Apache-2.0).
// https://github.com/google/go-jsonnet/tree/67968688d9952f50662e979fab1bef889d0d3e57
// Source: debugger.go
// Upstream attribution: Google Inc. (see NOTICE).
// Modifications: Adapted stepping and breakpoint contracts to synchronous MoonBit callbacks.
// License and retained attribution: LICENSE and NOTICE at the module root.

///|
pub(all) enum DebugStopReason {
  Step
  Breakpoint
  Exception
} derive(Eq, Debug)

///|
pub extend DebugStopReason with Eq::{equal, not_equal}

///|
pub extend DebugStopReason with @debug.Debug::{to_repr}

///|
/// Return a continuation from the stop callback. Finish runs until the current
/// expression returns, then requests a step at the next nonliteral expression.
pub(all) enum DebugCommand {
  Continue
  Step
  Finish
  Terminate
}

///|
pub(all) struct DebugStop {
  reason : DebugStopReason
  context : EvalContext
  outcome : EvalOutcome?
}

///|
/// Synchronous host-controlled debugger. A stop callback may wait for a UI or
/// terminal command before returning. The supplied evaluator retains its normal
/// parameters/import configuration and caches; its previous hook is restored.
pub struct Debugger {
  priv evaluator : Evaluator
  priv breakpoints : Map[String, SourceRange]
  priv mut stepping : Bool
  priv mut finishing : Int?
  priv mut terminated : Bool
  priv mut running : Bool
}

///|
pub fn Debugger::new(evaluator? : Evaluator = Evaluator::new()) -> Debugger {
  {
    evaluator,
    breakpoints: Map([]),
    stepping: false,
    finishing: None,
    terminated: false,
    running: false,
  }
}

///|
/// Independent ranges in lowered semantic traversal order. Nodes without a
/// source range are omitted; repeated ranges from compiler sugar are retained.
pub fn Debugger::breakpoint_locations(program : Program) -> Array[SourceRange] {
  let ranges = []
  let stack = [program.expression]
  while stack.pop() is Some(node) {
    if node.end is Some(end) {
      ranges.push({ begin: node.loc, end, })
    }
    let children = debugger_children(node)
    for i = children.length() - 1; i >= 0; i = i - 1 {
      stack.push(children[i])
    }
  }
  ranges
}

///|
pub fn Debugger::set_breakpoint(
  self : Debugger,
  program : Program,
  line : Int,
  column? : Int,
) -> String raise JsonnetError {
  let ranges = Debugger::breakpoint_locations(program)
  guard ranges.search_by(range => {
      range.begin.line == line &&
      column.map(c => c < 0 || range.begin.column == c).unwrap_or(true)
    })
    is Some(index) else {
    raise problem(
      Runtime,
      program.expression.loc,
      "breakpoint location invalid",
    )
  }
  let range = ranges[index]
  let identity = range.format()
  self.breakpoints[identity] = range
  identity
}

///|
pub fn Debugger::active_breakpoints(self : Debugger) -> Array[String] {
  let result = self.breakpoints.keys().collect()
  result.sort_by(compare_unicode)
  result
}

///|
pub fn Debugger::clear_breakpoints(self : Debugger, filename : String) -> Unit {
  let remove = self.breakpoints
    .keys()
    .filter(key => self.breakpoints[key].begin.file == filename)
    .collect()
  for key in remove {
    ignore(self.breakpoints.remove(key))
  }
}

///|
fn debugger_stoppable(ctx : EvalContext) -> Bool {
  if ctx.source_range() is None {
    return false
  }
  match ctx.expression.kind {
    Literal(_) | NumberLiteral(_) | StandardFunction(_) | StandardValue(_) =>
      false
    _ => true
  }
}

///|
fn Debugger::continue_from(
  self : Debugger,
  stop : DebugStop,
  callback : (DebugStop) -> DebugCommand raise,
) -> Unit raise {
  match callback(stop) {
    Continue => self.stepping = false
    Step => self.stepping = true
    Finish => {
      self.stepping = false
      self.finishing = Some(stop.context.serial)
    }
    Terminate => {
      self.terminated = true
      raise problem(Runtime, stop.context.location(), "debugger terminated")
    }
  }
}

///|
/// Evaluate text while servicing breakpoints, stepping and exception stops.
/// Live contexts/values expire when the callback returns. Metadata and copied
/// nodes can be retained. Reentrant runs/configuration changes are rejected.
pub fn Debugger::run(
  self : Debugger,
  program : Program,
  on_stop~ : (DebugStop) -> DebugCommand raise,
  start_with_step? : Bool = false,
) -> String raise JsonnetError {
  if self.running {
    raise problem(
      Runtime,
      program.expression.loc,
      "debugger is already executing",
    )
  }
  self.evaluator.idle()
  self.running = true
  self.stepping = start_with_step
  self.finishing = None
  self.terminated = false
  let mut entered_source = false
  let previous = self.evaluator.eval_hook
  defer {
    self.evaluator.eval_hook = previous
    self.running = false
  }
  self.evaluator.set_eval_hook(hook={
    before: ctx => {
      if ctx.location().file != "" {
        entered_source = true
      }
      if entered_source && !self.terminated && debugger_stoppable(ctx) {
        if self.stepping && self.finishing is None {
          self.stepping = false
          self.continue_from(
            { reason: Step, context: ctx, outcome: None, },
            on_stop,
          )
        } else if ctx.source_range() is Some(range) &&
          self.breakpoints.contains(range.format()) {
          self.continue_from(
            { reason: Breakpoint, context: ctx, outcome: None, },
            on_stop,
          )
        }
      }
    },
    after: (ctx, outcome) => {
      if !self.terminated && outcome is Failure(_) {
        self.continue_from(
          { reason: Exception, context: ctx, outcome: Some(outcome), },
          on_stop,
        )
      }
      if self.finishing == Some(ctx.serial) {
        self.finishing = None
        self.stepping = true
      }
    },
  })
  self.evaluator.evaluate_text(program)
}