///|
/// Interpreter resolution, the way PM2 does it.
///
/// PM2 never runs a script naked unless the file is a real executable: it
/// looks the extension up in a small map (`.js` → `node`, `.py` → `python`,
/// `.sh` → `bash`, …), and everything else that is not a binary is handed to
/// `node`. pm3 is language-agnostic, so the mapped commands are looked up on
/// `PATH` like any other program; what it keeps is PM2's decision, so
/// `pm3 start app.js` really runs `node app.js` even when `app.js` has no
/// shebang.
///
/// The pieces here are pure: the filesystem probes (`which`, reading the first
/// bytes of a file) live in the daemon, which is the side that has an async
/// runtime.

///|
/// The extension of `path`, including the dot, as `path.extname` defines it:
/// the last dot of the last component, and nothing for a leading dot or a
/// missing one.
pub fn script_extension(path : String) -> String {
  let base = path_basename(path)
  match base.rev_find(".") {
    Some(index) if index > 0 =>
      base.view(start_offset=index, end_offset=base.length()).to_owned()
    _ => ""
  }
}

///|
/// PM2's `lib/API/interpreter.json`, verbatim.
///
/// The returned name is a command, not a path: it is looked up on `PATH` when
/// the app starts.
pub fn interpreter_for_extension(extension : String) -> String? {
  match extension {
    ".sh" => Some("bash")
    ".py" => Some("python")
    ".rb" => Some("ruby")
    ".php" => Some("php")
    ".pl" => Some("perl")
    ".js" => Some("node")
    ".coffee" => Some("coffee")
    ".ls" => Some("lsc")
    ".ts" => Some("bun")
    ".tsx" => Some("bun")
    _ => None
  }
}

///|
/// Whether a file's first bytes look like a compiled executable rather than
/// text — PM2's `isbinaryfile` heuristic: a NUL byte, or more than 10% of the
/// sample being non-text, means binary. UTF-8 multibyte sequences and a UTF-8
/// BOM are not binary. An empty sample is text.
pub fn is_binary_content(bytes : Bytes) -> Bool {
  let total = if bytes.length() > 512 { 512 } else { bytes.length() }
  if total == 0 {
    return false
  }
  if total >= 3 &&
    bytes[0].to_int() == 0xEF &&
    bytes[1].to_int() == 0xBB &&
    bytes[2].to_int() == 0xBF {
    return false
  }
  let mut suspicious = 0
  let mut i = 0
  while i < total {
    let byte = bytes[i].to_int()
    if byte == 0 {
      return true
    }
    if (byte < 7 || byte > 14) && (byte < 32 || byte > 127) {
      // A UTF-8 two- or three-byte sequence is not suspicious.
      if byte > 193 && byte < 224 && i + 1 < total {
        i = i + 1
        let next = bytes[i].to_int()
        if next > 127 && next < 192 {
          i = i + 1
          continue
        }
      } else if byte > 223 && byte < 240 && i + 2 < total {
        i = i + 1
        let next = bytes[i].to_int()
        if next > 127 && next < 192 {
          let after = bytes[i + 1].to_int()
          if after > 127 && after < 192 {
            i = i + 2
            continue
          }
        }
      }
      suspicious = suspicious + 1
      // Read at least 32 bytes before making a decision.
      if i > 32 && suspicious * 100 / total > 10 {
        return true
      }
    }
    i = i + 1
  }
  suspicious * 100 / total > 10
}

///|
/// Whether a `python` interpreter needs its output unbuffered: PM2 exports
/// `PYTHONUNBUFFERED=1` whenever the interpreter contains `python`, so that
/// logs appear in the file as the program writes them.
pub fn is_python_interpreter(interpreter : String) -> Bool {
  interpreter.contains("python")
}

///|
/// The JS runtime behind an interpreter path or command.
///
/// This is PM2's `lib/tools/interpreter.js` rule, which 7.1.0 grew so that a
/// host is not mistaken for a host with no JavaScript just because its binary
/// is named differently. Only the last path segment counts, Windows
/// executable extensions and a trailing version tag are stripped, and the
/// match is anchored on the end of the name:
///
/// - `node`, `/usr/bin/node`, `nodejs`, `node.exe`, `node64.exe`, `node@18`,
///   `node18`, `node-v20.1.0`, `C:\nvm\v18.0.0\node.exe` are `node`;
/// - `bun`, `/home/ubuntu/.bun/bin/bun`, `bun.exe`, `bun-1.1` are `bun`;
/// - `bundle`, `bunx`, `/home/ubuntu/.venvs/bin/python` are neither (#5990).
pub fn runtime_of(interpreter : String) -> String? {
  let mut name = runtime_basename(interpreter).to_lower()
  for ext in [".exe", ".cmd", ".bat"] {
    match name.view().strip_suffix(ext) {
      Some(view) => name = view.to_owned()
      None => ()
    }
  }
  name = strip_version_tag(name)
  if name == "nodejs" || name.view().has_suffix("node") {
    Some("node")
  } else if name.view().has_suffix("bun") {
    Some("bun")
  } else {
    None
  }
}

