///| Upload-pack over git-upload-pack (native targets)

///|
priv struct ProcessSpec {
  cmd : String
  args : Array[String]
  extra_env : Map[String, String]?
}

///|
fn ensure_remote_path(spec : RemoteSpec) -> String raise GitError {
  if spec.path.length() == 0 {
    raise GitError::ProtocolError("Remote path is empty")
  }
  spec.path
}

///|
fn ssh_remote_path(spec : RemoteSpec, path : String) -> String {
  if spec.raw.has_prefix("ssh://") && not(path.has_prefix("/")) {
    "/\{path}"
  } else {
    path
  }
}

///|
fn build_upload_pack_process_spec(
  spec : RemoteSpec,
  advertise_refs~ : Bool,
  prefer_v2~ : Bool,
) -> ProcessSpec raise GitError {
  let path = ensure_remote_path(spec)
  match spec.kind {
    RemoteKind::Http =>
      raise GitError::ProtocolError(
        "HTTP transport is not supported by process client",
      )
    RemoteKind::File => {
      let args : Array[String] = []
      args.push("--stateless-rpc")
      if advertise_refs {
        args.push("--advertise-refs")
      }
      args.push(path)
      let extra_env = if prefer_v2 {
        Some({ "GIT_PROTOCOL": "version=2" })
      } else {
        None
      }
      { cmd: "git-upload-pack", args, extra_env }
    }
    RemoteKind::Ssh => {
      let host = match spec.host {
        None => raise GitError::ProtocolError("SSH remote missing host")
        Some(v) => v
      }
      let args : Array[String] = [host]
      if prefer_v2 {
        args.push("env")
        args.push("GIT_PROTOCOL=version=2")
      }
      args.push("git-upload-pack")
      args.push("--stateless-rpc")
      if advertise_refs {
        args.push("--advertise-refs")
      }
      args.push(ssh_remote_path(spec, path))
      { cmd: "ssh", args, extra_env: None }
    }
  }
}

///|
fn bytes_to_string_lossy(data : Bytes) -> String {
  @utf8.decode_lossy(data[:])
}

///|
fn cleanup_temp_files(paths : Array[String]) -> Unit {
  for path in paths {
    @fs.remove_file(path) catch {
      _ => ()
    }
  }
}

///|
async fn run_process_with_files(
  spec : ProcessSpec,
  stdin_data : Bytes?,
) -> (Int, Bytes, Bytes) raise GitError {
  let nonce = @async.now()
  let base = "/tmp/moonbit-git-upload-pack-\{nonce}"
  let stdout_path = "\{base}.out"
  let stderr_path = "\{base}.err"
  let stdin_path = "\{base}.in"
  let cleanup_paths = [stdout_path, stderr_path, stdin_path]
  let stdout = @process.redirect_to_file(stdout_path, truncate=true) catch {
    e => raise GitError::IoError("failed to open stdout file: \{e}")
  }
  let stderr = @process.redirect_to_file(stderr_path, truncate=true) catch {
    e => raise GitError::IoError("failed to open stderr file: \{e}")
  }
  let stdin = match stdin_data {
    None => None
    Some(data) => {
      @fs.write_bytes_to_file(stdin_path, data) catch {
        e => raise GitError::IoError("failed to write stdin temp file: \{e}")
      }
      let input = @process.redirect_from_file(stdin_path) catch {
        e => raise GitError::IoError("process stdin failed: \{e}")
      }
      Some(input)
    }
  }
  let code = match spec.extra_env {
    None =>
      match stdin {
        None =>
          @process.run(spec.cmd, spec.args, inherit_env=true, stdout~, stderr~) catch {
            e => {
              cleanup_temp_files(cleanup_paths)
              raise GitError::IoError("process failed: \{e}")
            }
          }
        Some(input) =>
          @process.run(
            spec.cmd,
            spec.args,
            inherit_env=true,
            stdin=input,
            stdout~,
            stderr~,
          ) catch {
            e => {
              cleanup_temp_files(cleanup_paths)
              raise GitError::IoError("process failed: \{e}")
            }
          }
      }
    Some(env) =>
      match stdin {
        None =>
          @process.run(
            spec.cmd,
            spec.args,
            extra_env=env,
            inherit_env=true,
            stdout~,
            stderr~,
          ) catch {
            e => {
              cleanup_temp_files(cleanup_paths)
              raise GitError::IoError("process failed: \{e}")
            }
          }
        Some(input) =>
          @process.run(
            spec.cmd,
            spec.args,
            extra_env=env,
            inherit_env=true,
            stdin=input,
            stdout~,
            stderr~,
          ) catch {
            e => {
              cleanup_temp_files(cleanup_paths)
              raise GitError::IoError("process failed: \{e}")
            }
          }
      }
  }
  let out = @fs.read_file_to_bytes(stdout_path) catch {
    e => {
      cleanup_temp_files(cleanup_paths)
      raise GitError::IoError("failed to read stdout: \{e}")
    }
  }
  let err = @fs.read_file_to_bytes(stderr_path) catch {
    e => {
      cleanup_temp_files(cleanup_paths)
      raise GitError::IoError("failed to read stderr: \{e}")
    }
  }
  cleanup_temp_files(cleanup_paths)
  (code, out, err)
}

