///|
/// Vendor a single TypeScript package's types into the consumer's
/// `/internal/generated//` sub-package.
///
/// `pkg_spec` is an npm-style specifier (`hono`, `@types/react`,
/// `@scope/foo`). The resolver walks the consumer's `node_modules` to
/// find the type entrypoint, generates a MoonBit bridge package, and
/// writes it under `/internal/generated//`.
///
/// `module_spec_override` overrides the runtime import the generated
/// `bridge.js` uses. Defaults to `pkg_spec` (or, for `@types/`
/// inputs, the unscoped runtime name).
///
/// `vendor_root_override` overrides the output root. Defaults to
/// `/internal/generated`.
pub async fn ts2mbt_vendor_package(
  pkg_spec : String,
  module_spec_override? : String? = None,
  vendor_root_override? : String? = None,
  print_import_hint? : Bool = true,
) -> Bool {
  let vendor_root = match vendor_root_override {
    Some(root) => root
    None =>
      match resolve_default_vendor_root() {
        Some(root) => root
        None => {
          println(
            "vendor: could not resolve `/internal/generated`; run inside a moon module or pass --out ",
          )
          return false
        }
      }
  }
  let entry_path = match
    @parser.resolve_type_module_specifier("__tsmbt_entry__.ts", pkg_spec) {
    Some(path) => path
    None => {
      println(
        "vendor: could not resolve TypeScript types for '\{pkg_spec}'; check that it's installed in node_modules",
      )
      return false
    }
  }
  let runtime_spec = match module_spec_override {
    Some(spec) => spec
    None => default_runtime_module_spec(pkg_spec)
  }
  let safe_dir = vendor_safe_pkg_dir(pkg_spec)
  let output_dir = main_join_path(vendor_root, safe_dir)
  let bare_specifier = "#tsmbt-bridge/\{safe_dir}"
  println(
    "vendor: \{pkg_spec} -> \{output_dir} (#module \"\{bare_specifier}\")",
  )
  // `emit_moonbit_scaffold_from_ts` writes `bridge.{mbti,mbt,js}`,
  // `package.json`, `moon.pkg.json`. The bare specifier baked into
  // `bridge.mbt` is a Node subpath import (`#tsmbt-bridge/`)
  // resolved by the consumer's `package.json#imports` map — see
  // `ensure_consumer_package_json_imports_entry` below. This avoids
  // the manual `node_modules/@tsmbt-bridge/` symlink that
  // `pnpm install` would otherwise wipe.
  if !emit_moonbit_scaffold_from_ts(
      entry_path,
      runtime_spec,
      output_dir,
      bare_module_specifier=Some(bare_specifier),
    ) {
    return false
  }
  let _ = ensure_vendor_root_guardrails(vendor_root)
  if print_import_hint {
    print_vendor_import_hint(safe_dir)
    print_vendor_package_json_imports_hint(vendor_root)
    print_vendor_module_type_hint()
  }
  true
}

///|
/// Drop a `.gitignore` and `AGENTS.md` at the vendor root so AI agents
/// (Claude / Cursor / Aider / etc.) know not to hand-edit the
/// generated files, and so consumers can leave the directory out of
/// version control with a single ignore rule. Both files are
/// idempotent — overwriting on every vendor run keeps them current.
async fn ensure_vendor_root_guardrails(vendor_root : String) -> Bool {
  let _ = ensure_dir_tree(vendor_root) catch { _ => return false }
  let gitignore_body = "# AUTO-GENERATED by `ts2mbt` (mizchi/ts). DO NOT EDIT.\n# Regenerate via `ts2mbt vendor ` or `ts2mbt sync`.\n*\n!.gitignore\n!AGENTS.md\n"
  let gitignore_path = main_join_path(vendor_root, ".gitignore")
  let _ = @fs.write_file(
    gitignore_path,
    string_to_bytes(gitignore_body),
    create=0o644,
  ) catch {
    _ => ()
  }
  let agents_body = "# Auto-generated TypeScript bridges\n\nFiles under this directory are generated by `ts2mbt vendor` / `ts2mbt sync`\nfrom upstream `.d.ts` declarations. Do not edit them by hand -- your\nchanges will be lost on the next vendor run.\n\nTo regenerate:\n\n```\nts2mbt vendor       # one package\nts2mbt sync              # everything in package.json\n```\n\nIf the upstream typings change, re-run the same command. The runtime\n`bridge.js` files resolve through the consumer's\n`package.json#imports` map under the `#tsmbt-bridge/*` wildcard;\n`pnpm install` / `npm install` cannot wipe that mapping the way it\nwould a `node_modules/` symlink.\n"
  let agents_path = main_join_path(vendor_root, "AGENTS.md")
  let _ = @fs.write_file(
    agents_path,
    string_to_bytes(agents_body),
    create=0o644,
  ) catch {
    _ => ()
  }
  true
}