///|
/// The last segment of a path, accepting both separators so a Windows path
/// classifies on any host (PM2 splits on `[\\/]`).
fn runtime_basename(path : String) -> String {
  let mut start = 0
  for i in 0.. start = i + 1
      _ => ()
    }
  }
  path.view(start_offset=start, end_offset=path.length()).to_owned()
}

///|
/// Drop a trailing version tag: PM2's `[-@_.]?v?\d+(\.\d+)*$`.
///
/// `node18` and `node@18` lose the number and its separator, `node-v20.1.0`
/// loses `-v20.1.0`. A name that does not end in a digit is returned as is,
/// so `node.` is not mistaken for a versioned `node`.
fn strip_version_tag(name : String) -> String {
  let mut index = name.length()
  while index > 0 {
    let ch = match name.get_char(index - 1) {
      Some(ch) => ch
      None => break
    }
    if (ch >= '0' && ch <= '9') || ch == '.' {
      index = index - 1
    } else {
      break
    }
  }
  if index == name.length() {
    return name
  }
  let last = match name.get_char(name.length() - 1) {
    Some(ch) => ch
    None => return name
  }
  if last < '0' || last > '9' {
    return name
  }
  let mut start = index
  if start > 0 {
    match name.get_char(start - 1) {
      Some(ch) if ch == 'v' || ch == 'V' => start = start - 1
      _ => ()
    }
  }
  if start > 0 {
    match name.get_char(start - 1) {
      Some(ch) if ch == '-' || ch == '@' || ch == '_' || ch == '.' =>
        start = start - 1
      _ => ()
    }
  }
  name.view(start_offset=0, end_offset=start).to_owned()
}

///|
/// Whether a node binary can run a `.ts` file, and under which conditions.
pub(all) enum TsSupport {
  /// Type stripping is on by default: no flag needed.
  Native
  /// Type stripping exists but has to be asked for.
  Flag
  /// This node cannot run TypeScript at all.
  Unsupported
} derive(Eq, Debug)

///|
pub extend TsSupport with Eq::{not_equal, equal}

///|
pub extend TsSupport with Debug::{to_repr}

///|
/// What a node version can do with `.ts` files, the way PM2's
/// `lib/tools/typestrip.js` classifies it:
///
/// - `native` from 22.18.0 in the 22 line, and from 23.6.0;
/// - `flag` (`--experimental-strip-types`) from 22.6.0;
/// - `none` before that, and for anything that is not a version at all.
pub fn type_strip_support(version : String) -> TsSupport {
  let (major, minor) = match parse_node_version(version) {
    Some(parsed) => parsed
    None => return Unsupported
  }
  if major >= 24 {
    Native
  } else if major == 23 {
    if minor >= 6 {
      Native
    } else {
      Flag
    }
  } else if major == 22 {
    if minor >= 18 {
      Native
    } else if minor >= 6 {
      Flag
    } else {
      Unsupported
    }
  } else {
    Unsupported
  }
}

///|
/// `(major, minor)` of a `node --version` answer such as `v22.18.0` or
/// `v23.6.0-rc.1`. A `v` prefix is optional.
fn parse_node_version(version : String) -> (Int32, Int32)? {
  let text = version.trim().to_owned()
  let digits = if text.has_prefix("v") || text.has_prefix("V") {
    text.view(start_offset=1, end_offset=text.length())
  } else {
    text.view()
  }
  let parts : Array[String] = []
  for part in digits.split(".") {
    parts.push(part.to_owned())
  }
  if parts.length() < 2 {
    return None
  }
  let major = match parse_non_negative_int(parts[0].view()) {
    Some(value) => value
    None => return None
  }
  let minor = match parse_non_negative_int(parts[1].view()) {
    Some(value) => value
    None => return None
  }
  Some((major, minor))
}