///|
async fn run_upload_pack_process(
  spec : ProcessSpec,
  stdin_data : Bytes?,
) -> Bytes raise GitError {
  let (code, stdout, stderr) = run_process_with_files(spec, stdin_data)
  if code != 0 {
    let args_joined = spec.args.join(" ")
    let cmdline = "\{spec.cmd} \{args_joined}"
    let err = bytes_to_string_lossy(stderr)
    let detail = if err.length() > 0 { ": \{err}" } else { "" }
    raise GitError::IoError(
      "git-upload-pack failed (exit=\{code}): \{cmdline}\{detail}",
    )
  }
  stdout
}

///|
pub async fn upload_pack_info_refs_process(
  remote : String,
  prefer_v2 : Bool,
) -> Bytes raise GitError {
  let spec = parse_remote(remote)
  let proc = build_upload_pack_process_spec(
    spec,
    advertise_refs=true,
    prefer_v2~,
  )
  run_upload_pack_process(proc, None)
}

///|
pub async fn upload_pack_request_process(
  remote : String,
  body : Bytes,
  prefer_v2 : Bool,
) -> Bytes raise GitError {
  let spec = parse_remote(remote)
  let proc = build_upload_pack_process_spec(
    spec,
    advertise_refs=false,
    prefer_v2~,
  )
  run_upload_pack_process(proc, Some(body))
}

///|
pub async fn discover_upload_refs_process(
  remote : String,
  prefer_v2 : Bool,
) -> (
  Array[(ObjectId, String)],
  Array[String],
  ProtocolVersion,
  Map[String, String],
) raise GitError {
  let adv = upload_pack_info_refs_process(remote, prefer_v2)
  let version = detect_protocol_version(adv)
  if version == ProtocolVersion::V2 {
    let caps = parse_v2_advertised_caps(adv)
    let req = build_ls_refs_request("git/moonbit", [
      "refs/heads/", "refs/tags/", "HEAD",
    ])
    let resp = upload_pack_request_process(remote, req, true)
    let parsed = parse_ls_refs_response(resp)
    return (parsed.refs, caps, version, parsed.symrefs)
  }
  let refs = parse_refs(adv)
  let caps = parse_v0_advertised_caps(adv)
  (refs, caps, ProtocolVersion::V0, {})
}

///|
pub async fn fetch_pack_process(
  remote : String,
  wants : Array[ObjectId],
  prefer_v2 : Bool,
) -> Bytes raise GitError {
  let (refs, caps, version, _symrefs) = discover_upload_refs_process(
    remote, prefer_v2,
  )
  let wants = if wants.length() == 0 {
    let ids : Array[ObjectId] = []
    for item in refs {
      let (id, _) = item
      ids.push(id)
    }
    ids
  } else {
    wants
  }
  if version == ProtocolVersion::V2 {
    // Note: depth=0 means full clone (process transport doesn't support shallow yet)
    let req = build_fetch_request_v2("git/moonbit", wants, 0)
    let resp = upload_pack_request_process(remote, req, true)
    return extract_pack_from_v2_response(resp)
  }
  let req = build_fetch_request_v0("git/moonbit", wants, caps)
  let resp = upload_pack_request_process(remote, req, false)
  extract_pack_from_v0_response(resp)
}

///|
pub async fn fetch_objects_process(
  remote : String,
  wants : Array[ObjectId],
  prefer_v2 : Bool,
) -> Array[PackObject] raise GitError {
  let pack = fetch_pack_process(remote, wants, prefer_v2)
  parse_packfile(pack)
}

///|
pub async fn clone_process(
  remote : String,
  prefer_v2 : Bool,
) -> (Array[(ObjectId, String)], Array[PackObject]) raise GitError {
  let (refs, _caps, _version, symrefs) = discover_upload_refs_process(
    remote, prefer_v2,
  )
  let wants = select_default_wants(refs, symrefs)
  let pack = fetch_pack_process(remote, wants, prefer_v2)
  let objects = parse_packfile(pack)
  (refs, objects)
}

///|
/// Clone and checkout into filesystem under `root`.
pub async fn clone_process_to_fs(
  remote : String,
  prefer_v2 : Bool,
  fs : &FileSystem,
  root : String,
) -> Array[(ObjectId, String)] raise GitError {
  let (refs, _caps, _version, symrefs) = discover_upload_refs_process(
    remote, prefer_v2,
  )
  let wants = select_default_wants(refs, symrefs)
  let default_ref = select_default_ref(refs, symrefs)
  match default_ref {
    None => return refs
    Some(_) => ()
  }
  if wants.length() == 0 {
    return refs
  }
  let pack = fetch_pack_process(remote, wants, prefer_v2)
  let objects = parse_packfile(pack)
  let store = ObjectStore::from_pack(objects)
  match default_ref {
    None => ()
    Some((refname, commit_id)) => {
      materialize_clone_to_fs(store, commit_id, refname, remote, fs, root)
      let git_dir = join_path(root, ".git")
      write_packfile_with_index(fs, git_dir, pack, objects)
    }
  }
  refs
}