// JSON bridge entry: the DeepSeek Harness plugin (dsh-plugin-moonbit-static-analysis)
// spawns this program under Node, passes ONE argument — a JSON request — and
// reads a JSON reply from stdout. Request kinds:
//   { "kind": "program", "source": "...", "filename": "main.mbt" }
//   { "kind": "file",    "source": "...", "filename": "doc.mbt.md" }   (extension dispatch)
//   { "kind": "machine", "spec": { MachineSpec fields } }
// The bridge keeps the plugin a pure spawner: all analyzer semantics stay in
// MoonBit, versioned and gated with the module itself.

///|
/// one finding, flattened for JSON
///
/// 0507. Two fields beside `severity`:
///   * `confidence` -- how much the lens CHECKED. The severity says how bad the
///     finding is if it is real; the confidence says how sure the analyzer is
///     that it is real, and the two are independent.
///   * `family_id` -- the family's STABLE id. `family` is the public NAME, and
///     a name is a second vocabulary that can be renamed without anybody
///     noticing; the id cannot be renumbered, so a consumer can pin it.
struct Finding {
  line : Int32
  col : Int32
  severity : String
  confidence : String
  message : String
  family : String
  family_id : String
  lenses : Array[String]
  vst : Array[FrameRep]
} derive(ToJson)

///|
/// one virtual-stack frame
struct FrameRep {
  func : String
  file : String
  line : Int32
} derive(ToJson)

///|
/// the reply envelope
struct Reply {
  ok : Bool
  error : String?
  count : Int32
  findings : Array[Finding]
  rendered : String
} derive(ToJson)

///|
/// machine tables, mirroring @statecheck.MachineSpec in wire form; every
/// field is optional and defaulted at conversion
struct SpecReq {
  name : String?
  states : Array[String]?
  initial : String?
  terminal : String?
  transitions : Array[(String, String, String)]?
  trigger_slots : Array[(String, String)]?
  slot_names : Array[String]?
  blocks : Array[(String, String, String)]?
  course : Array[String]?
} derive(FromJson)

///|
struct Request {
  kind : String?
  source : String?
  filename : String?
  spec : SpecReq?
} derive(FromJson)

///|
fn to_finding(rep : @report.Report) -> Finding {
  // The SAME label the text renderer uses. A JSON consumer that reads
  // `severity: "error"` for a declared scope limit is being told the opposite
  // of what the text report told the same reader, and a machine-readable
  // channel that disagrees with the human one is the worse of the two.
  let severity = rep.severity_label()
  let vst = rep.vst.map(frame => {
    func: frame.func,
    file: frame.file,
    line: frame.line,
  })
  {
    line: rep.span.start.line,
    col: rep.span.start.col,
    severity,
    confidence: rep.confidence().name(),
    message: rep.message,
    family: rep.family.name(),
    family_id: rep.family.id(),
    lenses: rep.lenses.map(lens => lens.name()),
    vst,
  }
}

///|
fn ok_reply(findings : Array[@report.Report], rendered : String) -> Reply {
  let items = findings.map(to_finding)
  { ok: true, error: None, count: items.length(), findings: items, rendered, }
}

///|
fn err_reply(msg : String) -> Reply {
  { ok: false, error: Some(msg), count: 0, findings: [], rendered: "", }
}

///|
fn reply_string(reply : Reply) -> String {
  @json.to_json(reply).stringify()
}

///|
/// the request JSON is the argument that starts with '{' (runner args before
/// it are ignored)
fn find_request_arg(args : Array[String]) -> String? {
  for s in args {
    if s.trim_start().has_prefix("{") {
      return Some(s)
    }
  }
  None
}

///|
fn to_spec(req : SpecReq) -> @statecheck.MachineSpec {
  let name = req.name.unwrap_or("machine")
  let states = req.states.unwrap_or([])
  let initial = req.initial.unwrap_or("")
  let terminal = req.terminal.unwrap_or("")
  let transitions = req.transitions.unwrap_or([])
  let trigger_slots = req.trigger_slots.unwrap_or([])
  let slot_names = req.slot_names.unwrap_or([])
  let blocks = req.blocks.unwrap_or([])
  let course = req.course.unwrap_or([])
  {
    name,
    states,
    initial,
    terminal,
    transitions,
    trigger_slots,
    slot_names,
    blocks,
    course,
  }
}

