// The `--GUI` front end.
//
// LunaPack and LunaPack Studio are one product, not two packages. There is
// one version, one tag and one GitHub release. Studio is an optional asset of
// that release; it is not published to Mooncakes and has no version of its
// own.
//
// `lunapack --GUI` finds Studio locally, downloads it from the very same
// release with a progress bar when it is missing, and starts it with the
// command line the user already typed. The flags are *forwarded*, not acted
// on: the window opens with the input/output/files fields already filled in.

///|
/// The version of this CLI. `studio_wbtest.mbt` reads moon.mod and refuses to
/// let the two drift apart, because the download URL is built from this.
pub const CLI_VERSION : String = "0.1.23"

///|
fn is_gui_flag(a : String) -> Bool {
  a == "--GUI" ||
  a == "--gui" ||
  a == "-GUI" ||
  a == "-gui" ||
  a == "--GUI=true" ||
  a == "--gui=true" ||
  a == "-GUI=true" ||
  a == "-gui=true" ||
  a == "--studio" ||
  a == "--Studio" ||
  a == "--studio=true" ||
  a == "--Studio=true"
}

///|
/// Is the GUI being asked for? The switch may sit anywhere on the command
/// line, so `lunapack --GUI ...` and `lunapack ... --GUI` both work.
pub fn wants_gui(argv : ArrayView[String]) -> Bool {
  for a in argv {
    if is_gui_flag(a) {
      return true
    }
  }
  false
}

///|
/// The command line with the GUI switch taken out, in its original order.
fn without_gui_flags(argv : ArrayView[String]) -> Array[String] {
  let out : Array[String] = []
  for a in argv {
    if !is_gui_flag(a) {
      out.push(a)
    }
  }
  out
}

///|
/// The path of this executable, in the form the GUI can pass back to the
/// `bundle` subprocess. The image path is asked of the operating system, not
/// taken from argv[0], for the same reason the self-extracting launcher does.
fn self_exe() -> String {
  match current_exe_path() {
    Some(p) => norm_sep(p)
    None => norm_sep(@env.args()[0])
  }
}

///|
/// What Studio receives. Everything the user typed is kept, and `--cli` is
/// added so the window always knows which LunaPack to drive - even when it
/// was downloaded into a cache folder far away from the checkout.
fn studio_arguments(argv : ArrayView[String]) -> Array[String] {
  let out = without_gui_flags(argv)
  let mut has_cli = false
  for a in out {
    if a == "--cli" || a.has_prefix("--cli=") {
      has_cli = true
    }
  }
  if !has_cli {
    out.push("--cli=" + self_exe())
  }
  out
}

///|
fn studio_exe_name() -> String {
  if host_platform() == "windows" {
    "lunapack-studio.exe"
  } else {
    "lunapack-studio"
  }
}

///|
/// The architecture part of the asset name. `default_arch` reads environment
/// variables that POSIX shells do not always export; a download that guesses
/// wrong is worse than one that tries the common architecture, because the
/// URL can always be overridden.
fn studio_arch() -> String {
  let a = default_arch()
  if a == "unknown" {
    "x86_64"
  } else {
    a
  }
}

///|
/// The name of the Studio asset in this version's release.
pub fn studio_asset_name() -> String {
  "lunapack-studio-" + host_platform() + "-" + studio_arch() + ".zip"
}

///|
/// Where Studio comes from. A mirror or an air-gapped install can override
/// the whole URL; the default is the release that carries this CLI.
pub fn studio_release_url() -> String {
  let fallback = "https://github.com/riantr/LunaPack/releases/download/v" +
    CLI_VERSION +
    "/" +
    studio_asset_name()
  match @env.get_env_var("LUNAPACK_STUDIO_URL") {
    Some(u) => if u == "" { fallback } else { u }
    None => fallback
  }
}

