///|
enum Token {
  Word(String)
  Quoted(String)
  Mark(String)
} derive(Eq)

///|
/// Lex Moon manifest scalars and balanced containers. Strings use JSON escapes;
/// comments never contribute metadata. This deliberately does not evaluate expressions.
fn manifest_tokens(text : String) -> Array[Token] raise {
  let out = []
  let mut i = 0
  while i < text.length() {
    let c = text.code_unit_at(i).to_int()
    if c == 32 || c == 9 || c == 10 || c == 13 {
      i += 1
      continue
    }
    if text[i:].has_prefix("//") || c == 35 {
      while i < text.length() && text.code_unit_at(i).to_int() != 10 {
        i += 1
      }
      continue
    }
    if text[i:].has_prefix("/*") {
      let mut nesting = 1
      i += 2
      while i < text.length() && nesting > 0 {
        if text[i:].has_prefix("/*") {
          nesting += 1
          i += 2
        } else if text[i:].has_prefix("*/") {
          nesting -= 1
          i += 2
        } else {
          i += 1
        }
      }
      if nesting != 0 {
        invalid("unterminated manifest comment")
      }
      continue
    }
    if c == 34 {
      let start = i
      i += 1
      let mut closed = false
      while i < text.length() {
        if text.code_unit_at(i).to_int() == 92 {
          i += 2
          continue
        }
        if text.code_unit_at(i).to_int() == 34 {
          i += 1
          closed = true
          break
        }
        i += 1
      }
      if !closed {
        invalid("unterminated manifest string")
      }
      out.push(Quoted(str(@json.parse(text[start:i]))))
      continue
    }
    if "{}[](),=:@".contains(text[i:i + 1].to_owned()) {
      out.push(Mark(text[i:i + 1].to_owned()))
      i += 1
      continue
    }
    let start = i
    while i < text.length() {
      let c = text.code_unit_at(i).to_int()
      if c <= 32 || "{}[](),=:@\"".contains(text[i:i + 1].to_owned()) {
        break
      }
      i += 1
    }
    if i == start {
      invalid("unsupported manifest token")
    }
    out.push(Word(text[start:i].to_owned()))
  }
  out
}

///|
fn metadata(text : String) -> Json raise {
  let ts = manifest_tokens(text)
  let m : Map[String, Json] = Map([])
  let mut depth = 0
  for i in 0.. depth += 1
      Mark("}" | "]" | ")") => depth -= 1
      Word("path") => invalid("local path dependencies are unsupported")
      Word(key) if depth == 0 && i + 2 < ts.length() && ts[i + 1] == Mark("=") =>
        if [
            "name", "version", "description", "license", "repository", "readme",
            "private",
          ].contains(key) {
          if m.contains(key) {
            invalid("duplicate module metadata: " + key)
          }
          let value = match ts[i + 2] {
            Quoted(s) => Json::string(s)
            Word("true") => Json::boolean(true)
            Word("false") => Json::boolean(false)
            _ => invalid("unsupported metadata expression for " + key)
          }
          if i + 3 < ts.length() {
            match ts[i + 3] {
              Word(next) if (i + 4 < ts.length() && ts[i + 4] == Mark("=")) ||
                ["import", "options", "targets"].contains(next) => ()
              _ => invalid("unsupported metadata expression for " + key)
            }
          }
          if key == "private" {
            if value != true && value != false {
              invalid("private metadata must be boolean")
            }
          } else if !(value is String(_)) {
            invalid("metadata must be a string: " + key)
          }
          m[key] = value
        }
      _ => ()
    }
  }
  if depth != 0 {
    invalid("unbalanced manifest containers")
  }
  Json::object(m)
}

///|
fn valid_name(name : String, npm : Bool) -> Bool {
  if name.is_empty() || name.length() > 214 {
    return false
  }
  let body = if npm && name.has_prefix("@") {
    name[1:].to_owned()
  } else {
    name
  }
  let parts = body.split("/").map(x => x.to_owned()).collect()
  if (!npm && parts.length() != 2) ||
    (npm && parts.length() != (if name.has_prefix("@") { 2 } else { 1 })) {
    return false
  }
  for part in parts {
    if part.is_empty() || part.has_prefix(".") || part.has_prefix("_") {
      return false
    }
    for c in part.to_array() {
      if !((c >= 'a' && c <= 'z') ||
        (c >= '0' && c <= '9') ||
        c == '-' ||
        c == '_' ||
        c == '.') {
        return false
      }
    }
  }
  true
}