///|
fn run_machine(req : SpecReq) -> Reply {
  let spec = to_spec(req)
  ok_reply(@statecheck.audit(spec), @statecheck.render(spec))
}

///|
fn run_program(source : String, filename : String) -> Reply {
  let result = @pipeline.run(source, filename, @core.IfaceSymTab::empty())
  ok_reply(result.reports, @pipeline.render_result(result))
}

///|
/// kind:"file" — dispatch on the filename's extension (the taxonomy lives in
/// the module root EXTENSIONS.md):
///   .mbt / .mbtx  → program three-inspection (.mbtx may carry an import block)
///   .md / .mbt.md → literate MoonBit (moonbit fences, real line numbers)
///   .mbti         → interface well-formedness audit
///   .mbtp         → proof-file logic-side audit
fn dispatch_file(filename : String, source : String) -> Reply {
  let lower = filename.to_lower()
  let kind = if lower.has_suffix(".mbt.md") || lower.has_suffix(".md") {
    "md"
  } else if lower.has_suffix(".mbti") {
    "iface"
  } else if lower.has_suffix(".mbtp") {
    "proof"
  } else {
    "program"
  }
  match kind {
    "md" => {
      let r = @moonfiles.literate(source, filename)
      ok_reply(r.reports, @pipeline.render_result(r))
    }
    "iface" => {
      // one file in, no module context: the empty table is the honest one —
      // a name is only resolvable when this file declares it
      let reports = @moonfiles.iface(
        source,
        filename,
        @core.IfaceSymTab::empty(),
      )
      ok_reply(reports, render_reports(reports))
    }
    "proof" => {
      let reports = @moonfiles.proof(source, filename)
      ok_reply(reports, render_reports(reports))
    }
    _ => run_program(source, filename)
  }
}

///|
/// render a bare report list without signatures (iface/proof audits)
fn render_reports(reports : Array[@report.Report]) -> String {
  let buf = StringBuilder()
  for rep in reports {
    buf.write_string(rep.render())
  }
  buf.write_string("\nSummary: \{reports.length()} finding(s)\n")
  buf.to_string()
}

///|
fn handle(raw : String) -> Reply {
  try {
    let request : Request = @json.from_json(@json.parse(raw))
    let kind = request.kind.unwrap_or("program")
    match kind {
      "program" =>
        run_program(
          request.source.unwrap_or(""),
          request.filename.unwrap_or("main.mbt"),
        )
      "file" =>
        dispatch_file(
          request.filename.unwrap_or("main.mbt"),
          request.source.unwrap_or(""),
        )
      "machine" =>
        match request.spec {
          None => err_reply("machine request needs a spec object")
          Some(spec) => run_machine(spec)
        }
      other => err_reply("unknown request kind '\{other}'")
    }
  } catch {
    err => err_reply("bad request: \{err.to_string()}")
  }
}

///|
/// `--file `: read the request JSON from a file instead of argv.
///
/// Windows caps the WHOLE command line at 32,767 characters, not each
/// argument, so a request is unusable once the source passes roughly 30 KB:
/// the spawn fails with ENAMETOOLONG before this program starts, and it throws
/// synchronously rather than arriving as an 'error' event. In moonbit_doubleML
/// 27 of 180 files are over 30 KB.
///
/// Splitting the request across several argv entries was tried and does not
/// help -- the cap is on the total, not per argument. A path is short, so the
/// caller writes the request to a file and passes the path.
///
/// This needs file IO, so it puts the package on the wasm/native side of the
/// fence: the js backend has no async runtime and cannot read a file.
async fn file_request(args : Array[String]) -> String? {
  // Not a fixed index: under `moonrun  --file ` the runner's own
  // arguments come first, which is exactly why the inline form scans for the
  // request instead of taking args[0].
  let mut i = 0
  while i + 1 < args.length() {
    if args[i] == "--file" {
      let data = @fs.read_file(args[i + 1]) catch { _ => return None }
      let text = data.text() catch { _ => return None }
      return Some(text)
    }
    i += 1
  }
  None
}

///|
async fn main {
  let args : Array[String] = @env.args()
  let raw = match file_request(args) {
    Some(r) => Some(r)
    None => find_request_arg(args)
  }
  match raw {
    None =>
      println(
        reply_string(
          err_reply(
            "missing request (pass a JSON object, or --file  for sources too large for the 32767-character command line)",
          ),
        ),
      )
    Some(r) => println(reply_string(handle(r)))
  }
}