///|
/// Where a downloaded Studio lives. The directory is versioned, so a newer
/// CLI does not silently start an older window.
pub fn studio_dir() -> String {
  let home = env_or("LUNAPACK_STUDIO_HOME")
  if home != "" {
    return norm_sep(home) + "/" + CLI_VERSION
  }
  let appdata = env_or("LOCALAPPDATA")
  if appdata != "" {
    return norm_sep(appdata) + "/LunaPack/Studio/" + CLI_VERSION
  }
  let xdg = env_or("XDG_CACHE_HOME")
  if xdg != "" {
    return norm_sep(xdg) + "/lunapack/studio/" + CLI_VERSION
  }
  let user = env_or("HOME")
  if user != "" {
    return norm_sep(user) + "/.cache/lunapack/studio/" + CLI_VERSION
  }
  ".lunapack/studio/" + CLI_VERSION
}

///|
/// Places a Studio executable may already be, most specific first. The
/// dev-checkout entries mean a build straight from the source tree works
/// without any download at all.
fn studio_candidates() -> Array[String] {
  let name = studio_exe_name()
  let out : Array[String] = []
  let explicit = env_or("LUNAPACK_STUDIO")
  if explicit != "" {
    let p = norm_sep(explicit)
    out.push(p)
    out.push(p + "/" + name)
  }
  let dir = dirname(self_exe())
  if dir != "" {
    out.push(dir + "/" + name)
    out.push(dir + "/studio/" + name)
    out.push(dir + "/dist/lunapack-studio/" + name)
    // A build straight from the source tree: the CLI sits in
    // LunaPack/_build/native//build, while the Studio workspace is a
    // sibling of the LunaPack checkout. Walking up five levels lands on the
    // directory that holds both projects.
    out.push(dir + "/../../../../../LunaPackGUI/dist/lunapack-studio/" + name)
    out.push(dir + "/../../../../dist/lunapack-studio/" + name)
  }
  let cache = studio_dir()
  out.push(cache + "/" + name)
  // proton_cli packages the window inside a folder of the same name.
  out.push(cache + "/lunapack-studio/" + name)
  out
}

///|
async fn find_studio() -> String? {
  for p in studio_candidates() {
    if @fs.exists(p) && @fs.kind(p, follow_symlink=true) != Directory {
      return Some(p)
    }
  }
  None
}

///|
/// Turn a redirect target into an absolute URL. GitHub answers a release
/// asset request with an absolute signed URL, but a mirror need not.
fn absolute_url(base : String, loc : String) -> String raise {
  if loc.has_prefix("http://") || loc.has_prefix("https://") {
    return loc
  }
  let scheme = match base.find("://") {
    Some(i) => i + 3
    None => fail("the Studio download URL has no scheme")
  }
  let rest = base[scheme:].to_owned()
  let authority = match rest.find("/") {
    Some(i) => rest[:i].to_owned()
    None => rest
  }
  let prefix = base[:scheme].to_owned() + authority
  if loc.has_prefix("/") {
    return prefix + loc
  }
  let path = rest[authority.length():].to_owned()
  let slash = path.rev_find("/")
  let directory = match slash {
    Some(i) => path[:i + 1].to_owned()
    None => "/"
  }
  prefix + directory + loc
}

///|
fn decimal(text : String) -> Int64 {
  let mut n = 0L
  for i = 0; i < text.length(); i = i + 1 {
    // `i` is bounded by `text.length()`, so `text[i]` is always a valid index.
    let c = text[i].to_int() - 48
    if c < 0 || c > 9 {
      return n
    }
    n = n * 10L + c.to_int64()
  }
  n
}

///|
fn header_value(header : @http.ResponseHeader, key : String) -> String {
  match header.headers.get(key) {
    Some(v) => v
    None => ""
  }
}

///|
/// A loopback server is a local mirror or a test fixture. Sending those
/// requests out through a corporate proxy either fails or loops back into
/// the wrong machine, so they always go direct.
pub fn is_loopback_url(url : String) -> Bool {
  let rest = match url.find("://") {
    Some(i) => url[i + 3:].to_owned()
    None => url
  }
  let authority = match rest.find("/") {
    Some(i) => rest[:i].to_owned()
    None => rest
  }
  let host = match authority.rev_find("@") {
    Some(i) => authority[i + 1:].to_owned()
    None => authority
  }
  // Strip the port and brackets of an IPv6 literal.
  let bare = if host.has_prefix("[") {
    match host.find("]") {
      Some(i) => host[1:i].to_owned()
      None => host
    }
  } else {
    match host.find(":") {
      Some(i) => host[:i].to_owned()
      None => host
    }
  }
  let bare = bare.to_lower()
  bare == "localhost" ||
  bare == "127.0.0.1" ||
  bare == "::1" ||
  bare.has_suffix(".localhost")
}