///|
fn semver(value : String) -> Bool {
  let parts = value.split("+").map(x => x.to_owned()).collect()
  if parts.length() > 2 {
    return false
  }
  let base = parts[0].split("-").map(x => x.to_owned()).collect()
  let nums = base[0].split(".").map(x => x.to_owned()).collect()
  if nums.length() != 3 {
    return false
  }
  for n in nums {
    if n.is_empty() || (n.length() > 1 && n.has_prefix("0")) {
      return false
    }
    for c in n.to_array() {
      if c < '0' || c > '9' {
        return false
      }
    }
  }
  if base.length() > 1 && !identifiers(base[1:].join("-"), true) {
    return false
  }
  if parts.length() == 2 && !identifiers(parts[1], false) {
    return false
  }
  true
}

///|
fn identifiers(s : String, prerelease : Bool) -> Bool {
  for p in s.split(".") {
    if p.is_empty() {
      return false
    }
    let mut numeric = true
    for c in p.to_owned().to_array() {
      if !(c >= '0' && c <= '9') {
        numeric = false
      }
      if !((c >= 'a' && c <= 'z') ||
        (c >= 'A' && c <= 'Z') ||
        (c >= '0' && c <= '9') ||
        c == '-') {
        return false
      }
    }
    if prerelease && numeric && p.length() > 1 && p.has_prefix("0") {
      return false
    }
  }
  true
}

///|
struct Entry {
  pkg : String
  wrapper : String
  declaration : Json
  target : String
  loader : Json
}

///|
struct Config {
  raw : Json
  config_path : String
  root : String
  meta : Json
  npm : Json
  entries : Array[Entry]
  assets : Array[Json]
  checks : Json
  output : String
}

///|
async fn load_config(path : String) -> Config {
  let config_path = @fs.realpath(absolute(path))
  let raw = read_json(config_path)
  keys(raw, [
    "schemaVersion", "module", "npm", "entries", "assets", "checks", "output",
  ])
  if field(raw, "schemaVersion") != 1 {
    invalid("pubman schemaVersion must be 1")
  }
  if field(raw, "module") == Json::null() &&
    @fs.exists(parent(config_path) + "/moon.work") {
    invalid(
      "module selection is ambiguous in a workspace; set module explicitly",
    )
  }
  let root = @fs.realpath(
    parent(config_path) + "/" + opt_string(raw, "module", "."),
  )
  let meta = metadata(@fs.read_file(root + "/moon.mod").text())
  let name = string_field(meta, "name")
  let v = string_field(meta, "version")
  if !valid_name(name, false) || !semver(v) {
    invalid("invalid MoonBit name/version")
  }
  if v.contains("+") {
    invalid("release versions cannot contain build metadata; npm removes it")
  }
  if field(meta, "private") == true {
    invalid("private modules cannot be published")
  }
  let npm = field(raw, "npm")
  keys(npm, ["name", "version", "description", "access", "dependencies", "tag"])
  if !valid_name(string_field(npm, "name"), true) {
    invalid("invalid npm package name")
  }
  if opt_string(npm, "version", v) != v {
    invalid(
      "npm version " +
      string_field(npm, "version") +
      " conflicts with module " +
      v,
    )
  }
  if string_field(npm, "access") != "public" {
    invalid("only public npm access is supported")
  }
  if field(npm, "dependencies") is Object(deps) {
    for name, v in deps {
      if !valid_name(name, true) || !semver(str(v)) {
        invalid(
          "runtime dependencies require public npm names and exact semver versions",
        )
      }
    }
  } else if field(npm, "dependencies") != Json::null() {
    invalid("dependencies must be an object")
  }
  let exports = field(raw, "entries")
  keys(exports, ["js", "wasm"])
  let entries = []
  for backend in ["js", "wasm"] {
    let j = field(exports, backend)
    keys(j, ["package", "wrapper", "declaration", "target", "loader"])
    let target = if backend == "js" { "js" } else { "wasm-gc" }
    if opt_string(j, "target", target) != target {
      invalid("supported targets are js and wasm-gc")
    }
    let pkg = string_field(j, "package")
    safe_relative(pkg)
    ignore(contained(root, pkg + "/moon.pkg"))
    let wrapper = string_field(j, "wrapper")
    ignore(contained(root, wrapper))
    let decl = field(j, "declaration")
    match decl {
      String(p) => {
        if !p.has_suffix(".d.ts") {
          invalid("declaration must end with .d.ts")
        }
        ignore(contained(root, p))
      }
      Object(_) => {
        keys(decl, ["backend", "path"])
        if string_field(decl, "backend") != target {
          invalid("compiler declaration backend mismatch")
        }
        let p = string_field(decl, "path")
        safe_relative(p)
        if !p.has_suffix(".d.ts") {
          invalid("compiler declaration must end with .d.ts")
        }
      }
      _ => invalid("missing " + backend + " declaration")
    }
    let loader = if field(j, "loader") == Json::null() {
      Json::empty_object()
    } else {
      field(j, "loader")
    }
    keys(loader, ["builtins", "importedStringConstants"])
    if field(loader, "importedStringConstants") != Json::null() {
      ignore(string_field(loader, "importedStringConstants"))
    }
    if field(loader, "builtins") != Json::null() {
      for b in strings(field(loader, "builtins")) {
        if b != "js-string" {
          invalid("unsupported Wasm builtin")
        }
      }
    }
    entries.push({ pkg, wrapper, declaration: decl, target, loader, })
  }
  let assets = array(field(raw, "assets"))
  let destinations = [
    "package.json", "js/index.mjs", "js/index.d.ts", "wasm/index.mjs", "wasm/index.d.ts",
    "internal/js/library.js", "internal/wasm/library.wasm", "internal/wasm/loader.mjs",
  ]
  for a in assets {
    keys(a, ["source", "destination"])
    ignore(contained(root, string_field(a, "source")))
    let dest = string_field(a, "destination")
    safe_relative(dest)
    for prior in destinations {
      if dest == prior || inside(dest, prior) || inside(prior, dest) {
        invalid("package destination collision: " + dest)
      }
    }
    if dest.has_prefix("internal/") {
      invalid("internal destinations are reserved")
    }
    destinations.push(dest)
  }
  let checks = field(raw, "checks")
  keys(checks, ["node", "browser", "types"])
  for kind in ["node", "browser", "types"] {
    let files = strings(field(checks, kind))
    if files.is_empty() {
      invalid("at least one " + kind + " consumer check is required")
    }
    for f in files {
      ignore(contained(root, f))
    }
  }
  let output_rel = opt_string(raw, "output", "_build/pubman/release")
  safe_relative(output_rel)
  let output = future_path(parent(config_path) + "/" + output_rel)
  // Outputs may live under _build, but must never contain selected source inputs.
  if output == root || inside(output, root) || output == parent(config_path) {
    invalid("output overlaps module")
  }
  if inside(root, output) && !inside(root + "/_build", output) {
    invalid("output within module must be under _build")
  }
  if @fs.exists(output) &&
    (
      !@fs.exists(output + "/.pubman-owned") ||
      @fs.read_file(output + "/.pubman-owned").text() != "pubman bundle v1\n"
    ) {
    invalid("output directory is not owned by pubman")
  }
  let input_paths = [config_path, root + "/moon.mod"]
  for e in entries {
    input_paths.push(contained(root, e.wrapper))
    if e.declaration is String(p) {
      input_paths.push(contained(root, p))
    }
  }
  for a in assets {
    input_paths.push(contained(root, string_field(a, "source")))
  }
  for k in ["node", "browser", "types"] {
    for p in strings(field(checks, k)) {
      input_paths.push(contained(root, p))
    }
  }
  if input_paths.any(p => p == output || inside(output, p)) {
    invalid("output overlaps source inputs")
  }
  validate_source_tree(root, root)
  { raw, config_path, root, meta, npm, entries, assets, checks, output, }
}

