// `--watch` / `-w`.
//
// Polling, not notification: neither `mizchi/x/fs` nor
// `moonbitlang/async/fs` exposes a watch syscall, so there is nothing to
// subscribe to. `@async_fs.mtime` is what is available, and the loop is
// built around what that can and cannot tell us.
//
// What it watches is the resolved PROGRAM, not the entry list. A change
// to a file the entry imports has to trigger a rebuild, and in a bundle
// that file is reached through the import graph rather than named on the
// command line — so the set comes back out of the compile. The first
// pass therefore has to run before there is anything to watch, which is
// also what `tsc --watch` does.
//
// Two limits are worth stating rather than discovering. A file ADDED to
// a directory an `include` pattern covers is not noticed until something
// already in the program changes, because a new path has no previous
// mtime to differ from — the poll compares a set it already has. And the
// interval is a compromise with no good answer: a shorter one burns CPU
// on a large program (the stat is per file, per pass), a longer one
// makes the tool feel broken.

///|
/// How long to wait between polls, in milliseconds.
///
/// `tsc --watch` debounces around 250 ms after a change it was told
/// about; with no notification to debounce, this is the whole latency, so
/// it is the largest value that still feels immediate rather than the
/// smallest one that is cheap.
const MTSC_WATCH_INTERVAL_MS : Int = 300

///|
/// `(seconds, nanos)` for `path`, or `None` when it cannot be read.
///
/// A file that disappears reads as `None` and so differs from its
/// recorded stamp, which is the behaviour wanted: deleting an imported
/// file should trigger a rebuild that reports the broken import.
#cfg(not(target="js"))
async fn mtsc_watch_stamp(path : String) -> (Int64, Int)? {
  let v = @async_fs.mtime(path) catch { _ => return None }
  Some(v)
}

///|
/// `mtime` for the JavaScript backend.
///
/// `moonbitlang/async/fs` states in its own `unimplemented.mbt` that it
/// "does not support JavaScript backend" and restricts every source
/// file but that one to `native` / `wasm`, so `@async_fs.mtime` does
/// not exist here and this is why the CLI could not build for `js` at
/// all.
///
/// SYNCHRONOUS, unlike the native arm, and that is a deliberate choice
/// between two costs. `node:fs/promises`' `stat` would keep the
/// signature `async` and match `mizchi/x/fs`'s own JS backend — but
/// awaiting a JS promise needs `moonbitlang/async/js_async` imported,
/// which is then unused on native, and `moon check --deny-warn` on
/// native is THE gate. `statSync` needs no import, so the cost lands as
/// one suppressed diagnostic on the caller below instead of two
/// warnings on the gate.
#cfg(target="js")
fn mtsc_watch_stamp(path : String) -> (Int64, Int)? {
  // Keep the `moonbitlang/async/fs` import referenced on this backend.
  // It exists for the `@async_fs.mtime` in the arm above, and moon has
  // no per-target `import` syntax, so on `js` the dependency reads as
  // unused and warning 0029 fails `--deny-warn --target js` on a
  // package that is otherwise clean. `unimplemented` is the ONE symbol
  // that package exposes here — declared `#cfg(not(target="native"))`
  // under the doc comment "This package currently does not support
  // JavaScript backend", i.e. it exists to be named in exactly this
  // situation. Deliberately not `warnings = "-0029"`: a package-wide
  // suppression would also hide a genuinely dead import, on every
  // target, which is how a gate stops being a gate. A standalone
  // function was tried first and is worse — it is itself unused
  // (warning 0001), so the reference has to sit in live code.
  ignore(@async_fs.unimplemented)
  let stamp = mtsc_js_stat_mtime(path)
  if stamp.length() < 2 {
    return None
  }
  Some((stamp[0].to_int64(), stamp[1].to_int()))
}