///|
/// Does `NO_PROXY` name this host? The value is a comma-separated list of
/// host names or suffixes; the wildcard "*" disables the proxy entirely.
fn proxy_bypassed(url : String) -> Bool {
  if is_loopback_url(url) {
    return true
  }
  let no_proxy = env_or("NO_PROXY")
  let no_proxy = if no_proxy == "" { env_or("no_proxy") } else { no_proxy }
  if no_proxy == "*" {
    return true
  }
  let rest = match url.find("://") {
    Some(i) => url[i + 3:].to_owned()
    None => url
  }
  let authority = match rest.find("/") {
    Some(i) => rest[:i].to_owned()
    None => rest
  }
  let host_part = match authority.find(":") {
    Some(i) => authority[:i].to_owned()
    None => authority
  }
  let host = host_part.to_lower()
  for rule in no_proxy.split(",") {
    let r = rule.trim().to_owned().to_lower()
    if r == "" {
      continue
    }
    let bare : String = if r.has_prefix(".") { r[1:].to_owned() } else { r }
    if host == bare || host.has_suffix("." + bare) {
      return true
    }
  }
  false
}

///|
/// A proxy, when the machine has one. GitHub is reached over TLS through a
/// CONNECT tunnel; a proxy that cannot be used simply means a direct try.
async fn download_proxy(url : String) -> @http.ClientConnection? {
  if proxy_bypassed(url) {
    return None
  }
  let proxy = env_or("HTTPS_PROXY")
  let proxy = if proxy == "" { env_or("https_proxy") } else { proxy }
  let proxy = if proxy == "" { env_or("HTTP_PROXY") } else { proxy }
  let proxy = if proxy == "" { env_or("http_proxy") } else { proxy }
  if proxy == "" {
    return None
  }
  Some(@http.ClientConnection::ClientConnection(proxy)) catch {
    _ => None
  }
}

///|
/// GET `url`, following a release redirect. The connection is returned with
/// the body still unread so the caller can count the bytes as they arrive.
async fn open_download(
  url : String,
) -> (@http.ResponseHeader, @http.ClientConnection) {
  let mut current = url
  let mut hops = 0
  while hops < 6 {
    let proxy = download_proxy(current)
    let (header, conn) = @http.get(current, proxy?)
    let code = header.code
    if code >= 300 && code < 400 {
      let location = header_value(header, "location")
      conn.skip_response_body()
      conn.close()
      if location == "" {
        fail("the Studio download redirected without a Location header")
      }
      current = absolute_url(current, location)
      hops = hops + 1
      continue
    }
    if code != 200 {
      conn.close()
      fail(
        "the Studio download returned HTTP " +
        code.to_string() +
        " for " +
        current,
      )
    }
    return (header, conn)
  }
  fail("too many redirects while downloading LunaPack Studio")
}

///|
/// `println` goes through a buffered stream that is flushed when the process
/// ends, while the progress bar writes straight to stdout. Mixing the two
/// would print every message *after* the bar, so all of this front end's
/// output uses the same stream.
async fn say(text : String) -> Unit {
  let newline = if host_platform() == "windows" { "\r\n" } else { "\n" }
  @stdio.stdout.write(@utf8.encode(text + newline))
}

///|
/// The progress line, always the same width so `\r` overwrites it cleanly.
fn download_line(done : Int64, total : Int64) -> String {
  if total > 0L {
    let p = percent_done(done, total)
    "  [" +
    progress_bar(p, 24) +
    "]  " +
    p.to_string() +
    "%  " +
    megabytes_text(done) +
    "/" +
    megabytes_text(total) +
    " MB"
  } else {
    "  " + megabytes_text(done) + " MB downloaded"
  }
}

///|
async fn show_download_progress(done : Int64, total : Int64) -> Unit {
  @stdio.stdout.write(
    @utf8.encode("\r" + fit_line(download_line(done, total), 72)),
  )
}