///|
fn Config::plan(self : Config) -> Json {
  {
    "module": self.root,
    "moon": self.meta,
    "npm": self.npm,
    "version": field(self.meta, "version"),
    "resolvedEntries": Json::array(
      self.entries.map(e => {
        "backend": e.target,
        "package": self.root + "/" + e.pkg,
        "wrapper": self.root + "/" + e.wrapper,
        "declaration": match e.declaration {
          String(p) => { "kind": "author", "path": self.root + "/" + p }
          _ =>
            {
              "kind": "compiler",
              "backend": e.target,
              "path": field(e.declaration, "path"),
            }
        },
      }),
    ),
    "entries": field(self.raw, "entries"),
    "assets": Json::array(self.assets),
    "checks": self.checks,
    "output": self.output,
    "tools": ["moon", "node", "npm", "tsc", "playwright", "unzip", "zip", "tar"],
    "operations": json_strings(operations("pack")),
  }
}

///|
async fn validate_source_tree(root : String, dir : String) -> Unit {
  for name in @fs.readdir(dir, include_hidden=true) {
    if ["_build", ".mooncakes", ".git", "node_modules"].contains(name) {
      continue
    }
    let path = dir + "/" + name
    match @fs.kind(path, follow_symlink=false) {
      SymLink => {
        let real = @fs.realpath(path)
        if !inside(root, real) {
          invalid("source symlink escapes module: " + path)
        }
        if @fs.kind(path) == Directory {
          invalid("source directory symlinks are unsupported")
        }
      }
      Directory => validate_source_tree(root, path)
      Regular => ()
      _ => invalid("non-regular source input: " + path)
    }
  }
}