///|
/// `[seconds, nanos]` for `path`, or `[]` when it cannot be stat'd.
///
/// An empty array rather than a thrown error, because a watched file
/// disappearing is an ordinary answer the caller turns into "changed",
/// and a directory is stat'able but is not a file we can compile.
///
/// `mtime` comes back in whole seconds plus nanoseconds to match the
/// native arm's `(Int64, Int)` exactly — `mtimeMs` alone loses
/// sub-millisecond resolution, and a rebuild triggered by an editor
/// that writes twice inside one millisecond is the case that needs it.
/// `mtimeNs` is a BigInt, so the nanosecond remainder is taken from it
/// when present and derived from `mtimeMs` when it is not.
///
/// `process.getBuiltinModule` is how the module is obtained, and that
/// is not a style choice. This bundle is an IIFE in a `.js` file, and
/// the repo's own `package.json` says `"type": "module"`, so Node loads
/// it as ESM — where `require` is NOT defined. Measured: the first
/// version of this body used `require("node:fs")` and a probe under ESM
/// gives `ReferenceError: require is not defined`, which the `catch`
/// below would have swallowed into `[]` for every file, leaving
/// `--watch` polling forever and never detecting a change. That is the
/// fail-open direction, so it is the one worth a measurement rather
/// than an assumption. `getBuiltinModule` is sync and works from both
/// module systems (Node 22.3 / 20.16 and up); `require` stays as the
/// fallback for an older Node loading this as CommonJS.
#cfg(target="js")
extern "js" fn mtsc_js_stat_mtime(path : String) -> FixedArray[Double] =
  #| function(path) {
  #|   try {
  #|     let fs = globalThis.__mtsc_node_fs;
  #|     if (fs === undefined) {
  #|       fs =
  #|         (typeof process !== "undefined" &&
  #|          typeof process.getBuiltinModule === "function")
  #|           ? process.getBuiltinModule("node:fs")
  #|           : (typeof require === "function" ? require("node:fs") : null);
  #|       globalThis.__mtsc_node_fs = fs;
  #|     }
  #|     if (!fs) return [];
  #|     const st = fs.statSync(path);
  #|     if (!st.isFile()) return [];
  #|     const seconds = Math.floor(st.mtimeMs / 1000);
  #|     const nanos = (st.mtimeNs !== undefined && st.mtimeNs !== null)
  #|       ? Number(BigInt(st.mtimeNs) % 1000000000n)
  #|       : Math.round((st.mtimeMs - seconds * 1000) * 1e6);
  #|     return [seconds, nanos];
  #|   } catch (e) {
  #|     return [];
  #|   }
  #| }

///|
/// Stamp every path in `paths`.
///
/// ONE definition rather than a per-target pair, and the annotation is
/// the price. On `js` the stamp read above is synchronous, so nothing in
/// this body awaits and the compiler correctly calls the `async` here
/// useless (warning 0067) — but the signature has to match the native
/// arm or every caller up the chain needs its own per-target variant,
/// which is the one-rule-in-several-places defect this repo keeps
/// paying for. Suppressing that one diagnostic at this one declaration
/// is the smaller cost than duplicating the loop; it is emphatically
/// not the package-wide `warnings = "-00.."` that CLAUDE.md records as
/// the way a gate stops being a gate.
#warnings("-unused_async")
async fn mtsc_watch_stamps(paths : Array[String]) -> Map[String, (Int64, Int)?] {
  let out : Map[String, (Int64, Int)?] = Map([])
  for p in paths {
    out[p] = mtsc_watch_stamp(p)
  }
  out
}

///|
/// Has any watched file changed since `previous`?
fn mtsc_watch_changed(
  previous : Map[String, (Int64, Int)?],
  current : Map[String, (Int64, Int)?],
) -> Bool {
  if previous.length() != current.length() {
    return true
  }
  for path, stamp in current {
    match previous.get(path) {
      // A path the previous pass did not have at all.
      None => return true
      Some(before) =>
        match (before, stamp) {
          (None, None) => ()
          (Some(a), Some(b)) => if a.0 != b.0 || a.1 != b.1 { return true }
          // Appeared or disappeared.
          _ => return true
        }
    }
  }
  false
}