///|
/// Stream `url` into `path`, drawing the progress bar on stdout.
async fn download_to(url : String, path : String) -> Unit {
  ensure_parent_dir(path)
  let (header, conn) = open_download(url)
  let total = decimal(header_value(header, "content-length"))
  let file = @fs.open(path, mode=WriteOnly, create_mode=CreateOrTruncate)
  let mut done = 0L
  let mut last = -1L
  show_download_progress(done, total)
  let mut chunk = conn.read()
  while chunk is Some(bytes) {
    file.write(bytes)
    done = done + bytes.length().to_int64()
    // A bar per network packet would spend more time writing the console
    // than writing the file.
    if done - last >= 262144L || (total > 0L && done >= total) {
      show_download_progress(done, total)
      last = done
    }
    chunk = conn.read()
  }
  show_download_progress(done, total)
  @stdio.stdout.write(
    @utf8.encode(
      "\r" + fit_line("  Downloaded " + megabytes_text(done) + " MB", 72) + "\n",
    ),
  )
  conn.close()
  file.close()
  if done == 0L {
    @fs.remove(path, allow_missing=true)
    fail("the Studio download was empty")
  }
}

///|
/// Download and unpack Studio, then report the executable that appeared.
async fn install_studio(url : String) -> String {
  let dir = studio_dir()
  @fs.mkdir(dir, recursive=true, allow_exist=true)
  let archive = dir + "/studio.zip"
  download_to(url, archive)
  say("Extracting LunaPack Studio into " + dir)
  let data = @fs.read_binary(archive)
  extract_zip(data, dir)
  @fs.remove(archive, allow_missing=true)
  match find_studio() {
    Some(p) => p
    None => fail("the Studio archive did not contain " + studio_exe_name())
  }
}

///|
/// Start the window.
///
/// The child's stdout/stderr are deliberately left inherited. Redirecting
/// them into a log would keep `lunapack --GUI | ...` from blocking on a pipe,
/// but the `@process` output API is not stable across MoonBit toolchains:
/// older ones expose `redirect_to_file() -> &ProcessOutput` (a trait object),
/// while newer ones return `SharedProcessOutput` and expect a `ProcessOutput`
/// enum variant in `spawn_orphan`. LunaPack is published as source and is
/// built with whatever toolchain the user has, so no `stdout=`/`stderr=`
/// argument can be written that compiles on every generation. Portability
/// wins here: Studio is a windowed application and writes nothing to the
/// console in normal use. When stdout is a pipe, the window holds it until
/// it is closed.
async fn spawn_studio(exe : String, args : Array[String]) -> Unit {
  ignore(
    @process.spawn_orphan(exe, args, cwd=dirname(exe), no_console_window=true),
  )
}

///|
/// Start Studio with the command line that was given to the CLI.
pub async fn launch_studio(argv : ArrayView[String]) -> Unit {
  if !is_native_target() {
    say("error: --GUI needs the native LunaPack executable.")
    say(
      "       The wasm/moonx build cannot open a window; use the native release binary.",
    )
    say("       Studio asset: " + studio_release_url())
    exit_with(1)
    return
  }
  let args = studio_arguments(argv)
  let exe = match find_studio() {
    Some(p) => p
    None => {
      let url = studio_release_url()
      say("LunaPack Studio " + CLI_VERSION + " is not installed yet.")
      say("Downloading " + url)
      let installed = install_studio(url) catch {
        err => {
          say("error: cannot install LunaPack Studio: " + err.to_string())
          say(
            "       Set LUNAPACK_STUDIO to a local build, or LUNAPACK_STUDIO_URL to a mirror.",
          )
          exit_with(1)
          ""
        }
      }
      if installed == "" {
        return
      }
      installed
    }
  }
  say("Starting LunaPack Studio: " + exe)
  let started = try {
    spawn_studio(exe, args)
    true
  } catch {
    err => {
      say("error: cannot start LunaPack Studio: " + err.to_string())
      exit_with(1)
      false
    }
  }
  if !started {
    return
  }
  say("LunaPack Studio started. Close the window to finish.")
}