///|
/// Print a copy-paste-ready `moon.pkg.json` import entry for a freshly
/// vendored package. Resolves the consumer's module name from
/// `moon.mod.json` so the suggestion is concrete, falling back to a
/// `` placeholder when no module is found.
async fn print_vendor_import_hint(safe_dir : String) -> Unit {
  let module_name = read_consumer_module_name()
  let import_path = "\{module_name}/internal/generated/\{safe_dir}"
  println("")
  println("Add to your consumer moon.pkg.json import list:")
  println("  { \"path\": \"\{import_path}\", \"alias\": \"\{safe_dir}\" }")
}

///|
/// Resolve the consumer's moon module name from `moon.mod.json`. Falls
/// back to a `` placeholder so the printed hint still
/// reads as a template even outside a moon module.
async fn read_consumer_module_name() -> String {
  let module_root = match find_nearest_moon_mod_dir(".") {
    Some(root) => root
    None => return ""
  }
  let path = main_join_path(module_root, "moon.mod.json")
  let source = @fs.read_file(path).text() catch { _ => return "" }
  match parse_moon_mod_string_field(source, "name") {
    Some(name) => name
    None => ""
  }
}

///|
/// Print a copy-paste-ready `package.json#imports` entry for the
/// `#tsmbt-bridge/*` wildcard. Walks the consumer's `package.json` to
/// see whether the entry already resolves to the right relative
/// target; stays silent when it does so re-running `vendor` / `sync`
/// is quiet on already-configured projects.
///
/// We deliberately don't auto-edit `package.json` -- the user's
/// existing formatting / key order / trailing-newline conventions are
/// preserved this way, and a one-time copy-paste setup is cheaper
/// than reasoning about diffing strategies for arbitrary inputs.
async fn print_vendor_package_json_imports_hint(vendor_root : String) -> Unit {
  let module_root = match find_nearest_moon_mod_dir(".") {
    Some(root) => root
    None => return
  }
  let pkg_json_path = main_join_path(module_root, "package.json")
  if !@fs.exists(pkg_json_path) {
    return
  }
  let module_real = @fs.realpath(module_root) catch { _ => module_root }
  let vendor_real = @fs.realpath(vendor_root) catch { _ => vendor_root }
  let rel_to_vendor = match strip_dir_prefix(module_real, vendor_real) {
    Some(rest) => rest
    None => vendor_real
  }
  let key = "#tsmbt-bridge/*"
  let target = "./\{rel_to_vendor}/*/bridge.js"
  let source = @fs.read_file(pkg_json_path).text() catch { _ => return }
  let json = @json.parse(source) catch { _ => return }
  if package_json_imports_entry_matches(json, key, target) {
    return
  }
  println("")
  println("Add to your consumer package.json (`imports` field):")
  println("  \"imports\": {")
  println("    \"\{key}\": \"\{target}\"")
  println("  }")
}

///|
/// `true` iff the parsed `package.json` already maps the given
/// `imports` key to the given target string.
fn package_json_imports_entry_matches(
  json : Json,
  key : String,
  target : String,
) -> Bool {
  guard json is Object(top) else { return false }
  for pair in top {
    let (name, val) = pair
    if name != "imports" {
      continue
    }
    guard val is Object(imports) else { return false }
    for entry in imports {
      let (k, v) = entry
      if k == key {
        guard v is String(s) else { return false }
        return s == target
      }
    }
    return false
  }
  false
}

///|
/// Detect a missing `"type": "module"` in the consumer's `package.json`
/// and surface a one-line hint. Generated `bridge.js` is ESM, so Node
/// prints a perf warning ("Reparsing as ES module ...") when the
/// containing package.json doesn't declare `type: module`.
async fn print_vendor_module_type_hint() -> Unit {
  let module_root = match find_nearest_moon_mod_dir(".") {
    Some(root) => root
    None => return
  }
  let pkg_json_path = main_join_path(module_root, "package.json")
  if !@fs.exists(pkg_json_path) {
    return
  }
  let source = @fs.read_file(pkg_json_path).text() catch { _ => return }
  if source.contains("\"type\"") {
    return
  }
  println(
    "  (note) add `\"type\": \"module\"` to package.json to silence Node's ESM-reparse warning",
  )
}

///|
/// Vendor every dependency listed in a `package.json` (`dependencies` +
/// `devDependencies`). Emits a per-package summary at the end and
/// returns `true` only when every package generated successfully.
///
/// `package_json_path_override` defaults to `./package.json`.
pub async fn ts2mbt_sync_package_json(
  package_json_path_override? : String? = None,
  vendor_root_override? : String? = None,
) -> Bool {
  let package_json_path = match package_json_path_override {
    Some(path) => path
    None => "package.json"
  }
  let deps = read_package_json_dependency_names(package_json_path)
  if deps.length() == 0 {
    println(
      "sync: no dependencies / devDependencies found in '\{package_json_path}'",
    )
    return true
  }
  let succeeded : Array[String] = []
  let failed : Array[String] = []
  for pkg_spec in deps {
    if ts2mbt_vendor_package(
        pkg_spec,
        vendor_root_override~,
        print_import_hint=false,
      ) {
      succeeded.push(pkg_spec)
    } else {
      failed.push(pkg_spec)
    }
  }
  println("")
  println(
    "sync summary: \{succeeded.length()} ok, \{failed.length()} failed (of \{deps.length()})",
  )
  if failed.length() > 0 {
    println("failed packages:")
    for pkg in failed {
      println("  - \{pkg}")
    }
  }
  // Print one consolidated import block at the end so the user gets a
  // copy-paste-ready chunk for their `moon.pkg.json`.
  if succeeded.length() > 0 {
    print_sync_import_block(succeeded)
    let vendor_root = match vendor_root_override {
      Some(root) => root
      None =>
        match resolve_default_vendor_root() {
          Some(root) => root
          None => return failed.length() == 0
        }
    }
    print_vendor_package_json_imports_hint(vendor_root)
    print_vendor_module_type_hint()
  }
  failed.length() == 0
}