///|
/// Every file the program reads, for one run of the entries.
///
/// In bundle mode this is the import graph, which is why it comes from
/// the loader rather than from the entry list: watching only the named
/// entries would miss every file they import, which is nearly the whole
/// program.
///
/// The walk here is the loader's, so the set is exactly what the compile
/// read — a second, independent walk could disagree with it, and a
/// watcher that disagrees with its compiler misses rebuilds.
async fn mtsc_watch_program_files(
  opts : MtscOptions,
  entries : Array[String],
) -> Array[String] {
  let out : Array[String] = []
  let seen : Map[String, Bool] = Map([])
  fn add(p : String) -> Unit {
    if !seen.contains(p) {
      seen[p] = true
      out.push(p)
    }
  }

  for entry in entries {
    add(entry)
    if !opts.bundle {
      continue
    }
    let files : Map[String, String] = Map([])
    let aliases : Map[String, String] = Map([])
    let type_props : Map[String, Bool] = Map([])
    let parsed : Map[String, @ast.TsModuleBlock] = Map([])
    let parse_split : Array[Double] = []
    let edges : Array[@mtsc_core.MtscModuleEdge] = []
    match
      mtsc_load_bundle_files(
        entry,
        files,
        aliases,
        opts.externals,
        type_props,
        false,
        parse_split,
        parsed,
        edges,
      ) {
      Ok(_) =>
        for path in files.keys() {
          if mtsc_is_module_path(path) || path.has_suffix(".d.ts") {
            add(path)
          }
        }
      // A load that failed still leaves the entry watched, so fixing the
      // file that broke it triggers the retry.
      Err(_) => ()
    }
  }
  out
}

///|
/// Compile, then recompile whenever a watched file changes.
///
/// Never exits on a failed compile — that is the whole point of the
/// mode, and it is why `mtsc_compile_entry` reports failure by returning
/// rather than by ending the process.
async fn mtsc_watch_loop(opts : MtscOptions, entries : Array[String]) -> Unit {
  // The resolution caches in `main.mbt` are memoized for the lifetime of
  // a process, on the stated assumption that "a compile is one process
  // over a snapshot of the tree, so nothing here can go stale within a
  // run". Watch mode breaks that assumption: the point is that the tree
  // changes. So every pass clears them, and the cost is per pass rather
  // than per run.
  let mut first = true
  let mut stamps : Map[String, (Int64, Int)?] = Map([])
  while true {
    if !first {
      let watched : Array[String] = []
      for path in stamps.keys() {
        watched.push(path)
      }
      let current = mtsc_watch_stamps(watched)
      if !mtsc_watch_changed(stamps, current) {
        @async.sleep(MTSC_WATCH_INTERVAL_MS)
        continue
      }
      println("mtsc: file change detected. Starting incremental compilation...")
      mtsc_clear_resolution_caches()
    }
    first = false
    let failed = mtsc_compile_all(opts, entries)
    // `tsc --watch`'s own summary line, so a watching human gets the
    // same signal from either tool.
    if failed == 0 {
      println("Found 0 errors. Watching for file changes.")
    } else if failed == 1 {
      println("Found errors in 1 file. Watching for file changes.")
    } else {
      println("Found errors in \{failed} files. Watching for file changes.")
    }
    stamps = mtsc_watch_stamps(mtsc_watch_program_files(opts, entries))
    // A watcher never exits, so nothing else ever flushes stdout. See
    // `mtsc_flush_stdout`: without this, `mtsc --watch > build.log`
    // leaves the log empty while the compile is working correctly.
    mtsc_flush_stdout()
    @async.sleep(MTSC_WATCH_INTERVAL_MS)
  }
}