// Portions adapted from google/go-jsonnet v0.21.0 (Apache-2.0).
// https://github.com/google/go-jsonnet/tree/67968688d9952f50662e979fab1bef889d0d3e57
// Source: linter/internal/variables/find_variables.go
// Source: linter/internal/traversal/traversal.go
// Source: internal/parser/context.go
// Copyright 2017 Google Inc. All rights reserved.
// Modifications: Ported lexical-use and eager-cycle analysis to MoonBit ASTs with bounded traversal.
// License and retained attribution: LICENSE and NOTICE at the module root.

///|
priv struct LintBinding {
  name : String
  loc : Location
  warn : Bool
  mut used : Bool
}

///|
priv struct LintScan {
  limits : Limits
  mut steps : Int
  bindings : Array[LintBinding]
  diagnostics : Array[Diagnostic]
}

///|
fn LintScan::tick(self : LintScan, e : Expr) -> Unit raise JsonnetError {
  self.steps += 1
  if self.steps > self.limits.steps {
    raise problem(Limit, e.loc, "lint step limit exceeded")
  }
}

///|
fn LintScan::report(self : LintScan, loc : Location, message : String) -> Unit {
  self.diagnostics.push({
    kind: Static,
    location: loc,
    message,
    context: [],
    trace: [],
  })
}

///|
fn LintScan::bind(
  self : LintScan,
  scope : Map[String, Int],
  name : String,
  loc : Location,
  warn : Bool,
) -> Unit {
  scope[name] = self.bindings.length()
  self.bindings.push({ name, loc, warn, used: false, })
}

///|
fn LintScan::locals(
  self : LintScan,
  bs : Array[ExprBinding],
  scope : Map[String, Int],
) -> Map[String, Int] raise JsonnetError {
  let inner = scope.copy()
  for b in bs {
    let loc = b.range
      .map(r => r.begin)
      .unwrap_or({ file: "", line: 0, column: 0, })
    self.bind(inner, b.name, loc, true)
  }
  for { value: e, .. } in bs {
    self.variables(e, inner)
  }
  inner
}

///|
fn LintScan::clauses(
  self : LintScan,
  cs : Array[Clause],
  scope : Map[String, Int],
) -> Map[String, Int] raise JsonnetError {
  let inner = scope.copy()
  for c in cs {
    match c {
      For(n, e) => {
        self.variables(e, inner)
        self.bind(inner, n, e.loc, false)
      }
      When(e) => self.variables(e, inner)
    }
  }
  inner
}

///|
fn LintScan::variables(
  self : LintScan,
  e : Expr,
  scope : Map[String, Int],
) -> Unit raise JsonnetError {
  self.tick(e)
  match e.kind {
    Var(n) => if scope.get(n) is Some(id) { self.bindings[id].used = true }
    Local(bs, body) => self.variables(body, self.locals(bs, scope))
    Func(ps, body) => {
      let inner = scope.copy()
      for { name: n, .. } in ps {
        self.bind(inner, n, e.loc, false)
      }
      for { default_value: x, .. } in ps {
        if x is Some(x) {
          self.variables(x, inner)
        }
      }
      self.variables(body, inner)
    }
    Obj(fs, bs, checks, _) => {
      let inner = self.locals(bs, scope)
      for (a, b) in checks {
        self.variables(a, inner)
        if b is Some(b) {
          self.variables(b, inner)
        }
      }
      for f in fs {
        self.variables(f.value, inner)
        self.variables(f.key, scope)
      }
    }
    ArrayComp(body, cs) => self.variables(body, self.clauses(cs, scope))
    ObjectComp(key, body, cs, bs) => {
      let outer = self.clauses(cs, scope)
      self.variables(key, outer)
      self.variables(body, self.locals(bs, outer))
    }
    _ =>
      for child in lint_children(e) {
        self.variables(child, scope)
      }
  }
}

///|
fn LintScan::looping(
  self : LintScan,
  e : Expr,
  vars : Map[String, Expr],
  runs : Map[String, Int],
  run : Int,
) -> Bool raise JsonnetError {
  let pending = [e]
  while pending.pop() is Some(e) {
    self.tick(e)
    if e.kind is Var(n) && vars.get(n) is Some(body) {
      match runs.get(n) {
        Some(first) =>
          if first == run {
            self.report(e.loc, "Endless loop in local definition")
            return true
          }
        None => {
          runs[n] = run
          pending.push(body)
          continue
        }
      }
    }
    let children = lint_children(e, direct=true)
    for i = children.length() - 1; i >= 0; i = i - 1 {
      pending.push(children[i])
    }
  }
  false
}