///|
/// Print one combined `moon.pkg.json` import block listing every
/// successfully vendored package.
async fn print_sync_import_block(pkg_specs : Array[String]) -> Unit {
  let module_name = read_consumer_module_name()
  println("")
  println("Add to your consumer moon.pkg.json import list:")
  for i, pkg_spec in pkg_specs {
    let safe_dir = vendor_safe_pkg_dir(pkg_spec)
    let suffix = if i == pkg_specs.length() - 1 { "" } else { "," }
    println(
      "  { \"path\": \"\{module_name}/internal/generated/\{safe_dir}\", \"alias\": \"\{safe_dir}\" }\{suffix}",
    )
  }
}

///|
/// Sanitize an npm package specifier into a directory-name-safe slug.
/// `@scope/foo-bar` -> `scope__foo_bar`; `react` -> `react`.
fn vendor_safe_pkg_dir(name : String) -> String {
  let mut result = ""
  for c in name {
    if c == '@' {
      // Strip leading scope marker
      ()
    } else if (c >= 'a' && c <= 'z') ||
      (c >= 'A' && c <= 'Z') ||
      (c >= '0' && c <= '9') {
      result += c.to_string()
    } else if c == '/' {
      result += "__"
    } else {
      result += "_"
    }
  }
  if result == "" {
    "package"
  } else {
    result
  }
}

///|
/// Default runtime module spec for a package vendored from node_modules.
/// `@types/react` -> `react`; everything else passes through.
fn default_runtime_module_spec(pkg_spec : String) -> String {
  let prefix = "@types/"
  if pkg_spec.has_prefix(prefix) {
    pkg_spec[prefix.length():pkg_spec.length()].to_string()
  } else {
    pkg_spec
  }
}

///|
/// Pure-function variant of `resolve_default_vendor_root` for testing.
/// Given a moon module root and the raw `moon.mod.json` contents,
/// returns the directory the vendor pipeline should write into.
///
/// When `moon.mod.json` does not declare a `source` field MoonBit
/// treats the module root itself as the source directory, so the
/// fallback here is `"."` rather than `"src"`. Picking `"src"` for
/// missing-source projects would emit the bridge under a phantom
/// `/src/internal/generated/...` path that the moon module
/// can't see, and `moon build` would silently skip it.
pub fn compute_vendor_root(
  module_root : String,
  moon_mod_source : String,
) -> String {
  let source_dir_rel = match
    parse_moon_mod_string_field(moon_mod_source, "source") {
    Some(value) => value
    None => "."
  }
  let source_dir = if source_dir_rel == "" || source_dir_rel == "." {
    module_root
  } else {
    main_join_path(module_root, source_dir_rel)
  }
  main_join_path(source_dir, "internal/generated")
}

///|
/// Resolve `/internal/generated` from the cwd's
/// nearest `moon.mod.json`. Returns `None` if no module is found.
async fn resolve_default_vendor_root() -> String? {
  let module_root = match find_nearest_moon_mod_dir(".") {
    Some(root) => root
    None => return None
  }
  let moon_mod_path = main_join_path(module_root, "moon.mod.json")
  let source = @fs.read_file(moon_mod_path).text() catch { _ => return None }
  Some(compute_vendor_root(module_root, source))
}

///|
/// Read the names listed under `dependencies` and `devDependencies` in
/// the given `package.json`. Order: deps first, devDeps second, with
/// each block in the order JSON returned. Duplicates are de-duped.
async fn read_package_json_dependency_names(path : String) -> Array[String] {
  let result : Array[String] = []
  let source = @fs.read_file(path).text() catch {
    _ => {
      println("vendor: could not read '\{path}'")
      return result
    }
  }
  let json = @json.parse(source) catch {
    _ => {
      println("vendor: '\{path}' is not valid JSON")
      return result
    }
  }
  guard json is Object(members) else { return result }
  for field in ["dependencies", "devDependencies"] {
    for pair in members {
      let (name, value) = pair
      if name != field {
        continue
      }
      guard value is Object(deps) else { continue }
      for dep_pair in deps {
        let (dep_name, _) = dep_pair
        if !result.contains(dep_name) {
          result.push(dep_name)
        }
      }
    }
  }
  result
}