///|
fn LintScan::loops(self : LintScan, e : Expr) -> Unit raise JsonnetError {
  self.tick(e)
  if e.kind is Local(bs, _) {
    let vars = Map::from_array(bs.map(b => (b.name, b.value)))
    let runs = Map([])
    for i, { value: body, .. } in bs {
      if self.looping(body, vars, runs, i) {
        break
      }
    }
  }
  for child in lint_children(e) {
    self.loops(child)
  }
}

///|
/// Check snippets without execution. Returns parse/static/import findings,
/// unused bindings, eager cycles, and conservative inferred-type findings for
/// calls, arguments, indexing and unary operators. No Jsonnet code is executed.
/// Imports are checked recursively, but only entry snippets receive lint checks.
/// Limits abort analysis with JsonnetError instead of returning a partial result.
///
/// ```mbt check
/// test {
///   let issues = @moonjsonnet.lint([{ id: "entry", text: "local unused=1;42", }])
///   assert_eq(issues[0].message, "Unused variable: unused")
/// }
/// ```
pub fn lint(
  snippets : Array[Source],
  resolver? : (String, String) -> BinarySource raise,
  limits? : Limits = Limits::default(),
) -> Array[Diagnostic] raise JsonnetError {
  let loc : Location = { file: "", line: 1, column: 1, }
  check_limits(limits, loc)
  let scan : LintScan = { limits, steps: 0, bindings: [], diagnostics: [], }
  let entries : Array[(String, Expr)] = []
  let roots : Map[String, Expr] = Map([])
  let imported : Map[(String, String), String] = Map([])
  for source in snippets {
    try {
      let program = Program::parse(source.text, filename=source.id, limits~)
      entries.push((source.id, program.expression))
      roots[source.id] = program.expression
    } catch {
      Error(d) =>
        if d.kind == Limit {
          raise Error(d)
        } else {
          scan.diagnostics.push(d)
        }
    }
  }
  let evaluator = Evaluator::new()
  evaluator.set_limits(limits)
  evaluator.set_importer(binary_resolver?=resolver)
  let requests : Map[(String, String), Bool] = Map([])
  let mut bytes = 0
  // Iterative import traversal terminates cycles by resolved identity, including
  // paths already supplied as roots. Raw imports never undergo UTF-8 decoding.
  let pending = []
  for i = entries.length() - 1; i >= 0; i = i - 1 {
    pending.push(entries[i])
  }
  while pending.pop() is Some((from, e)) {
    scan.tick(e)
    let request : (String, Bool)? = match e.kind {
      ImportFile(path, string) => Some((path, !string))
      ImportBinary(path) => Some((path, false))
      _ => None
    }
    if request is Some((path, code)) {
      try {
        let source = evaluator.import_data(from, path)
        if !requests.contains((from, path)) {
          requests[(from, path)] = true
          bytes += source.bytes.length()
          if requests.length() > limits.imports || bytes > limits.import_bytes {
            raise problem(Limit, e.loc, "lint import limit exceeded")
          }
        }
        if code {
          let (program, id) = evaluator.import_program(from, path)
          imported[(from, path)] = id
          if !roots.contains(id) {
            roots[id] = program.expression
            pending.push((id, program.expression))
          }
        }
      } catch {
        Error(d) =>
          if d.kind == Limit {
            raise Error(d)
          } else {
            scan.report(e.loc, d.message)
          }
      }
    } else {
      let children = lint_children(e)
      for i = children.length() - 1; i >= 0; i = i - 1 {
        pending.push((from, children[i]))
      }
    }
  }
  let graph = LintGraph::new(scan, imported)
  for name, _ in roots {
    graph.roots[name] = graph.add()
  }
  let typed : Map[String, LintTyped] = Map([])
  for name, e in roots {
    typed[name] = graph.build(e, Map([("std", 11)]), name, id=graph.roots[name])
  }
  // Go keys inference roots by filename: only the last entry at a path has
  // inferred types. Earlier entries still receive checks on their own syntax,
  // using the empty type bound rather than the later entry's expression tree.
  let last : Map[String, Int] = Map([])
  for i, (name, _) in entries {
    last[name] = i
  }
  let checks = []
  for i, (name, e) in entries {
    if last[name] == i {
      checks.push((typed[name], false))
    } else {
      checks.push((graph.build(e, Map([("std", 11)]), name), true))
    }
  }
  graph.solve()
  for i, (_, e) in entries {
    scan.bindings.clear()
    scan.variables(e, Map([]))
    for b in scan.bindings {
      if b.warn && !b.used && b.name != "$" {
        scan.report(b.loc, "Unused variable: " + b.name)
      }
    }
    let (node, untyped) = checks[i]
    graph.check(node, untyped~)
    scan.loops(e)
  }
  scan.diagnostics
}