#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# GAM7
#
# Copyright 2025, All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
GAM is a command line tool which allows Administrators to control their Google Workspace domain and accounts.

For more information, see:
https://github.com/GAM-team/GAM
https://github.com/GAM-team/GAM/wiki
"""

__author__ = 'GAM Team <google-apps-manager@googlegroups.com>'
__version__ = '7.08.01'
__license__ = 'Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0)'

#pylint: disable=wrong-import-position
import base64
import calendar as calendarlib
import codecs
import collections
import configparser
import csv
import datetime
from email.charset import add_charset, QP
from email.generator import Generator
from email.header import decode_header, Header
from email import message_from_string
from email.mime.application import MIMEApplication
from email.mime.audio import MIMEAudio
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate
from email.policy import SMTP as policySMTP
import hashlib
from html.entities import name2codepoint
from html.parser import HTMLParser
import http.client as http_client
import importlib
from importlib.metadata import version as lib_version
import io
import ipaddress
import json
import logging
from logging.handlers import RotatingFileHandler
import mimetypes
import multiprocessing
import os
import platform
import queue
import random
import re
from secrets import SystemRandom
import shlex
import signal
import smtplib
import socket
import sqlite3
import ssl
import string
import struct
import subprocess
import sys
from tempfile import TemporaryFile
try:
  import termios
except ImportError:
  # termios does not exist for Windows
  pass
import threading
import time
from traceback import print_exc
import types
from urllib.parse import quote, quote_plus, unquote, urlencode, urlparse, parse_qs
import uuid
import warnings
import webbrowser
import wsgiref.simple_server
import wsgiref.util
import zipfile

# disable legacy stuff we don't use and isn't secure
os.environ['CRYPTOGRAPHY_OPENSSL_NO_LEGACY'] = "1"
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID

# 10/2024 - I don't recall why we did this but PyInstaller
# 6.10.0+ does not like it. Only run this when we're not
# Frozen.
if not getattr(sys, 'frozen', False):
  sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))

from dateutil.relativedelta import relativedelta

from pathvalidate import sanitize_filename, sanitize_filepath

import google.oauth2.credentials
import google.oauth2.id_token
import google.auth
from google.auth.jwt import Credentials as JWTCredentials
import google.oauth2.service_account
import google_auth_oauthlib.flow
import google_auth_httplib2
import httplib2

httplib2.RETRIES = 5

from passlib.hash import sha512_crypt
from filelock import FileLock

if platform.system() == 'Linux':
  import distro

from gamlib import glaction
from gamlib import glapi as API
from gamlib import glcfg as GC
from gamlib import glclargs
from gamlib import glentity
from gamlib import glgapi as GAPI
from gamlib import glgdata as GDATA
from gamlib import glglobals as GM
from gamlib import glindent
from gamlib import glmsgs as Msg
from gamlib import glskus as SKU
from gamlib import gluprop as UProp
from gamlib import glverlibs

import gdata.apps.service
import gdata.apps.audit
import gdata.apps.audit.service
import gdata.apps.contacts
import gdata.apps.contacts.service
# Import local library, does not include discovery documents
import googleapiclient
import googleapiclient.discovery
import googleapiclient.errors
import googleapiclient.http
from iso8601 import iso8601

IS08601_TIME_FORMAT = '%Y-%m-%dT%H:%M:%S%:z'
RFC2822_TIME_FORMAT = '%a, %d %b %Y %H:%M:%S %z'

def ISOformatTimeStamp(timestamp):
  return timestamp.isoformat('T', 'seconds')

def currentISOformatTimeStamp(timespec='milliseconds'):
  return datetime.datetime.now(GC.Values[GC.TIMEZONE]).isoformat('T', timespec)

Act = glaction.GamAction()
Cmd = glclargs.GamCLArgs()
Ent = glentity.GamEntity()
Ind = glindent.GamIndent()

# Finding path method varies between Python source, PyInstaller and StaticX
if os.environ.get('STATICX_PROG_PATH', False):
  # StaticX static executable
  GM.Globals[GM.GAM_PATH] = os.path.dirname(os.environ['STATICX_PROG_PATH'])
  GM.Globals[GM.GAM_TYPE] = 'staticx'
elif getattr(sys, 'frozen', False):
  # Pyinstaller executable
  GM.Globals[GM.GAM_PATH] = os.path.dirname(sys.executable)
  GM.Globals[GM.GAM_TYPE] = 'pyinstaller'
else:
  # Source code
  GM.Globals[GM.GAM_PATH] = os.path.dirname(os.path.realpath(__file__))
  GM.Globals[GM.GAM_TYPE] = 'pythonsource'

GIT_USER = 'GAM-team'
GAM = 'GAM'
GAM_URL = f'https://github.com/{GIT_USER}/{GAM}'
GAM_USER_AGENT = (f'{GAM} {__version__} - {GAM_URL} / '
                  f'{__author__} / '
                  f'Python {sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]} {sys.version_info[3]} / '
                  f'{platform.platform()} {platform.machine()} /'
                  )
GAM_RELEASES = f'https://github.com/{GIT_USER}/{GAM}/releases'
GAM_WIKI = f'https://github.com/{GIT_USER}/{GAM}/wiki'
GAM_LATEST_RELEASE = f'https://api.github.com/repos/{GIT_USER}/{GAM}/releases/latest'
GAM_PROJECT_CREATION = 'GAM Project Creation'
GAM_PROJECT_CREATION_CLIENT_ID = '297408095146-fug707qsjv4ikron0hugpevbrjhkmsk7.apps.googleusercontent.com'

TRUE = 'true'
FALSE = 'false'
TRUE_VALUES = [TRUE, 'on', 'yes', 'enabled', '1']
FALSE_VALUES = [FALSE, 'off', 'no', 'disabled', '0']
TRUE_FALSE = [TRUE, FALSE]
ERROR = 'ERROR'
ERROR_PREFIX = ERROR+': '
WARNING = 'WARNING'
WARNING_PREFIX = WARNING+': '
ONE_KILO_10_BYTES = 1000
ONE_MEGA_10_BYTES = 1000000
ONE_GIGA_10_BYTES = 1000000000
ONE_KILO_BYTES = 1024
ONE_MEGA_BYTES = 1048576
ONE_GIGA_BYTES = 1073741824
SECONDS_PER_MINUTE = 60
SECONDS_PER_HOUR = 3600
SECONDS_PER_DAY = 86400
SECONDS_PER_WEEK = 604800
MAX_GOOGLE_SHEET_CELLS = 10000000 # See https://support.google.com/drive/answer/37603
MAX_LOCAL_GOOGLE_TIME_OFFSET = 30
SHARED_DRIVE_MAX_FILES_FOLDERS = 500000
UTF8 = 'utf-8'
UTF8_SIG = 'utf-8-sig'
EV_GAMCFGDIR = 'GAMCFGDIR'
EV_GAMCFGSECTION = 'GAMCFGSECTION'
EV_OLDGAMPATH = 'OLDGAMPATH'
FN_GAM_CFG = 'gam.cfg'
FN_LAST_UPDATE_CHECK_TXT = 'lastupdatecheck.txt'
FN_GAMCOMMANDS_TXT = 'GamCommands.txt'
MY_DRIVE = 'My Drive'
TEAM_DRIVE = 'Drive'
ROOT = 'root'
ROOTID = 'rootid'
ORPHANS = 'Orphans'
SHARED_WITHME = 'SharedWithMe'
SHARED_DRIVES = 'SharedDrives'

LOWERNUMERIC_CHARS = string.ascii_lowercase+string.digits
ALPHANUMERIC_CHARS = LOWERNUMERIC_CHARS+string.ascii_uppercase
URL_SAFE_CHARS = ALPHANUMERIC_CHARS+'-._~'
PASSWORD_SAFE_CHARS = ALPHANUMERIC_CHARS+'!#$%&()*-./:;<=>?@[\\]^_{|}~'
FILENAME_SAFE_CHARS = ALPHANUMERIC_CHARS+'-_.() '
CHAT_MESSAGEID_CHARS = string.ascii_lowercase+string.digits+'-'

ADMIN_ACCESS_OPTIONS = {'adminaccess', 'asadmin'}
OWNER_ACCESS_OPTIONS = {'owneraccess', 'asowner'}

# Python 3 values
DEFAULT_CSV_READ_MODE = 'r'
DEFAULT_FILE_APPEND_MODE = 'a'
DEFAULT_FILE_READ_MODE = 'r'
DEFAULT_FILE_WRITE_MODE = 'w'

# Google API constants
APPLICATION_VND_GOOGLE_APPS = 'application/vnd.google-apps.'
MIMETYPE_GA_DOCUMENT = f'{APPLICATION_VND_GOOGLE_APPS}document'
MIMETYPE_GA_DRAWING = f'{APPLICATION_VND_GOOGLE_APPS}drawing'
MIMETYPE_GA_FILE = f'{APPLICATION_VND_GOOGLE_APPS}file'
MIMETYPE_GA_FOLDER = f'{APPLICATION_VND_GOOGLE_APPS}folder'
MIMETYPE_GA_FORM = f'{APPLICATION_VND_GOOGLE_APPS}form'
MIMETYPE_GA_FUSIONTABLE = f'{APPLICATION_VND_GOOGLE_APPS}fusiontable'
MIMETYPE_GA_JAM = f'{APPLICATION_VND_GOOGLE_APPS}jam'
MIMETYPE_GA_MAP = f'{APPLICATION_VND_GOOGLE_APPS}map'
MIMETYPE_GA_PRESENTATION = f'{APPLICATION_VND_GOOGLE_APPS}presentation'
MIMETYPE_GA_SCRIPT = f'{APPLICATION_VND_GOOGLE_APPS}script'
MIMETYPE_GA_SCRIPT_JSON = f'{APPLICATION_VND_GOOGLE_APPS}script+json'
MIMETYPE_GA_SHORTCUT = f'{APPLICATION_VND_GOOGLE_APPS}shortcut'
MIMETYPE_GA_3P_SHORTCUT = f'{APPLICATION_VND_GOOGLE_APPS}drive-sdk'
MIMETYPE_GA_SITE = f'{APPLICATION_VND_GOOGLE_APPS}site'
MIMETYPE_GA_SPREADSHEET = f'{APPLICATION_VND_GOOGLE_APPS}spreadsheet'
MIMETYPE_TEXT_CSV = 'text/csv'
MIMETYPE_TEXT_HTML = 'text/html'
MIMETYPE_TEXT_PLAIN = 'text/plain'

GOOGLE_NAMESERVERS = ['8.8.8.8', '8.8.4.4']
GOOGLE_TIMECHECK_LOCATION = 'admin.googleapis.com'
NEVER_DATE = '1970-01-01'
NEVER_DATETIME = '1970-01-01 00:00'
NEVER_TIME = '1970-01-01T00:00:00.000Z'
NEVER_TIME_NOMS = '1970-01-01T00:00:00Z'
NEVER_END_DATE = '1969-12-31'
NEVER_START_DATE = NEVER_DATE
PROJECTION_CHOICE_MAP = {'basic': 'BASIC', 'full': 'FULL'}
REFRESH_EXPIRY = '1970-01-01T00:00:01Z'
REPLACE_GROUP_PATTERN = re.compile(r'\\(\d+)')
UNKNOWN = 'Unknown'

# Queries
ME_IN_OWNERS = "'me' in owners"
ME_IN_OWNERS_AND = ME_IN_OWNERS+" and "
AND_ME_IN_OWNERS = " and "+ME_IN_OWNERS
NOT_ME_IN_OWNERS = "not "+ME_IN_OWNERS
NOT_ME_IN_OWNERS_AND = NOT_ME_IN_OWNERS+" and "
AND_NOT_ME_IN_OWNERS = " and "+NOT_ME_IN_OWNERS
ANY_FOLDERS = "mimeType = '"+MIMETYPE_GA_FOLDER+"'"
MY_FOLDERS = ME_IN_OWNERS_AND+ANY_FOLDERS
NON_TRASHED = "trashed = false"
WITH_PARENTS = "'{0}' in parents"
ANY_NON_TRASHED_WITH_PARENTS = "trashed = false and '{0}' in parents"
ANY_NON_TRASHED_FOLDER_NAME = "mimeType = '"+MIMETYPE_GA_FOLDER+"' and name = '{0}' and trashed = false"
MY_NON_TRASHED_FOLDER_NAME = ME_IN_OWNERS_AND+ANY_NON_TRASHED_FOLDER_NAME
MY_NON_TRASHED_FOLDER_NAME_WITH_PARENTS = ME_IN_OWNERS_AND+"mimeType = '"+MIMETYPE_GA_FOLDER+"' and name = '{0}' and trashed = false and '{1}' in parents"
ANY_NON_TRASHED_FOLDER_NAME_WITH_PARENTS = "mimeType = '"+MIMETYPE_GA_FOLDER+"' and name = '{0}' and trashed = false and '{1}' in parents"
WITH_ANY_FILE_NAME = "name = '{0}'"
WITH_MY_FILE_NAME = ME_IN_OWNERS_AND+WITH_ANY_FILE_NAME
WITH_OTHER_FILE_NAME = NOT_ME_IN_OWNERS_AND+WITH_ANY_FILE_NAME
AND_NOT_SHORTCUT = " and mimeType != '"+MIMETYPE_GA_SHORTCUT+"'"

# Program return codes
UNKNOWN_ERROR_RC = 1
USAGE_ERROR_RC = 2
SOCKET_ERROR_RC = 3
GOOGLE_API_ERROR_RC = 4
NETWORK_ERROR_RC = 5
FILE_ERROR_RC = 6
MEMORY_ERROR_RC = 7
KEYBOARD_INTERRUPT_RC = 8
HTTP_ERROR_RC = 9
SCOPES_NOT_AUTHORIZED_RC = 10
DATA_ERROR_RC = 11
API_ACCESS_DENIED_RC = 12
CONFIG_ERROR_RC = 13
SYSTEM_ERROR_RC = 14
NO_SCOPES_FOR_API_RC = 15
CLIENT_SECRETS_JSON_REQUIRED_RC = 16
OAUTH2SERVICE_JSON_REQUIRED_RC = 16
OAUTH2_TXT_REQUIRED_RC = 16
INVALID_JSON_RC = 17
JSON_ALREADY_EXISTS_RC = 17
AUTHENTICATION_TOKEN_REFRESH_ERROR_RC = 18
HARD_ERROR_RC = 19
# Information
ENTITY_IS_A_USER_RC = 20
ENTITY_IS_A_USER_ALIAS_RC = 21
ENTITY_IS_A_GROUP_RC = 22
ENTITY_IS_A_GROUP_ALIAS_RC = 23
ENTITY_IS_AN_UNMANAGED_ACCOUNT_RC = 24
ORGUNIT_NOT_EMPTY_RC = 25
CHECK_USER_GROUPS_ERROR_RC = 29
ORPHANS_COLLECTED_RC = 30
# Warnings/Errors
ACTION_FAILED_RC = 50
ACTION_NOT_PERFORMED_RC = 51
INVALID_ENTITY_RC = 52
BAD_REQUEST_RC = 53
ENTITY_IS_NOT_UNIQUE_RC = 54
DATA_NOT_AVALIABLE_RC = 55
ENTITY_DOES_NOT_EXIST_RC = 56
ENTITY_DUPLICATE_RC = 57
ENTITY_IS_NOT_AN_ALIAS_RC = 58
ENTITY_IS_UKNOWN_RC = 59
NO_ENTITIES_FOUND_RC = 60
INVALID_DOMAIN_RC = 61
INVALID_DOMAIN_VALUE_RC = 62
INVALID_TOKEN_RC = 63
JSON_LOADS_ERROR_RC = 64
MULTIPLE_DELETED_USERS_FOUND_RC = 65
MULTIPLE_PROJECT_FOLDERS_FOUND_RC = 65
STDOUT_STDERR_ERROR_RC = 66
INSUFFICIENT_PERMISSIONS_RC = 67
REQUEST_COMPLETED_NO_RESULTS_RC = 71
REQUEST_NOT_COMPLETED_RC = 72
SERVICE_NOT_APPLICABLE_RC = 73
TARGET_DRIVE_SPACE_ERROR_RC = 74
USER_REQUIRED_TO_CHANGE_PASSWORD_ERROR_RC = 75
USER_SUSPENDED_ERROR_RC = 76
NO_CSV_DATA_TO_UPLOAD_RC = 77
NO_SA_ACCESS_CONTEXT_MANAGER_EDITOR_ROLE_RC = 78
ACCESS_POLICY_ERROR_RC = 79
YUBIKEY_CONNECTION_ERROR_RC = 80
YUBIKEY_INVALID_KEY_TYPE_RC = 81
YUBIKEY_INVALID_SLOT_RC = 82
YUBIKEY_INVALID_PIN_RC = 83
YUBIKEY_APDU_ERROR_RC = 84
YUBIKEY_VALUE_ERROR_RC = 85
YUBIKEY_MULTIPLE_CONNECTED_RC = 86
YUBIKEY_NOT_FOUND_RC = 87

# Multiprocessing lock
mplock = None

# stdin/stdout/stderr
def readStdin(prompt):
  return input(prompt)

def stdErrorExit(e):
  try:
    sys.stderr.write(f'\n{ERROR_PREFIX}{str(e)}\n')
  except IOError:
    pass
  sys.exit(STDOUT_STDERR_ERROR_RC)

def writeStdout(data):
  try:
    GM.Globals[GM.STDOUT].get(GM.REDIRECT_MULTI_FD, sys.stdout).write(data)
  except IOError as e:
    stdErrorExit(e)

def flushStdout():
  try:
    GM.Globals[GM.STDOUT].get(GM.REDIRECT_MULTI_FD, sys.stdout).flush()
  except IOError as e:
    stdErrorExit(e)

def writeStderr(data):
  flushStdout()
  try:
    GM.Globals[GM.STDERR].get(GM.REDIRECT_MULTI_FD, sys.stderr).write(data)
  except IOError as e:
    stdErrorExit(e)

def flushStderr():
  try:
    GM.Globals[GM.STDERR].get(GM.REDIRECT_MULTI_FD, sys.stderr).flush()
  except IOError as e:
    stdErrorExit(e)

# Error messages
def setSysExitRC(sysRC):
  GM.Globals[GM.SYSEXITRC] = sysRC

def stderrErrorMsg(message):
  writeStderr(f'\n{ERROR_PREFIX}{message}\n')

def stderrWarningMsg(message):
  writeStderr(f'\n{WARNING_PREFIX}{message}\n')

def systemErrorExit(sysRC, message):
  if message:
    stderrErrorMsg(message)
  sys.exit(sysRC)

def printErrorMessage(sysRC, message):
  setSysExitRC(sysRC)
  writeStderr(f'\n{Ind.Spaces()}{ERROR_PREFIX}{message}\n')

def printWarningMessage(sysRC, message):
  setSysExitRC(sysRC)
  writeStderr(f'\n{Ind.Spaces()}{WARNING_PREFIX}{message}\n')

def supportsColoredText():
  """Determines if the current terminal environment supports colored text.

  Returns:
    Bool, True if the current terminal environment supports colored text via
    ANSI escape characters.
  """
  # Make a rudimentary check for Windows. Though Windows does seem to support
  # colorization with VT100 emulation, it is disabled by default. Therefore,
  # we'll simply disable it in GAM on Windows for now.
  return not sys.platform.startswith('win')

def createColoredText(text, color):
  """Uses ANSI escape characters to create colored text in supported terminals.

  See more at https://en.wikipedia.org/wiki/ANSI_escape_code#Colors

  Args:
    text: String, The text to colorize using ANSI escape characters.
    color: String, An ANSI escape sequence denoting the color of the text to be
      created. See more at https://en.wikipedia.org/wiki/ANSI_escape_code#Colors

  Returns:
    The input text with appropriate ANSI escape characters to create
    colorization in a supported terminal environment.
  """
  END_COLOR_SEQUENCE = '\033[0m'  # Ends the applied color formatting
  if supportsColoredText():
    return color + text + END_COLOR_SEQUENCE
  return text  # Hand back the plain text, uncolorized.

def createRedText(text):
  """Uses ANSI encoding to create red colored text if supported."""
  return createColoredText(text, '\033[91m')

def createGreenText(text):
  """Uses ANSI encoding to create green colored text if supported."""
  return createColoredText(text, '\u001b[32m')

def createYellowText(text):
  """Uses ANSI encoding to create yellow text if supported."""
  return createColoredText(text, '\u001b[33m')

def executeBatch(dbatch):
  dbatch.execute()
  if GC.Values[GC.INTER_BATCH_WAIT] > 0:
    time.sleep(GC.Values[GC.INTER_BATCH_WAIT])

def _stripControlCharsFromName(name):
  for cc in ['\x00', '\r', '\n']:
    name = name.replace(cc, '')
  return name

class LazyLoader(types.ModuleType):
  """Lazily import a module, mainly to avoid pulling in large dependencies.

  `contrib`, and `ffmpeg` are examples of modules that are large and not always
  needed, and this allows them to only be loaded when they are used.
  """

  # The lint error here is incorrect.
  def __init__(self, local_name, parent_module_globals, name):
    self._local_name = local_name
    self._parent_module_globals = parent_module_globals

    super().__init__(name)

  def _load(self):
    # Import the target module and insert it into the parent's namespace
    module = importlib.import_module(self.__name__)
    self._parent_module_globals[self._local_name] = module

    # Update this object's dict so that if someone keeps a reference to the
    #   LazyLoader, lookups are efficient (__getattr__ is only called on lookups
    #   that fail).
    self.__dict__.update(module.__dict__)

    return module

  def __getattr__(self, item):
    module = self._load()
    return getattr(module, item)

  def __dir__(self):
    module = self._load()
    return dir(module)

yubikey = LazyLoader('yubikey', globals(), 'gam.gamlib.yubikey')

# gam yubikey resetpvi [yubikey_serialnumber <String>]
def doResetYubiKeyPIV():
  new_data = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'yubikeyserialnumber':
      new_data['yubikey_serial_number'] =  getInteger()
    else:
      unknownArgumentExit()
  yk = yubikey.YubiKey(new_data)
  yk.serial_number = yk.get_serial_number()
  yk.reset_piv()

class _DeHTMLParser(HTMLParser): #pylint: disable=abstract-method
  def __init__(self):
    HTMLParser.__init__(self)
    self.__text = []

  def handle_data(self, data):
    self.__text.append(data)

  def handle_charref(self, name):
    self.__text.append(chr(int(name[1:], 16)) if name.startswith('x') else chr(int(name)))

  def handle_entityref(self, name):
    cp = name2codepoint.get(name)
    if cp:
      self.__text.append(chr(cp))
    else:
      self.__text.append('&'+name)

  def handle_starttag(self, tag, attrs):
    if tag == 'p':
      self.__text.append('\n\n')
    elif tag == 'br':
      self.__text.append('\n')
    elif tag == 'a':
      for attr in attrs:
        if attr[0] == 'href':
          self.__text.append(f'({attr[1]}) ')
          break
    elif tag == 'div':
      if not attrs:
        self.__text.append('\n')
    elif tag in {'http:', 'https'}:
      self.__text.append(f' ({tag}//{attrs[0][0]}) ')

  def handle_startendtag(self, tag, attrs):
    if tag == 'br':
      self.__text.append('\n\n')

  def text(self):
    return re.sub(r'\n{2}\n+', '\n\n', re.sub(r'\n +', '\n', ''.join(self.__text))).strip()

def dehtml(text):
  parser = _DeHTMLParser()
  parser.feed(str(text))
  parser.close()
  return parser.text()

def currentCount(i, count):
  return f' ({i}/{count})' if (count > GC.Values[GC.SHOW_COUNTS_MIN]) else ''

def currentCountNL(i, count):
  return f' ({i}/{count})\n' if (count > GC.Values[GC.SHOW_COUNTS_MIN]) else '\n'

# Format a key value list
#   key, value	-> "key: value" + ", " if not last item
#   key, ''	-> "key:" + ", " if not last item
#   key, None	-> "key" + " " if not last item
def formatKeyValueList(prefixStr, kvList, suffixStr):
  msg = prefixStr
  i = 0
  l = len(kvList)
  while i < l:
    if isinstance(kvList[i], (bool, float, int)):
      msg += str(kvList[i])
    else:
      msg += kvList[i]
    i += 1
    if i < l:
      val = kvList[i]
      if (val is not None) or (i == l-1):
        msg += ':'
        if (val is not None) and (not isinstance(val, str) or val):
          msg += ' '
          if isinstance(val, (bool, float, int)):
            msg += str(val)
          else:
            msg += val
        i += 1
        if i < l:
          msg += ', '
      else:
        i += 1
        if i < l:
          msg += ' '
  msg += suffixStr
  return msg

# Something's wrong with CustomerID??
def accessErrorMessage(cd, errMsg=None):
  if cd is None:
    cd = buildGAPIObject(API.DIRECTORY)
  try:
    callGAPI(cd.customers(), 'get',
             throwReasons=[GAPI.BAD_REQUEST, GAPI.INVALID_INPUT, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
             customerKey=GC.Values[GC.CUSTOMER_ID], fields='id')
  except (GAPI.badRequest, GAPI.invalidInput):
    return formatKeyValueList('',
                              [Ent.Singular(Ent.CUSTOMER_ID), GC.Values[GC.CUSTOMER_ID],
                               Msg.INVALID],
                              '')
  except GAPI.resourceNotFound:
    return formatKeyValueList('',
                              [Ent.Singular(Ent.CUSTOMER_ID), GC.Values[GC.CUSTOMER_ID],
                               Msg.DOES_NOT_EXIST],
                              '')
  except GAPI.forbidden:
    return formatKeyValueList('',
                              Ent.FormatEntityValueList([Ent.CUSTOMER_ID, GC.Values[GC.CUSTOMER_ID],
                                                         Ent.DOMAIN, GC.Values[GC.DOMAIN],
                                                         Ent.USER, GM.Globals[GM.ADMIN]])+[Msg.ACCESS_FORBIDDEN],
                              '')
  if errMsg:
    return formatKeyValueList('',
                              [Ent.Singular(Ent.CUSTOMER_ID), GC.Values[GC.CUSTOMER_ID],
                               errMsg],
                              '')
  return None

def accessErrorExit(cd, errMsg=None):
  systemErrorExit(INVALID_DOMAIN_RC, accessErrorMessage(cd or buildGAPIObject(API.DIRECTORY), errMsg))

def accessErrorExitNonDirectory(api, errMsg):
  systemErrorExit(API_ACCESS_DENIED_RC,
                  formatKeyValueList('',
                                     Ent.FormatEntityValueList([Ent.CUSTOMER_ID, GC.Values[GC.CUSTOMER_ID],
                                                                Ent.DOMAIN, GC.Values[GC.DOMAIN],
                                                                Ent.API, api])+[errMsg],
                                     ''))

def ClientAPIAccessDeniedExit(errMsg=None):
  stderrErrorMsg(Msg.API_ACCESS_DENIED)
  if errMsg:
    stderrErrorMsg(errMsg)
  missingScopes = API.getClientScopesSet(GM.Globals[GM.CURRENT_CLIENT_API])-GM.Globals[GM.CURRENT_CLIENT_API_SCOPES]
  if missingScopes:
    writeStderr(Msg.API_CHECK_CLIENT_AUTHORIZATION.format(GM.Globals[GM.OAUTH2_CLIENT_ID],
                                                          ','.join(sorted(missingScopes))))
  systemErrorExit(API_ACCESS_DENIED_RC, None)

def SvcAcctAPIAccessDenied():
  _getSvcAcctData()
  if (GM.Globals[GM.CURRENT_SVCACCT_API] == API.GMAIL and
      GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES] and
      GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES][0] == API.GMAIL_SEND_SCOPE):
    systemErrorExit(OAUTH2SERVICE_JSON_REQUIRED_RC, Msg.NO_SVCACCT_ACCESS_ALLOWED)
  stderrErrorMsg(Msg.API_ACCESS_DENIED)
  apiOrScopes = API.getAPIName(GM.Globals[GM.CURRENT_SVCACCT_API]) if GM.Globals[GM.CURRENT_SVCACCT_API] else ','.join(sorted(GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES]))
  writeStderr(Msg.API_CHECK_SVCACCT_AUTHORIZATION.format(GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['client_id'],
                                                         apiOrScopes,
                                                         GM.Globals[GM.CURRENT_SVCACCT_USER] or _getAdminEmail()))
def SvcAcctAPIAccessDeniedExit():
  SvcAcctAPIAccessDenied()
  systemErrorExit(API_ACCESS_DENIED_RC, None)

def SvcAcctAPIDisabledExit():
  if not GM.Globals[GM.CURRENT_SVCACCT_USER] and GM.Globals[GM.CURRENT_CLIENT_API]:
    ClientAPIAccessDeniedExit()
  if GM.Globals[GM.CURRENT_SVCACCT_API]:
    stderrErrorMsg(Msg.SERVICE_ACCOUNT_API_DISABLED.format(API.getAPIName(GM.Globals[GM.CURRENT_SVCACCT_API])))
    systemErrorExit(API_ACCESS_DENIED_RC, None)
  systemErrorExit(API_ACCESS_DENIED_RC, Msg.API_ACCESS_DENIED)

def APIAccessDeniedExit():
  if not GM.Globals[GM.CURRENT_SVCACCT_USER] and GM.Globals[GM.CURRENT_CLIENT_API]:
    ClientAPIAccessDeniedExit()
  if GM.Globals[GM.CURRENT_SVCACCT_API]:
    SvcAcctAPIAccessDeniedExit()
  systemErrorExit(API_ACCESS_DENIED_RC, Msg.API_ACCESS_DENIED)

def checkEntityDNEorAccessErrorExit(cd, entityType, entityName, i=0, count=0):
  message = accessErrorMessage(cd)
  if message:
    systemErrorExit(INVALID_DOMAIN_RC, message)
  entityDoesNotExistWarning(entityType, entityName, i, count)

def checkEntityAFDNEorAccessErrorExit(cd, entityType, entityName, i=0, count=0):
  message = accessErrorMessage(cd)
  if message:
    systemErrorExit(INVALID_DOMAIN_RC, message)
  entityActionFailedWarning([entityType, entityName], Msg.DOES_NOT_EXIST, i, count)

def checkEntityItemValueAFDNEorAccessErrorExit(cd, entityType, entityName, itemType, itemValue, i=0, count=0):
  message = accessErrorMessage(cd)
  if message:
    systemErrorExit(INVALID_DOMAIN_RC, message)
  entityActionFailedWarning([entityType, entityName, itemType, itemValue], Msg.DOES_NOT_EXIST, i, count)

def invalidClientSecretsJsonExit(errMsg):
  stderrErrorMsg(Msg.DOES_NOT_EXIST_OR_HAS_INVALID_FORMAT.format(Ent.Singular(Ent.CLIENT_SECRETS_JSON_FILE), GC.Values[GC.CLIENT_SECRETS_JSON], errMsg))
  writeStderr(Msg.INSTRUCTIONS_CLIENT_SECRETS_JSON)
  systemErrorExit(CLIENT_SECRETS_JSON_REQUIRED_RC, None)

def invalidOauth2serviceJsonExit(errMsg):
  stderrErrorMsg(Msg.DOES_NOT_EXIST_OR_HAS_INVALID_FORMAT.format(Ent.Singular(Ent.OAUTH2SERVICE_JSON_FILE), GC.Values[GC.OAUTH2SERVICE_JSON], errMsg))
  writeStderr(Msg.INSTRUCTIONS_OAUTH2SERVICE_JSON)
  systemErrorExit(OAUTH2SERVICE_JSON_REQUIRED_RC, None)

def invalidOauth2TxtExit(errMsg):
  stderrErrorMsg(Msg.DOES_NOT_EXIST_OR_HAS_INVALID_FORMAT.format(Ent.Singular(Ent.OAUTH2_TXT_FILE), GC.Values[GC.OAUTH2_TXT], errMsg))
  writeStderr(Msg.EXECUTE_GAM_OAUTH_CREATE)
  systemErrorExit(OAUTH2_TXT_REQUIRED_RC, None)

def expiredRevokedOauth2TxtExit():
  stderrErrorMsg(Msg.IS_EXPIRED_OR_REVOKED.format(Ent.Singular(Ent.OAUTH2_TXT_FILE), GC.Values[GC.OAUTH2_TXT]))
  writeStderr(Msg.EXECUTE_GAM_OAUTH_CREATE)
  systemErrorExit(OAUTH2_TXT_REQUIRED_RC, None)

def invalidDiscoveryJsonExit(fileName, errMsg):
  stderrErrorMsg(Msg.DOES_NOT_EXIST_OR_HAS_INVALID_FORMAT.format(Ent.Singular(Ent.DISCOVERY_JSON_FILE), fileName, errMsg))
  systemErrorExit(INVALID_JSON_RC, None)

def entityActionFailedExit(entityValueList, errMsg, i=0, count=0):
  systemErrorExit(ACTION_FAILED_RC, formatKeyValueList(Ind.Spaces(),
                                                       Ent.FormatEntityValueList(entityValueList)+[Act.Failed(), errMsg],
                                                       currentCountNL(i, count)))

def entityDoesNotExistExit(entityType, entityName, i=0, count=0, errMsg=None):
  Cmd.Backup()
  writeStderr(Cmd.CommandLineWithBadArgumentMarked(False))
  systemErrorExit(ENTITY_DOES_NOT_EXIST_RC, formatKeyValueList(Ind.Spaces(),
                                                               [Ent.Singular(entityType), entityName, errMsg or Msg.DOES_NOT_EXIST],
                                                               currentCountNL(i, count)))

def entityDoesNotHaveItemExit(entityValueList, i=0, count=0):
  Cmd.Backup()
  writeStderr(Cmd.CommandLineWithBadArgumentMarked(False))
  systemErrorExit(ENTITY_DOES_NOT_EXIST_RC, formatKeyValueList(Ind.Spaces(),
                                                               Ent.FormatEntityValueList(entityValueList)+[Msg.DOES_NOT_EXIST],
                                                               currentCountNL(i, count)))

def entityIsNotUniqueExit(entityType, entityName, valueType, valueList, i=0, count=0):
  Cmd.Backup()
  writeStderr(Cmd.CommandLineWithBadArgumentMarked(False))
  systemErrorExit(ENTITY_IS_NOT_UNIQUE_RC, formatKeyValueList(Ind.Spaces(),
                                                              [Ent.Singular(entityType), entityName, Msg.IS_NOT_UNIQUE.format(Ent.Plural(valueType), ','.join(valueList))],
                                                              currentCountNL(i, count)))

def usageErrorExit(message, extraneous=False):
  writeStderr(Cmd.CommandLineWithBadArgumentMarked(extraneous))
  stderrErrorMsg(message)
  writeStderr(Msg.HELP_SYNTAX.format(os.path.join(GM.Globals[GM.GAM_PATH], FN_GAMCOMMANDS_TXT)))
  writeStderr(Msg.HELP_WIKI.format(GAM_WIKI))
  sys.exit(USAGE_ERROR_RC)

def csvFieldErrorExit(fieldName, fieldNames, backupArg=False, checkForCharset=False):
  if backupArg:
    Cmd.Backup()
    if checkForCharset and Cmd.Previous() == 'charset':
      Cmd.Backup()
      Cmd.Backup()
  usageErrorExit(Msg.HEADER_NOT_FOUND_IN_CSV_HEADERS.format(fieldName, ','.join(fieldNames)))

def csvDataAlreadySavedErrorExit():
  Cmd.Backup()
  usageErrorExit(Msg.CSV_DATA_ALREADY_SAVED)

# The last thing shown is unknown
def unknownArgumentExit():
  Cmd.Backup()
  usageErrorExit(Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_INVALID][1])

# Argument describes what's expected
def expectedArgumentExit(problem, argument):
  usageErrorExit(f'{problem}: {Msg.EXPECTED} <{argument}>')

def blankArgumentExit(argument):
  expectedArgumentExit(Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_BLANK][1], f'{Msg.NON_BLANK} {argument}')

def emptyArgumentExit(argument):
  expectedArgumentExit(Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_EMPTY][1], f'{Msg.NON_EMPTY} {argument}')

def invalidArgumentExit(argument):
  expectedArgumentExit(Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_INVALID][1], argument)

def missingArgumentExit(argument):
  expectedArgumentExit(Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_MISSING][1], argument)

def deprecatedArgument(argument):
  Cmd.Backup()
  writeStderr(Cmd.CommandLineWithBadArgumentMarked(False))
  Cmd.Advance()
  stderrWarningMsg(f'{Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_DEPRECATED][1]}: {Msg.IGNORED} <{argument}>')

def deprecatedArgumentExit(argument):
  usageErrorExit(f'{Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_DEPRECATED][1]}: <{argument}>')

def deprecatedCommandExit():
  systemErrorExit(USAGE_ERROR_RC, Msg.SITES_COMMAND_DEPRECATED.format(Cmd.CommandDeprecated()))

# Choices is the valid set of choices that was expected
def formatChoiceList(choices):
  choiceList = [c if c else "''" for c in choices]
  if len(choiceList) <= 5:
    return '|'.join(choiceList)
  return '|'.join(sorted(choiceList))

def invalidChoiceExit(choice, choices, backupArg):
  if backupArg:
    Cmd.Backup()
  expectedArgumentExit(Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_INVALID_CHOICE][1].format(choice), formatChoiceList(choices))

def missingChoiceExit(choices):
  expectedArgumentExit(Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_MISSING][1], formatChoiceList(choices))

# Check if argument present
def checkArgumentPresent(choices, required=False):
  choiceList = choices if isinstance(choices, (list, set)) else [choices]
  if Cmd.ArgumentsRemaining():
    choice = Cmd.Current().strip().lower().replace('_', '')
    if choice:
      if choice in choiceList:
        Cmd.Advance()
        return True
    if not required:
      return False
    invalidChoiceExit(choice, choiceList, False)
  elif not required:
    return False
  missingChoiceExit(choiceList)

# Check that there are no extraneous arguments at the end of the command line
def checkForExtraneousArguments():
  if Cmd.ArgumentsRemaining():
    usageErrorExit(Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_EXTRANEOUS][[1, 0][Cmd.MultipleArgumentsRemaining()]], extraneous=True)

# Check that an argument remains, get an argument, downshift, delete underscores
def checkGetArgument():
  if Cmd.ArgumentsRemaining():
    argument = Cmd.Current().lower()
    if argument:
      Cmd.Advance()
      return argument.replace('_', '')
  missingArgumentExit(Cmd.OB_ARGUMENT)

# Get an argument, downshift, delete underscores
def getArgument():
  argument = Cmd.Current().lower()
  if argument:
    Cmd.Advance()
    return argument.replace('_', '')
  missingArgumentExit(Cmd.OB_ARGUMENT)

# Get an argument, downshift, delete underscores
# An empty argument is allowed
def getArgumentEmptyAllowed():
  argument = Cmd.Current().lower()
  Cmd.Advance()
  return argument.replace('_', '')

def getACLRoles(aclRolesMap):
  roles = []
  for role in getString(Cmd.OB_ROLE_LIST, minLen=0).strip().lower().replace(',', ' ').split():
    if role == 'all':
      for arole in aclRolesMap:
        roles.append(aclRolesMap[arole])
    elif role in aclRolesMap:
      roles.append(aclRolesMap[role])
    else:
      invalidChoiceExit(role, aclRolesMap, True)
  return set(roles)

def getBoolean(defaultValue=True):
  if Cmd.ArgumentsRemaining():
    boolean = Cmd.Current().strip().lower()
    if boolean in TRUE_VALUES:
      Cmd.Advance()
      return True
    if boolean in FALSE_VALUES:
      Cmd.Advance()
      return False
    if defaultValue is not None:
      if not Cmd.Current().strip(): # If current argument is empty, skip over it
        Cmd.Advance()
      return defaultValue
    invalidChoiceExit(boolean, TRUE_FALSE, False)
  if defaultValue is not None:
    return defaultValue
  missingChoiceExit(TRUE_FALSE)

def getCharSet():
  if checkArgumentPresent('charset'):
    return getString(Cmd.OB_CHAR_SET)
  return GC.Values[GC.CHARSET]

DEFAULT_CHOICE = 'defaultChoice'
CHOICE_ALIASES = 'choiceAliases'
MAP_CHOICE = 'mapChoice'
NO_DEFAULT = 'NoDefault'

def getChoice(choices, **opts):
  if Cmd.ArgumentsRemaining():
    choice = Cmd.Current().strip().lower()
    if choice or '' in choices:
      if choice in opts.get(CHOICE_ALIASES, []):
        choice = opts[CHOICE_ALIASES][choice]
      if choice not in choices:
        choice = choice.replace('_', '').replace('-', '')
        if choice in opts.get(CHOICE_ALIASES, []):
          choice = opts[CHOICE_ALIASES][choice]
      if choice in choices:
        Cmd.Advance()
        return choice if not opts.get(MAP_CHOICE, False) else choices[choice]
    if opts.get(DEFAULT_CHOICE, NO_DEFAULT) != NO_DEFAULT:
      return opts[DEFAULT_CHOICE]
    invalidChoiceExit(choice, choices, False)
  elif opts.get(DEFAULT_CHOICE, NO_DEFAULT) != NO_DEFAULT:
    return opts[DEFAULT_CHOICE]
  missingChoiceExit(choices)

def getChoiceAndValue(item, choices, delimiter):
  if not Cmd.ArgumentsRemaining() or Cmd.Current().find(delimiter) == -1:
    return (None, None)
  choice, value = Cmd.Current().strip().split(delimiter, 1)
  choice = choice.strip().lower()
  value = value.strip()
  if choice in choices:
    if value:
      Cmd.Advance()
      return (choice, value)
    missingArgumentExit(item)
  invalidChoiceExit(choice, choices, False)

SUSPENDED_ARGUMENTS = {'notsuspended', 'suspended', 'issuspended'}
SUSPENDED_CHOICE_MAP = {'notsuspended': False, 'suspended': True}
def _getIsSuspended(myarg):
  if myarg in SUSPENDED_CHOICE_MAP:
    return SUSPENDED_CHOICE_MAP[myarg]
  return getBoolean()

ARCHIVED_ARGUMENTS = {'notarchived', 'archived', 'isarchived'}
ARCHIVED_CHOICE_MAP = {'notarchived': False, 'archived': True}
def _getIsArchived(myarg):
  if myarg in ARCHIVED_CHOICE_MAP:
    return ARCHIVED_CHOICE_MAP[myarg]
  return getBoolean()

def _getOptionalIsSuspendedIsArchived():
  isSuspended = isArchived = None
  while True:
    if Cmd.PeekArgumentPresent(SUSPENDED_ARGUMENTS):
      isSuspended = getChoice(SUSPENDED_CHOICE_MAP, defaultChoice=None, mapChoice=True)
      if isSuspended is None:
        isSuspended = getBoolean()
    elif Cmd.PeekArgumentPresent(ARCHIVED_ARGUMENTS):
      isArchived = getChoice(ARCHIVED_CHOICE_MAP, defaultChoice=None, mapChoice=True)
      if isArchived is None:
        isArchived = getBoolean()
    else:
      break
  return isSuspended, isArchived

CALENDAR_COLOR_MAP = {
  'amethyst': 24, 'avocado': 10, 'banana': 12, 'basil': 8, 'birch': 20, 'blueberry': 16,
  'cherryblossom': 22, 'citron': 11, 'cobalt': 15, 'cocoa': 1, 'eucalyptus': 7, 'flamingo': 2,
  'grape': 23, 'graphite': 19, 'lavender': 17, 'mango': 6, 'peacock': 14, 'pistachio': 9,
  'pumpkin': 5, 'radicchio': 21, 'sage': 13, 'tangerine': 4, 'tomato': 3, 'wisteria': 18,
  }

CALENDAR_EVENT_COLOR_MAP = {
  'banana': 5, 'basil': 10, 'blueberry': 9, 'flamingo': 4, 'graphite': 8, 'grape': 3,
  'lavender': 1, 'peacock': 7, 'sage': 2, 'tangerine': 6, 'tomato': 11,
  }

GOOGLE_COLOR_MAP = {
  'asparagus': '#7bd148', 'bluevelvet': '#9a9cff', 'bubblegum': '#f691b2', 'cardinal': '#f83a22',
  'chocolateicecream': '#ac725e', 'denim': '#9fc6e7', 'desertsand': '#fbe983', 'earthworm': '#cca6ac',
  'macaroni': '#fad165', 'marsorange': '#ff7537', 'mountaingray': '#cabdbf', 'mountaingrey': '#cabdbf',
  'mouse': '#8f8f8f', 'oldbrickred': '#d06b64', 'pool': '#9fe1e7', 'purpledino': '#b99aff',
  'purplerain': '#cd74e6', 'rainysky': '#4986e7', 'seafoam': '#92e1c0', 'slimegreen': '#b3dc6c',
  'spearmint': '#42d692', 'toyeggplant': '#a47ae2', 'vernfern': '#16a765', 'wildstrawberries': '#fa573c',
  'yellowcab': '#ffad46',
  }

WEB_COLOR_MAP = {
  'aliceblue': '#f0f8ff', 'antiquewhite': '#faebd7', 'aqua': '#00ffff', 'aquamarine': '#7fffd4',
  'azure': '#f0ffff', 'beige': '#f5f5dc', 'bisque': '#ffe4c4', 'black': '#000000',
  'blanchedalmond': '#ffebcd', 'blue': '#0000ff', 'blueviolet': '#8a2be2', 'brown': '#a52a2a',
  'burlywood': '#deb887', 'cadetblue': '#5f9ea0', 'chartreuse': '#7fff00', 'chocolate': '#d2691e',
  'coral': '#ff7f50', 'cornflowerblue': '#6495ed', 'cornsilk': '#fff8dc', 'crimson': '#dc143c',
  'cyan': '#00ffff', 'darkblue': '#00008b', 'darkcyan': '#008b8b', 'darkgoldenrod': '#b8860b',
  'darkgray': '#a9a9a9', 'darkgrey': '#a9a9a9', 'darkgreen': '#006400', 'darkkhaki': '#bdb76b',
  'darkmagenta': '#8b008b', 'darkolivegreen': '#556b2f', 'darkorange': '#ff8c00', 'darkorchid': '#9932cc',
  'darkred': '#8b0000', 'darksalmon': '#e9967a', 'darkseagreen': '#8fbc8f', 'darkslateblue': '#483d8b',
  'darkslategray': '#2f4f4f', 'darkslategrey': '#2f4f4f', 'darkturquoise': '#00ced1', 'darkviolet': '#9400d3',
  'deeppink': '#ff1493', 'deepskyblue': '#00bfff', 'dimgray': '#696969', 'dimgrey': '#696969',
  'dodgerblue': '#1e90ff', 'firebrick': '#b22222', 'floralwhite': '#fffaf0', 'forestgreen': '#228b22',
  'fuchsia': '#ff00ff', 'gainsboro': '#dcdcdc', 'ghostwhite': '#f8f8ff', 'gold': '#ffd700',
  'goldenrod': '#daa520', 'gray': '#808080', 'grey': '#808080', 'green': '#008000',
  'greenyellow': '#adff2f', 'honeydew': '#f0fff0', 'hotpink': '#ff69b4', 'indianred': '#cd5c5c',
  'indigo': '#4b0082', 'ivory': '#fffff0', 'khaki': '#f0e68c', 'lavender': '#e6e6fa',
  'lavenderblush': '#fff0f5', 'lawngreen': '#7cfc00', 'lemonchiffon': '#fffacd', 'lightblue': '#add8e6',
  'lightcoral': '#f08080', 'lightcyan': '#e0ffff', 'lightgoldenrodyellow': '#fafad2', 'lightgray': '#d3d3d3',
  'lightgrey': '#d3d3d3', 'lightgreen': '#90ee90', 'lightpink': '#ffb6c1', 'lightsalmon': '#ffa07a',
  'lightseagreen': '#20b2aa', 'lightskyblue': '#87cefa', 'lightslategray': '#778899', 'lightslategrey': '#778899',
  'lightsteelblue': '#b0c4de', 'lightyellow': '#ffffe0', 'lime': '#00ff00', 'limegreen': '#32cd32',
  'linen': '#faf0e6', 'magenta': '#ff00ff', 'maroon': '#800000', 'mediumaquamarine': '#66cdaa',
  'mediumblue': '#0000cd', 'mediumorchid': '#ba55d3', 'mediumpurple': '#9370db', 'mediumseagreen': '#3cb371',
  'mediumslateblue': '#7b68ee', 'mediumspringgreen': '#00fa9a', 'mediumturquoise': '#48d1cc', 'mediumvioletred': '#c71585',
  'midnightblue': '#191970', 'mintcream': '#f5fffa', 'mistyrose': '#ffe4e1', 'moccasin': '#ffe4b5',
  'navajowhite': '#ffdead', 'navy': '#000080', 'oldlace': '#fdf5e6', 'olive': '#808000',
  'olivedrab': '#6b8e23', 'orange': '#ffa500', 'orangered': '#ff4500', 'orchid': '#da70d6',
  'palegoldenrod': '#eee8aa', 'palegreen': '#98fb98', 'paleturquoise': '#afeeee', 'palevioletred': '#db7093',
  'papayawhip': '#ffefd5', 'peachpuff': '#ffdab9', 'peru': '#cd853f', 'pink': '#ffc0cb',
  'plum': '#dda0dd', 'powderblue': '#b0e0e6', 'purple': '#800080', 'red': '#ff0000',
  'rosybrown': '#bc8f8f', 'royalblue': '#4169e1', 'saddlebrown': '#8b4513', 'salmon': '#fa8072',
  'sandybrown': '#f4a460', 'seagreen': '#2e8b57', 'seashell': '#fff5ee', 'sienna': '#a0522d',
  'silver': '#c0c0c0', 'skyblue': '#87ceeb', 'slateblue': '#6a5acd', 'slategray': '#708090',
  'slategrey': '#708090', 'snow': '#fffafa', 'springgreen': '#00ff7f', 'steelblue': '#4682b4',
  'tan': '#d2b48c', 'teal': '#008080', 'thistle': '#d8bfd8', 'tomato': '#ff6347',
  'turquoise': '#40e0d0', 'violet': '#ee82ee', 'wheat': '#f5deb3', 'white': '#ffffff',
  'whitesmoke': '#f5f5f5', 'yellow': '#ffff00', 'yellowgreen': '#9acd32',
  }

COLORHEX_PATTERN = re.compile(r'^#[0-9a-fA-F]{6}$')
COLORHEX_FORMAT_REQUIRED = 'ColorName|ColorHex'

def getColor():
  if Cmd.ArgumentsRemaining():
    color = Cmd.Current().strip().lower()
    if color in GOOGLE_COLOR_MAP:
      Cmd.Advance()
      return GOOGLE_COLOR_MAP[color]
    if color in WEB_COLOR_MAP:
      Cmd.Advance()
      return WEB_COLOR_MAP[color]
    tg = COLORHEX_PATTERN.match(color)
    if tg:
      Cmd.Advance()
      return tg.group(0)
    invalidArgumentExit(COLORHEX_FORMAT_REQUIRED)
  missingArgumentExit(COLORHEX_FORMAT_REQUIRED)

LABEL_COLORS = [
  '#000000', '#076239', '#0b804b', '#149e60', '#16a766', '#1a764d', '#1c4587', '#285bac',
  '#2a9c68', '#3c78d8', '#3dc789', '#41236d', '#434343', '#43d692', '#44b984', '#4a86e8',
  '#653e9b', '#666666', '#68dfa9', '#6d9eeb', '#822111', '#83334c', '#89d3b2', '#8e63ce',
  '#999999', '#a0eac9', '#a46a21', '#a479e2', '#a4c2f4', '#aa8831', '#ac2b16', '#b65775',
  '#b694e8', '#b9e4d0', '#c6f3de', '#c9daf8', '#cc3a21', '#cccccc', '#cf8933', '#d0bcf1',
  '#d5ae49', '#e07798', '#e4d7f5', '#e66550', '#eaa041', '#efa093', '#efefef', '#f2c960',
  '#f3f3f3', '#f691b3', '#f6c5be', '#f7a7c0', '#fad165', '#fb4c2f', '#fbc8d9', '#fcda83',
  '#fcdee8', '#fce8b3', '#fef1d1', '#ffad47', '#ffbc6b', '#ffd6a2', '#ffe6c7', '#ffffff',
  ]
LABEL_BACKGROUND_COLORS = [
  '#16a765', '#2da2bb', '#42d692', '#4986e7', '#98d7e4', '#a2dcc1',
  '#b3efd3', '#b6cff5', '#b99aff', '#c2c2c2', '#cca6ac', '#e3d7ff',
  '#e7e7e7', '#ebdbde', '#f2b2a8', '#f691b2', '#fb4c2f', '#fbd3e0',
  '#fbe983', '#fdedc1', '#ff7537', '#ffad46', '#ffc8af', '#ffdeb5',
  ]
LABEL_TEXT_COLORS = [
  '#04502e', '#094228', '#0b4f30', '#0d3472', '#0d3b44', '#3d188e',
  '#464646', '#594c05', '#662e37', '#684e07', '#711a36', '#7a2e0b',
  '#7a4706', '#8a1c0a', '#994a64', '#ffffff',
  ]

def getLabelColor(colorType):
  if Cmd.ArgumentsRemaining():
    color = Cmd.Current().strip().lower()
    tg = COLORHEX_PATTERN.match(color)
    if tg:
      color = tg.group(0)
      if color in colorType or color in LABEL_COLORS:
        Cmd.Advance()
        return color
    elif color.startswith('custom:'):
      tg = COLORHEX_PATTERN.match(color[7:])
      if tg:
        Cmd.Advance()
        return tg.group(0)
    invalidArgumentExit('|'.join(colorType))
  missingArgumentExit(Cmd.OB_LABEL_COLOR_HEX)

# Language codes used in Drive Labels/Youtube
BCP47_LANGUAGE_CODES_MAP = {
  'ar-sa': 'ar-SA', 'cs-cz': 'cs-CZ', 'da-dk': 'da-DK', 'de-de': 'de-DE', #Arabic Saudi Arabia, Czech Czech Republic, Danish Denmark, German Germany
  'el-gr': 'el-GR', 'en-au': 'en-AU', 'en-gb': 'en-GB', 'en-ie': 'en-IE', #Modern Greek Greece, English Australia, English United Kingdom, English Ireland
  'en-us': 'en-US', 'en-za': 'en-ZA', 'es-es': 'es-ES', 'es-mx': 'es-MX', #English United States, English South Africa, Spanish Spain, Spanish Mexico
  'fi-fi': 'fi-FI', 'fr-ca': 'fr-CA', 'fr-fr': 'fr-FR', 'he-il': 'he-IL', #Finnish Finland, French Canada, French France, Hebrew Israel
  'hi-in': 'hi-IN', 'hu-hu': 'hu-HU', 'id-id': 'id-ID', 'it-it': 'it-IT', #Hindi India, Hungarian Hungary, Indonesian Indonesia, Italian Italy
  'ja-jp': 'ja-JP', 'ko-kr': 'ko-KR', 'nl-be': 'nl-BE', 'nl-nl': 'nl-NL', #Japanese Japan, Korean Republic of Korea, Dutch Belgium, Dutch Netherlands
  'no-no': 'no-NO', 'pl-pl': 'pl-PL', 'pt-br': 'pt-BR', 'pt-pt': 'pt-PT', #Norwegian Norway, Polish Poland, Portuguese Brazil, Portuguese Portugal
  'ro-ro': 'ro-RO', 'ru-ru': 'ru-RU', 'sk-sk': 'sk-SK', 'sv-se': 'sv-SE', #Romanian Romania, Russian Russian Federation, Slovak Slovakia, Swedish Sweden
  'th-th': 'th-TH', 'tr-tr': 'tr-TR', 'zh-cn': 'zh-CN', 'zh-hk': 'zh-HK', #Thai Thailand, Turkish Turkey, Chinese China, Chinese Hong Kong
  'zh-tw': 'zh-TW' #Chinese Taiwan
  }

# Valid language codes
LANGUAGE_CODES_MAP = {
  'ach': 'ach', 'af': 'af', 'ag': 'ga', 'ak': 'ak', 'am': 'am', 'ar': 'ar', 'az': 'az', #Luo, Afrikaans, Irish, Akan, Amharic, Arabica, Azerbaijani
  'be': 'be', 'bem': 'bem', 'bg': 'bg', 'bn': 'bn', 'br': 'br', 'bs': 'bs', 'ca': 'ca', #Belarusian, Bemba, Bulgarian, Bengali, Breton, Bosnian, Catalan
  'chr': 'chr', 'ckb': 'ckb', 'co': 'co', 'crs': 'crs', 'cs': 'cs', 'cy': 'cy', 'da': 'da', #Cherokee, Kurdish (Sorani), Corsican, Seychellois Creole, Czech, Welsh, Danish
  'de': 'de', 'ee': 'ee', 'el': 'el', 'en': 'en', 'en-ca': 'en-CA', 'en-gb': 'en-GB', 'en-us': 'en-US', 'eo': 'eo', #German, Ewe, Greek, English, English (CA), English (UK), English (US), Esperanto
  'es': 'es', 'es-419': 'es-419', 'et': 'et', 'eu': 'eu', 'fa': 'fa', 'fi': 'fi', 'fil': 'fil', 'fo': 'fo', #Spanish, Spanish (Latin American), Estonian, Basque, Persian, Finnish, Filipino, Faroese
  'fr': 'fr', 'fr-ca': 'fr-CA', 'fy': 'fy', 'ga': 'ga', 'gaa': 'gaa', 'gd': 'gd', 'gl': 'gl', #French, French (Canada), Frisian, Irish, Ga, Scots Gaelic, Galician
  'gn': 'gn', 'gu': 'gu', 'ha': 'ha', 'haw': 'haw', 'he': 'he', 'hi': 'hi', 'hr': 'hr', #Guarani, Gujarati, Hausa, Hawaiian, Hebrew, Hindi, Croatian
  'ht': 'ht', 'hu': 'hu', 'hy': 'hy', 'ia': 'ia', 'id': 'id', 'ig': 'ig', 'in': 'in', #Haitian Creole, Hungarian, Armenian, Interlingua, Indonesian, Igbo, in
  'is': 'is', 'it': 'it', 'iw': 'iw', 'ja': 'ja', 'jw': 'jw', 'ka': 'ka', 'kg': 'kg', #Icelandic, Italian, Hebrew, Japanese, Javanese, Georgian, Kongo
  'kk': 'kk', 'km': 'km', 'kn': 'kn', 'ko': 'ko', 'kri': 'kri', 'k': 'k', 'ky': 'ky', #Kazakh, Khmer, Kannada, Korean, Krio (Sierra Leone), Kurdish, Kyrgyz
  'la': 'la', 'lg': 'lg', 'ln': 'ln', 'lo': 'lo', 'loz': 'loz', 'lt': 'lt', 'lua': 'lua', #Latin, Luganda, Lingala, Laothian, Lozi, Lithuanian, Tshiluba
  'lv': 'lv', 'mfe': 'mfe', 'mg': 'mg', 'mi': 'mi', 'mk': 'mk', 'ml': 'ml', 'mn': 'mn', #Latvian, Mauritian Creole, Malagasy, Maori, Macedonian, Malayalam, Mongolian
  'mo': 'mo', 'mr': 'mr', 'ms': 'ms', 'mt': 'mt', 'my': 'my', 'ne': 'ne', 'nl': 'nl', #Moldavian, Marathi, Malay, Maltese, Burmese, Nepali, Dutch
  'nn': 'nn', 'no': 'no', 'nso': 'nso', 'ny': 'ny', 'nyn': 'nyn', 'oc': 'oc', 'om': 'om', #Norwegian (Nynorsk), Norwegian, Northern Sotho, Chichewa, Runyakitara, Occitan, Oromo
  'or': 'or', 'pa': 'pa', 'pcm': 'pcm', 'pl': 'pl', 'ps': 'ps', 'pt-br': 'pt-BR', 'pt-pt': 'pt-PT', #Oriya, Punjabi, Nigerian Pidgin, Polish, Pashto, Portuguese (Brazil), Portuguese (Portugal)
  'q': 'q', 'rm': 'rm', 'rn': 'rn', 'ro': 'ro', 'ru': 'ru', 'rw': 'rw', 'sd': 'sd', #Quechua, Romansh, Kirundi, Romanian, Russian, Kinyarwanda, Sindhi
  'sh': 'sh', 'si': 'si', 'sk': 'sk', 'sl': 'sl', 'sn': 'sn', 'so': 'so', 'sq': 'sq', #Serbo-Croatian, Sinhalese, Slovak, Slovenian, Shona, Somali, Albanian
  'sr': 'sr', 'sr-me': 'sr-ME', 'st': 'st', 'su': 'su', 'sv': 'sv', 'sw': 'sw', 'ta': 'ta', #Serbian, Montenegrin, Sesotho, Sundanese, Swedish, Swahili, Tamil
  'te': 'te', 'tg': 'tg', 'th': 'th', 'ti': 'ti', 'tk': 'tk', 'tl': 'tl', 'tn': 'tn', #Telugu, Tajik, Thai, Tigrinya, Turkmen, Tagalog, Setswana
  'to': 'to', 'tr': 'tr', 'tt': 'tt', 'tum': 'tum', 'tw': 'tw', 'ug': 'ug', 'uk': 'uk', #Tonga, Turkish, Tatar, Tumbuka, Twi, Uighur, Ukrainian
  'ur': 'ur', 'uz': 'uz', 'vi': 'vi', 'wo': 'wo', 'xh': 'xh', 'yi': 'yi', 'yo': 'yo', #Urdu, Uzbek, Vietnamese, Wolof, Xhosa, Yiddish, Yoruba
  'zh-cn': 'zh-CN', 'zh-hk': 'zh-HK', 'zh-tw': 'zh-TW', 'zu': 'zu', #Chinese (Simplified), Chinese (Hong Kong/Traditional), Chinese (Taiwan/Traditional), Zulu
  }

LOCALE_CODES_MAP = {
  '': '',
  'ar-eg': 'ar_EG', #Arabic, Egypt
  'az-az': 'az_AZ', #Azerbaijani, Azerbaijan
  'be-by': 'be_BY', #Belarusian, Belarus
  'bg-bg': 'bg_BG', #Bulgarian, Bulgaria
  'bn-in': 'bn_IN', #Bengali, India
  'ca-es': 'ca_ES', #Catalan, Spain
  'cs-cz': 'cs_CZ', #Czech, Czech Republic
  'cy-gb': 'cy_GB', #Welsh, United Kingdom
  'da-dk': 'da_DK', #Danish, Denmark
  'de-ch': 'de_CH', #German, Switzerland
  'de-de': 'de_DE', #German, Germany
  'el-gr': 'el_GR', #Greek, Greece
  'en-au': 'en_AU', #English, Australia
  'en-ca': 'en_CA', #English, Canada
  'en-gb': 'en_GB', #English, United Kingdom
  'en-ie': 'en_IE', #English, Ireland
  'en-us': 'en_US', #English, U.S.A.
  'es-ar': 'es_AR', #Spanish, Argentina
  'es-bo': 'es_BO', #Spanish, Bolivia
  'es-cl': 'es_CL', #Spanish, Chile
  'es-co': 'es_CO', #Spanish, Colombia
  'es-ec': 'es_EC', #Spanish, Ecuador
  'es-es': 'es_ES', #Spanish, Spain
  'es-mx': 'es_MX', #Spanish, Mexico
  'es-py': 'es_PY', #Spanish, Paraguay
  'es-uy': 'es_UY', #Spanish, Uruguay
  'es-ve': 'es_VE', #Spanish, Venezuela
  'fi-fi': 'fi_FI', #Finnish, Finland
  'fil-ph': 'fil_PH', #Filipino, Philippines
  'fr-ca': 'fr_CA', #French, Canada
  'fr-fr': 'fr_FR', #French, France
  'gu-in': 'gu_IN', #Gujarati, India
  'hi-in': 'hi_IN', #Hindi, India
  'hr-hr': 'hr_HR', #Croatian, Croatia
  'hu-hu': 'hu_HU', #Hungarian, Hungary
  'hy-am': 'hy_AM', #Armenian, Armenia
  'in-id': 'in_ID', #Indonesian, Indonesia
  'it-it': 'it_IT', #Italian, Italy
  'iw-il': 'iw_IL', #Hebrew, Israel
  'ja-jp': 'ja_JP', #Japanese, Japan
  'ka-ge': 'ka_GE', #Georgian, Georgia
  'kk-kz': 'kk_KZ', #Kazakh, Kazakhstan
  'kn-in': 'kn_IN', #Kannada, India
  'ko-kr': 'ko_KR', #Korean, Korea
  'lt-lt': 'lt_LT', #Lithuanian, Lithuania
  'lv-lv': 'lv_LV', #Latvian, Latvia
  'ml-in': 'ml_IN', #Malayalam, India
  'mn-mn': 'mn_MN', #Mongolian, Mongolia
  'mr-in': 'mr_IN', #Marathi, India
  'my-mn': 'my_MN', #Burmese, Myanmar
  'nl-nl': 'nl_NL', #Dutch, Netherlands
  'nn-no': 'nn_NO', #Nynorsk, Norway
  'no-no': 'no_NO', #Bokmal, Norway
  'pa-in': 'pa_IN', #Punjabi, India
  'pl-pl': 'pl_PL', #Polish, Poland
  'pt-br': 'pt_BR', #Portuguese, Brazil
  'pt-pt': 'pt_PT', #Portuguese, Portugal
  'ro-ro': 'ro_RO', #Romanian, Romania
  'ru-ru': 'ru_RU', #Russian, Russia
  'sk-sk': 'sk_SK', #Slovak, Slovakia
  'sl-si': 'sl_SI', #Slovenian, Slovenia
  'sr-rs': 'sr_RS', #Serbian, Serbia
  'sv-se': 'sv_SE', #Swedish, Sweden
  'ta-in': 'ta_IN', #Tamil, India
  'te-in': 'te_IN', #Telugu, India
  'th-th': 'th_TH', #Thai, Thailand
  'tr-tr': 'tr_TR', #Turkish, Turkey
  'uk-ua': 'uk_UA', #Ukrainian, Ukraine
  'vi-vn': 'vi_VN', #Vietnamese, Vietnam
  'zh-cn': 'zh_CN', #Simplified Chinese, China
  'zh-hk': 'zh_HK', #Traditional Chinese, Hong Kong SAR China
  'zh-tw': 'zh_TW', #Traditional Chinese, Taiwan
  }

def getLanguageCode(languageCodeMap):
  if Cmd.ArgumentsRemaining():
    choice = Cmd.Current().strip().lower().replace('_', '-')
    if choice in languageCodeMap:
      Cmd.Advance()
      return languageCodeMap[choice]
    invalidChoiceExit(choice, languageCodeMap, False)
  missingChoiceExit(languageCodeMap)

def addCourseIdScope(courseId):
  if not courseId.isdigit() and courseId[:2] not in {'d:', 'p:'}:
    return f'd:{courseId}'
  return courseId

def removeCourseIdScope(courseId):
  if courseId.startswith('d:'):
    return courseId[2:]
  return courseId

def addCourseAliasScope(alias):
  if alias[:2] not in {'d:', 'p:'}:
    return f'd:{alias}'
  return alias

def removeCourseAliasScope(alias):
  if alias.startswith('d:'):
    return alias[2:]
  return alias

def getCourseAlias():
  if Cmd.ArgumentsRemaining():
    courseAlias = Cmd.Current()
    if courseAlias:
      Cmd.Advance()
      return addCourseAliasScope(courseAlias)
  missingArgumentExit(Cmd.OB_COURSE_ALIAS)

DELIVERY_SETTINGS_UNDEFINED = 'DSU'
GROUP_DELIVERY_SETTINGS_MAP = {
  'allmail': 'ALL_MAIL',
  'abridged': 'DAILY',
  'daily': 'DAILY',
  'digest': 'DIGEST',
  'disabled': 'DISABLED',
  'none': 'NONE',
  'nomail': 'NONE',
  }

def getDeliverySettings():
  if checkArgumentPresent(['delivery', 'deliverysettings']):
    return getChoice(GROUP_DELIVERY_SETTINGS_MAP, mapChoice=True)
  return getChoice(GROUP_DELIVERY_SETTINGS_MAP, defaultChoice=DELIVERY_SETTINGS_UNDEFINED, mapChoice=True)

UID_PATTERN = re.compile(r'u?id: ?(.+)', re.IGNORECASE)
PEOPLE_PATTERN = re.compile(r'people/([0-9]+)$', re.IGNORECASE)

def validateEmailAddressOrUID(emailAddressOrUID, checkPeople=True, ciGroupsAPI=False):
  cg = UID_PATTERN.match(emailAddressOrUID)
  if cg:
    return cg.group(1)
  if checkPeople:
    cg = PEOPLE_PATTERN.match(emailAddressOrUID)
    if cg:
      return cg.group(1)
  if ciGroupsAPI and emailAddressOrUID.startswith('groups/'):
    return emailAddressOrUID
  return emailAddressOrUID.find('@') != 0 and emailAddressOrUID.count('@') <= 1

# Normalize user/group email address/uid
# uid:12345abc -> 12345abc
# foo -> foo@domain
# foo@ -> foo@domain
# foo@bar.com -> foo@bar.com
# @domain -> domain
def normalizeEmailAddressOrUID(emailAddressOrUID, noUid=False, checkForCustomerId=False, noLower=False, ciGroupsAPI=False):
  if checkForCustomerId and (emailAddressOrUID == GC.Values[GC.CUSTOMER_ID]):
    return emailAddressOrUID
  if not noUid:
    cg = UID_PATTERN.match(emailAddressOrUID)
    if cg:
      return cg.group(1)
    cg = PEOPLE_PATTERN.match(emailAddressOrUID)
    if cg:
      return cg.group(1)
  if ciGroupsAPI and emailAddressOrUID.startswith('groups/'):
    return emailAddressOrUID
  atLoc = emailAddressOrUID.find('@')
  if atLoc == 0:
    return emailAddressOrUID[1:].lower() if not noLower else emailAddressOrUID[1:]
  if (atLoc == -1) or (atLoc == len(emailAddressOrUID)-1) and GC.Values[GC.DOMAIN]:
    if atLoc == -1:
      emailAddressOrUID = f'{emailAddressOrUID}@{GC.Values[GC.DOMAIN]}'
    else:
      emailAddressOrUID = f'{emailAddressOrUID}{GC.Values[GC.DOMAIN]}'
  return emailAddressOrUID.lower() if not noLower else emailAddressOrUID

# Normalize student/guardian email address/uid
# 12345678 -> 12345678
# - -> -
# Otherwise, same results as normalizeEmailAddressOrUID
def normalizeStudentGuardianEmailAddressOrUID(emailAddressOrUID, allowDash=False):
  if emailAddressOrUID.isdigit() or (allowDash and emailAddressOrUID == '-'):
    return emailAddressOrUID
  return normalizeEmailAddressOrUID(emailAddressOrUID)

def getEmailAddress(noUid=False, minLen=1, optional=False, returnUIDprefix=''):
  if Cmd.ArgumentsRemaining():
    emailAddress = Cmd.Current().strip().lower()
    if emailAddress:
      cg = UID_PATTERN.match(emailAddress)
      if cg:
        if not noUid:
          if cg.group(1):
            Cmd.Advance()
            return f'{returnUIDprefix}{cg.group(1)}'
        else:
          invalidArgumentExit('name@domain')
      else:
        atLoc = emailAddress.find('@')
        if atLoc == -1:
          if GC.Values[GC.DOMAIN]:
            emailAddress = f'{emailAddress}@{GC.Values[GC.DOMAIN]}'
          Cmd.Advance()
          return emailAddress
        if atLoc != 0:
          if (atLoc == len(emailAddress)-1) and GC.Values[GC.DOMAIN]:
            emailAddress = f'{emailAddress}{GC.Values[GC.DOMAIN]}'
          Cmd.Advance()
          return emailAddress
        invalidArgumentExit('name@domain')
    if optional:
      Cmd.Advance()
      return None
    if minLen == 0:
      Cmd.Advance()
      return ''
  elif optional:
    return None
  missingArgumentExit([Cmd.OB_EMAIL_ADDRESS_OR_UID, Cmd.OB_EMAIL_ADDRESS][noUid])

def getFilename():
  filename = os.path.expanduser(getString(Cmd.OB_FILE_NAME))
  if os.path.isfile(filename):
    return filename
  entityDoesNotExistExit(Ent.FILE, filename)

def getPermissionId():
  if Cmd.ArgumentsRemaining():
    emailAddress = Cmd.Current().strip()
    if emailAddress:
      cg = UID_PATTERN.match(emailAddress)
      if cg:
        Cmd.Advance()
        return (False, cg.group(1))
      emailAddress = emailAddress.lower()
      atLoc = emailAddress.find('@')
      if atLoc == -1:
        if emailAddress == 'anyone':
          Cmd.Advance()
          return (False, emailAddress)
        if emailAddress == 'anyonewithlink':
          Cmd.Advance()
          return (False, 'anyoneWithLink')
        if GC.Values[GC.DOMAIN]:
          emailAddress = f'{emailAddress}@{GC.Values[GC.DOMAIN]}'
        Cmd.Advance()
        return (True, emailAddress)
      if atLoc != 0:
        if (atLoc == len(emailAddress)-1) and GC.Values[GC.DOMAIN]:
          emailAddress = f'{emailAddress}{GC.Values[GC.DOMAIN]}'
        Cmd.Advance()
        return (True, emailAddress)
      invalidArgumentExit('name@domain')
  missingArgumentExit(Cmd.OB_DRIVE_FILE_PERMISSION_ID)

def getGoogleProduct():
  if Cmd.ArgumentsRemaining():
    product = Cmd.Current().strip()
    if product:
      status, productId = SKU.normalizeProductId(product)
      if not status:
        invalidChoiceExit(productId, SKU.getSortedProductList(), False)
      Cmd.Advance()
      return productId
  missingArgumentExit(Cmd.OB_PRODUCT_ID)

def getGoogleProductList():
  if Cmd.ArgumentsRemaining():
    productsList = []
    for product in Cmd.Current().split(','):
      status, productId = SKU.normalizeProductId(product)
      if not status:
        invalidChoiceExit(productId, SKU.getSortedProductList(), False)
      if productId not in productsList:
        productsList.append(productId)
    Cmd.Advance()
    return productsList
  missingArgumentExit(Cmd.OB_PRODUCT_ID_LIST)

def getGoogleSKU():
  if Cmd.ArgumentsRemaining():
    sku = Cmd.Current().strip()
    if sku:
      Cmd.Advance()
      return SKU.getProductAndSKU(sku)
  missingArgumentExit(Cmd.OB_SKU_ID)

def getGoogleSKUList(allowUnknownProduct=False):
  if Cmd.ArgumentsRemaining():
    skusList = []
    for sku in Cmd.Current().split(','):
      productId, sku = SKU.getProductAndSKU(sku)
      if not productId and not allowUnknownProduct:
        invalidChoiceExit(sku, SKU.getSortedSKUList(), False)
      if (productId, sku) not in skusList:
        skusList.append((productId, sku))
    Cmd.Advance()
    return skusList
  missingArgumentExit(Cmd.OB_SKU_ID_LIST)

def floatLimits(minVal, maxVal, item='float'):
  if (minVal is not None) and (maxVal is not None):
    return f'{item} {minVal:.3f}<=x<={maxVal:.3f}'
  if minVal is not None:
    return f'{item} x>={minVal:.3f}'
  if maxVal is not None:
    return f'{item} x<={maxVal:.3f}'
  return f'{item} x'

def getFloat(minVal=None, maxVal=None):
  if Cmd.ArgumentsRemaining():
    try:
      number = float(Cmd.Current().strip())
      if ((minVal is None) or (number >= minVal)) and ((maxVal is None) or (number <= maxVal)):
        Cmd.Advance()
        return number
    except ValueError:
      pass
    invalidArgumentExit(floatLimits(minVal, maxVal))
  missingArgumentExit(floatLimits(minVal, maxVal))

def integerLimits(minVal, maxVal, item='integer'):
  if (minVal is not None) and (maxVal is not None):
    return f'{item} {minVal}<=x<={maxVal}'
  if minVal is not None:
    return f'{item} x>={minVal}'
  if maxVal is not None:
    return f'{item} x<={maxVal}'
  return f'{item} x'

def getInteger(minVal=None, maxVal=None, default=None):
  if Cmd.ArgumentsRemaining():
    try:
      number = int(Cmd.Current().strip())
      if ((minVal is None) or (number >= minVal)) and ((maxVal is None) or (number <= maxVal)):
        Cmd.Advance()
        return number
    except ValueError:
      if default is not None:
        if not Cmd.Current().strip(): # If current argument is empty, skip over it
          Cmd.Advance()
        return default
    invalidArgumentExit(integerLimits(minVal, maxVal))
  elif default is not None:
    return default
  missingArgumentExit(integerLimits(minVal, maxVal))

def getIntegerEmptyAllowed(minVal=None, maxVal=None, default=0):
  if Cmd.ArgumentsRemaining():
    number = Cmd.Current().strip()
    if not number:
      Cmd.Advance()
      return default
    try:
      number = int(number)
      if ((minVal is None) or (number >= minVal)) and ((maxVal is None) or (number <= maxVal)):
        Cmd.Advance()
        return number
    except ValueError:
      pass
    invalidArgumentExit(integerLimits(minVal, maxVal))
  return default

SORTORDER_CHOICE_MAP = {'ascending': 'ASCENDING', 'descending': 'DESCENDING'}

class OrderBy():
  def __init__(self, choiceMap, ascendingKeyword='', descendingKeyword='desc'):
    self.choiceMap = choiceMap
    self.ascendingKeyword = ascendingKeyword
    self.descendingKeyword = descendingKeyword
    self.items = []

  def GetChoice(self):
    fieldName = getChoice(self.choiceMap, mapChoice=True)
    fieldNameAscending = fieldName
    if self.ascendingKeyword:
      fieldNameAscending += f' {self.ascendingKeyword}'
    if fieldNameAscending in self.items:
      self.items.remove(fieldNameAscending)
    fieldNameDescending = fieldName
    if self.descendingKeyword:
      fieldNameDescending += f' {self.descendingKeyword}'
    if fieldNameDescending in self.items:
      self.items.remove(fieldNameDescending)
    if getChoice(SORTORDER_CHOICE_MAP, defaultChoice=None, mapChoice=True) != 'DESCENDING':
      self.items.append(fieldNameAscending)
    else:
      self.items.append(fieldNameDescending)

  def SetItems(self, itemList):
    self.items = itemList.split(',')

  @property
  def orderBy(self):
    return ','.join(self.items)

def getOrderBySortOrder(choiceMap, defaultSortOrderChoice='ASCENDING', mapSortOrderChoice=True):
  return (getChoice(choiceMap, mapChoice=True),
          getChoice(SORTORDER_CHOICE_MAP, defaultChoice=defaultSortOrderChoice, mapChoice=mapSortOrderChoice))

def orgUnitPathQuery(path, isSuspended):
  query = "orgUnitPath='{0}'".format(path.replace("'", "\\'")) if path != '/' else ''
  if isSuspended is not None:
    query += f' isSuspended={isSuspended}'
  return query

def makeOrgUnitPathAbsolute(path):
  if path == '/':
    return path
  if path.startswith('/'):
    if not path.endswith('/'):
      return path
    return path[:-1]
  if path.startswith('id:'):
    return path
  if path.startswith('uid:'):
    return path[1:]
  if not path.endswith('/'):
    return '/'+path
  return '/'+path[:-1]

def makeOrgUnitPathRelative(path):
  if path == '/':
    return path
  if path.startswith('/'):
    if not path.endswith('/'):
      return path[1:]
    return path[1:-1]
  if path.startswith('id:'):
    return path
  if path.startswith('uid:'):
    return path[1:]
  if not path.endswith('/'):
    return path
  return path[:-1]

def encodeOrgUnitPath(path):
# 6.22.19 - Encoding doesn't work
# % no longer needs encoding and + is handled incorrectly in API with or without encoding
  return path
#  if path.find('+') == -1 and path.find('%') == -1:
#    return path
#  encpath = ''
#  for c in path:
#    if c == '+':
#      encpath += '%2B'
#    elif c == '%':
#      encpath += '%25'
#    else:
#      encpath += c
#  return encpath

def getOrgUnitItem(pathOnly=False, absolutePath=True, cd=None):
  if Cmd.ArgumentsRemaining():
    path = Cmd.Current().strip()
    if path:
      if pathOnly and (path.startswith('id:') or path.startswith('uid:')) and cd is not None:
        try:
          result = callGAPI(cd.orgunits(), 'get',
                            throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                            customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=path,
                            fields='orgUnitPath')
          Cmd.Advance()
          if absolutePath:
            return makeOrgUnitPathAbsolute(result['orgUnitPath'])
          return makeOrgUnitPathRelative(result['orgUnitPath'])
        except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError,
                GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
          checkEntityAFDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, path)
        invalidArgumentExit(Cmd.OB_ORGUNIT_PATH)
      Cmd.Advance()
      if absolutePath:
        return makeOrgUnitPathAbsolute(path)
      return makeOrgUnitPathRelative(path)
  missingArgumentExit([Cmd.OB_ORGUNIT_ITEM, Cmd.OB_ORGUNIT_PATH][pathOnly])

def getTopLevelOrgId(cd, parentOrgUnitPath):
  if parentOrgUnitPath != '/':
    try:
      result = callGAPI(cd.orgunits(), 'get',
                        throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                        customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=encodeOrgUnitPath(makeOrgUnitPathRelative(parentOrgUnitPath)),
                        fields='orgUnitId')
      return result['orgUnitId']
    except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError):
      return None
    except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
      checkEntityAFDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, parentOrgUnitPath)
      return None
  try:
    result = callGAPI(cd.orgunits(), 'list',
                      throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                      customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath='/', type='allIncludingParent',
                      fields='organizationUnits(orgUnitId,orgUnitPath)')
    for orgUnit in result.get('organizationUnits', []):
      if orgUnit['orgUnitPath'] == '/':
        return orgUnit['orgUnitId']
    return None
  except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError):
    return None
  except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
    checkEntityAFDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, parentOrgUnitPath)
    return None

def getOrgUnitId(cd=None, orgUnit=None):
  if cd is None:
    cd = buildGAPIObject(API.DIRECTORY)
  if orgUnit is None:
    orgUnit = getOrgUnitItem()
  try:
    if orgUnit == '/':
      result = callGAPI(cd.orgunits(), 'list',
                        throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                        customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath='/', type='children',
                        fields='organizationUnits(parentOrgUnitId,parentOrgUnitPath)')
      if result.get('organizationUnits', []):
        return (result['organizationUnits'][0]['parentOrgUnitPath'], result['organizationUnits'][0]['parentOrgUnitId'])
      topLevelOrgId = getTopLevelOrgId(cd, '/')
      if topLevelOrgId:
        return (orgUnit, topLevelOrgId)
      return (orgUnit, '/') #Bogus but should never happen
    result = callGAPI(cd.orgunits(), 'get',
                      throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                      customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=encodeOrgUnitPath(makeOrgUnitPathRelative(orgUnit)),
                      fields='orgUnitId,orgUnitPath')
    return (result['orgUnitPath'], result['orgUnitId'])
  except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError):
    entityDoesNotExistExit(Ent.ORGANIZATIONAL_UNIT, orgUnit)
  except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
    accessErrorExit(cd)

def getAllParentOrgUnitsForUser(cd, user):
  try:
    result = callGAPI(cd.users(), 'get',
                      throwReasons=GAPI.USER_GET_THROW_REASONS,
                      userKey=user, fields='orgUnitPath', projection='basic')
  except (GAPI.userNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden):
    entityDoesNotExistExit(Ent.USER, user)
  except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
    accessErrorExit(cd)
  parentPath = result['orgUnitPath']
  if parentPath == '/':
    orgUnitPath, orgUnitId = getOrgUnitId(cd, '/')
    return {orgUnitId: orgUnitPath}
  parentPath = encodeOrgUnitPath(makeOrgUnitPathRelative(parentPath))
  orgUnits = {}
  while True:
    try:
      result = callGAPI(cd.orgunits(), 'get',
                        throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                        customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=parentPath,
                        fields='orgUnitId,orgUnitPath,parentOrgUnitId')
      orgUnits[result['orgUnitId']] = result['orgUnitPath']
      if 'parentOrgUnitId' not in result:
        break
      parentPath = result['parentOrgUnitId']
    except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError):
      entityDoesNotExistExit(Ent.ORGANIZATIONAL_UNIT, parentPath)
    except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
      accessErrorExit(cd)
  return orgUnits

def validateREPattern(patstr, flags=0):
  try:
    return re.compile(patstr, flags)
  except re.error as e:
    Cmd.Backup()
    usageErrorExit(f'{Cmd.OB_RE_PATTERN} {Msg.ERROR}: {e}')

def getREPattern(flags=0):
  if Cmd.ArgumentsRemaining():
    patstr = Cmd.Current()
    if patstr:
      Cmd.Advance()
      return validateREPattern(patstr, flags)
  missingArgumentExit(Cmd.OB_RE_PATTERN)

def validateREPatternSubstitution(pattern, replacement):
  try:
    re.sub(pattern, replacement, '')
    return (pattern, replacement)
  except re.error as e:
    Cmd.Backup()
    usageErrorExit(f'{Cmd.OB_RE_SUBSTITUTION} {Msg.ERROR}: {e}')

def getREPatternSubstitution(flags=0):
  pattern = getREPattern(flags)
  replacement = getString(Cmd.OB_RE_SUBSTITUTION, minLen=0)
  return validateREPatternSubstitution(pattern, replacement)

def getSheetEntity(allowBlankSheet):
  if Cmd.ArgumentsRemaining():
    sheet = Cmd.Current()
    if sheet or allowBlankSheet:
      cg = UID_PATTERN.match(sheet)
      if cg:
        if cg.group(1).isdigit():
          Cmd.Advance()
          return {'sheetType': Ent.SHEET_ID, 'sheetValue': int(cg.group(1)), 'sheetId': int(cg.group(1)), 'sheetTitle': ''}
      else:
        Cmd.Advance()
        return {'sheetType': Ent.SHEET, 'sheetValue': sheet, 'sheetId': None, 'sheetTitle': sheet}
  missingArgumentExit(Cmd.OB_SHEET_ENTITY)

def getSheetIdFromSheetEntity(spreadsheet, sheetEntity):
  if sheetEntity['sheetType'] == Ent.SHEET_ID:
    for sheet in spreadsheet['sheets']:
      if sheetEntity['sheetId'] == sheet['properties']['sheetId']:
        return sheet['properties']['sheetId']
  else:
    sheetTitleLower = sheetEntity['sheetTitle'].lower()
    for sheet in spreadsheet['sheets']:
      if sheetTitleLower == sheet['properties']['title'].lower():
        return sheet['properties']['sheetId']
  return None

def protectedSheetId(spreadsheet, sheetId):
  for sheet in spreadsheet['sheets']:
    for protectedRange in sheet.get('protectedRanges', []):
      if protectedRange.get('range', {}).get('sheetId', -1) == sheetId and not protectedRange.get('requestingUserCanEdit', False):
        return True
  return False

def getString(item, checkBlank=False, optional=False, minLen=1, maxLen=None):
  if Cmd.ArgumentsRemaining():
    argstr = Cmd.Current()
    if argstr:
      if checkBlank:
        if argstr.isspace():
          blankArgumentExit(item)
      if (len(argstr) >= minLen) and ((maxLen is None) or (len(argstr) <= maxLen)):
        Cmd.Advance()
        return argstr
      invalidArgumentExit(f'{integerLimits(minLen, maxLen, Msg.STRING_LENGTH)} for {item}')
    if optional or (minLen == 0):
      Cmd.Advance()
      return ''
    emptyArgumentExit(item)
  elif optional:
    return ''
  missingArgumentExit(item)

def escapeCRsNLs(value):
  return value.replace('\r', '\\r').replace('\n', '\\n')

def unescapeCRsNLs(value):
  return value.replace('\\r', '\r').replace('\\n', '\n')

def getStringWithCRsNLs():
  return unescapeCRsNLs(getString(Cmd.OB_STRING, minLen=0))

def getStringReturnInList(item):
  argstr = getString(item, minLen=0).strip()
  if argstr:
    return [argstr]
  return []

SORF_SIG_ARGUMENTS = {'signature', 'sig', 'textsig', 'htmlsig'}
SORF_MSG_ARGUMENTS = {'message', 'textmessage', 'htmlmessage'}
SORF_FILE_ARGUMENTS = {'file', 'textfile', 'htmlfile', 'gdoc', 'ghtml', 'gcsdoc', 'gcshtml'}
SORF_HTML_ARGUMENTS = {'htmlsig', 'htmlmessage', 'htmlfile', 'ghtml', 'gcshtml'}
SORF_TEXT_ARGUMENTS = {'text', 'textfile', 'gdoc', 'gcsdoc'}
SORF_SIG_FILE_ARGUMENTS = SORF_SIG_ARGUMENTS.union(SORF_FILE_ARGUMENTS)
SORF_MSG_FILE_ARGUMENTS = SORF_MSG_ARGUMENTS.union(SORF_FILE_ARGUMENTS)

def getStringOrFile(myarg, minLen=0, unescapeCRLF=False):
  if myarg in SORF_SIG_ARGUMENTS:
    if checkArgumentPresent(SORF_FILE_ARGUMENTS):
      myarg = Cmd.Previous().strip().lower().replace('_', '')
  html = myarg in SORF_HTML_ARGUMENTS
  if myarg in SORF_FILE_ARGUMENTS:
    if myarg in {'file', 'textfile', 'htmlfile'}:
      filename = getString(Cmd.OB_FILE_NAME)
      encoding = getCharSet()
      return (readFile(filename, encoding=encoding), encoding, html)
    if myarg in {'gdoc', 'ghtml'}:
      f = getGDocData(myarg)
      data = f.read()
      f.close()
      return (data, UTF8, html)
    return (getStorageFileData(myarg), UTF8, html)
  if not unescapeCRLF:
    return (getString(Cmd.OB_STRING, minLen=minLen), UTF8, html)
  return (unescapeCRsNLs(getString(Cmd.OB_STRING, minLen=minLen)), UTF8, html)

def getStringWithCRsNLsOrFile():
  if checkArgumentPresent(SORF_FILE_ARGUMENTS):
    return getStringOrFile(Cmd.Previous().strip().lower().replace('_', ''), minLen=0)
  return (unescapeCRsNLs(getString(Cmd.OB_STRING, minLen=0)), UTF8, False)

def todaysDate():
  return datetime.datetime(GM.Globals[GM.DATETIME_NOW].year, GM.Globals[GM.DATETIME_NOW].month, GM.Globals[GM.DATETIME_NOW].day,
                           tzinfo=GC.Values[GC.TIMEZONE])

def todaysTime():
  return datetime.datetime(GM.Globals[GM.DATETIME_NOW].year, GM.Globals[GM.DATETIME_NOW].month, GM.Globals[GM.DATETIME_NOW].day,
                           GM.Globals[GM.DATETIME_NOW].hour, GM.Globals[GM.DATETIME_NOW].minute,
                           tzinfo=GC.Values[GC.TIMEZONE])

def getDelta(argstr, pattern):
  if argstr == 'NOW':
    return todaysTime()
  if argstr == 'TODAY':
    return todaysDate()
  tg = pattern.match(argstr.lower())
  if tg is None:
    return None
  sign = tg.group(1)
  delta = int(tg.group(2))
  unit = tg.group(3)
  if unit == 'y':
    deltaTime = datetime.timedelta(days=delta*365)
  elif unit == 'w':
    deltaTime = datetime.timedelta(weeks=delta)
  elif unit == 'd':
    deltaTime = datetime.timedelta(days=delta)
  elif unit == 'h':
    deltaTime = datetime.timedelta(hours=delta)
  elif unit == 'm':
    deltaTime = datetime.timedelta(minutes=delta)
  baseTime = todaysDate()
  if unit in {'h', 'm'}:
    baseTime = baseTime+datetime.timedelta(hours=GM.Globals[GM.DATETIME_NOW].hour, minutes=GM.Globals[GM.DATETIME_NOW].minute)
  if sign == '-':
    return baseTime-deltaTime
  return baseTime+deltaTime

DELTA_DATE_PATTERN = re.compile(r'^([+-])(\d+)([dwy])$')
DELTA_DATE_FORMAT_REQUIRED = '(+|-)<Number>(d|w|y)'
def getDeltaDate(argstr):
  deltaDate = getDelta(argstr, DELTA_DATE_PATTERN)
  if deltaDate is None:
    invalidArgumentExit(DELTA_DATE_FORMAT_REQUIRED)
  return deltaDate

DELTA_TIME_PATTERN = re.compile(r'^([+-])(\d+)([mhdwy])$')
DELTA_TIME_FORMAT_REQUIRED = '(+|-)<Number>(m|h|d|w|y)'

def getDeltaTime(argstr):
  deltaTime = getDelta(argstr, DELTA_TIME_PATTERN)
  if deltaTime is None:
    invalidArgumentExit(DELTA_TIME_FORMAT_REQUIRED)
  return deltaTime

YYYYMMDD_FORMAT = '%Y-%m-%d'
YYYYMMDD_FORMAT_REQUIRED = 'yyyy-mm-dd'

TODAY_NOW = {'TODAY', 'NOW'}
PLUS_MINUS = {'+', '-'}

def getYYYYMMDD(minLen=1, returnTimeStamp=False, returnDateTime=False, alternateValue=None):
  if Cmd.ArgumentsRemaining():
    argstr = Cmd.Current().strip().upper()
    if argstr:
      if alternateValue is not None and argstr == alternateValue.upper():
        Cmd.Advance()
        return None
      if argstr in TODAY_NOW or argstr[0] in PLUS_MINUS:
        if argstr == 'NOW':
          argstr = 'TODAY'
        argstr = getDeltaDate(argstr).strftime(YYYYMMDD_FORMAT)
      elif argstr == 'NEVER':
        argstr = NEVER_DATE
      try:
        dateTime = datetime.datetime.strptime(argstr, YYYYMMDD_FORMAT)
        Cmd.Advance()
        if returnTimeStamp:
          return time.mktime(dateTime.timetuple())*1000
        if returnDateTime:
          return dateTime
        return argstr
      except ValueError:
        invalidArgumentExit(YYYYMMDD_FORMAT_REQUIRED)
    elif minLen == 0:
      Cmd.Advance()
      return ''
  missingArgumentExit(YYYYMMDD_FORMAT_REQUIRED)

HHMM_FORMAT = '%H:%M'
HHMM_FORMAT_REQUIRED = 'hh:mm'

def getHHMM():
  if Cmd.ArgumentsRemaining():
    argstr = Cmd.Current().strip().upper()
    if argstr:
      try:
        datetime.datetime.strptime(argstr, HHMM_FORMAT)
        Cmd.Advance()
        return argstr
      except ValueError:
        invalidArgumentExit(HHMM_FORMAT_REQUIRED)
  missingArgumentExit(HHMM_FORMAT_REQUIRED)

YYYYMMDD_HHMM_FORMAT = '%Y-%m-%d %H:%M'
YYYYMMDD_HHMM_FORMAT_REQUIRED = 'yyyy-mm-dd hh:mm'

def getYYYYMMDD_HHMM():
  if Cmd.ArgumentsRemaining():
    argstr = Cmd.Current().strip().upper()
    if argstr:
      if argstr in TODAY_NOW or argstr[0] in PLUS_MINUS:
        argstr = getDeltaTime(argstr).strftime(YYYYMMDD_HHMM_FORMAT)
      elif argstr == 'NEVER':
        argstr = NEVER_DATETIME
      argstr = argstr.replace('T', ' ')
      try:
        datetime.datetime.strptime(argstr, YYYYMMDD_HHMM_FORMAT)
        Cmd.Advance()
        return argstr
      except ValueError:
        invalidArgumentExit(YYYYMMDD_HHMM_FORMAT_REQUIRED)
  missingArgumentExit(YYYYMMDD_HHMM_FORMAT_REQUIRED)

YYYYMMDDTHHMMSSZ_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
YYYYMMDD_PATTERN = re.compile(r'^[0-9]{4}-[0-9]{2}-[0-9]{2}$')

def getDateOrDeltaFromNow(returnDateTime=False):
  if Cmd.ArgumentsRemaining():
    argstr = Cmd.Current().strip().upper()
    if argstr:
      if argstr in TODAY_NOW or argstr[0] in PLUS_MINUS:
        if argstr == 'NOW':
          argstr = 'TODAY'
        argDate = getDeltaDate(argstr)
      elif argstr == 'NEVER':
        argDate = datetime.datetime.strptime(NEVER_DATE, YYYYMMDD_FORMAT)
      elif YYYYMMDD_PATTERN.match(argstr):
        try:
          argDate = datetime.datetime.strptime(argstr, YYYYMMDD_FORMAT)
        except ValueError:
          invalidArgumentExit(YYYYMMDD_FORMAT_REQUIRED)
      else:
        invalidArgumentExit(YYYYMMDD_FORMAT_REQUIRED)
      Cmd.Advance()
      if not returnDateTime:
        return argDate.strftime(YYYYMMDD_FORMAT)
      return (datetime.datetime(argDate.year, argDate.month, argDate.day, tzinfo=GC.Values[GC.TIMEZONE]),
              GC.Values[GC.TIMEZONE], argDate.strftime(YYYYMMDD_FORMAT))
  missingArgumentExit(YYYYMMDD_FORMAT_REQUIRED)

YYYYMMDDTHHMMSS_FORMAT_REQUIRED = 'yyyy-mm-ddThh:mm:ss[.fff](Z|(+|-(hh:mm)))'
TIMEZONE_FORMAT_REQUIRED = 'Z|(+|-(hh:mm))'

def getTimeOrDeltaFromNow(returnDateTime=False):
  if Cmd.ArgumentsRemaining():
    argstr = Cmd.Current().strip().upper()
    if argstr:
      if argstr in TODAY_NOW or argstr[0] in PLUS_MINUS:
        argstr = ISOformatTimeStamp(getDeltaTime(argstr))
      elif argstr == 'NEVER':
        argstr = NEVER_TIME
      elif YYYYMMDD_PATTERN.match(argstr):
        try:
          dateTime = datetime.datetime.strptime(argstr, YYYYMMDD_FORMAT)
        except ValueError:
          invalidArgumentExit(YYYYMMDD_FORMAT_REQUIRED)
        try:
          argstr = ISOformatTimeStamp(dateTime.replace(tzinfo=GC.Values[GC.TIMEZONE]))
        except OverflowError:
          pass
      try:
        fullDateTime, tz = iso8601.parse_date(argstr)
        Cmd.Advance()
        if not returnDateTime:
          return argstr.replace(' ', 'T')
        return (fullDateTime, tz, argstr.replace(' ', 'T'))
      except (iso8601.ParseError, OverflowError):
        pass
      invalidArgumentExit(YYYYMMDDTHHMMSS_FORMAT_REQUIRED)
  missingArgumentExit(YYYYMMDDTHHMMSS_FORMAT_REQUIRED)

def getRowFilterDateOrDeltaFromNow(argstr):
  argstr = argstr.strip().upper()
  if argstr in TODAY_NOW or argstr[0] in PLUS_MINUS:
    if argstr == 'NOW':
      argstr = 'TODAY'
    deltaDate = getDelta(argstr, DELTA_DATE_PATTERN)
    if deltaDate is None:
      return (False, DELTA_DATE_FORMAT_REQUIRED)
    argstr = ISOformatTimeStamp(deltaDate.replace(tzinfo=iso8601.UTC))
  elif argstr == 'NEVER' or YYYYMMDD_PATTERN.match(argstr):
    if argstr == 'NEVER':
      argstr = NEVER_DATE
    try:
      dateTime = datetime.datetime.strptime(argstr, YYYYMMDD_FORMAT)
    except ValueError:
      return (False, YYYYMMDD_FORMAT_REQUIRED)
    argstr = ISOformatTimeStamp(dateTime.replace(tzinfo=iso8601.UTC))
  try:
    iso8601.parse_date(argstr)
    return (True, argstr.replace(' ', 'T'))
  except (iso8601.ParseError, OverflowError):
    return (False, YYYYMMDD_FORMAT_REQUIRED)

def getRowFilterTimeOrDeltaFromNow(argstr):
  argstr = argstr.strip().upper()
  if argstr in TODAY_NOW or argstr[0] in PLUS_MINUS:
    deltaTime = getDelta(argstr, DELTA_TIME_PATTERN)
    if deltaTime is None:
      return (False, DELTA_TIME_FORMAT_REQUIRED)
    argstr = ISOformatTimeStamp(deltaTime)
  elif argstr == 'NEVER':
    argstr = NEVER_TIME
  elif YYYYMMDD_PATTERN.match(argstr):
    try:
      dateTime = datetime.datetime.strptime(argstr, YYYYMMDD_FORMAT)
    except ValueError:
      return (False, YYYYMMDD_FORMAT_REQUIRED)
    argstr = ISOformatTimeStamp(dateTime.replace(tzinfo=GC.Values[GC.TIMEZONE]))
  try:
    iso8601.parse_date(argstr)
    return (True, argstr.replace(' ', 'T'))
  except (iso8601.ParseError, OverflowError):
    return (False, YYYYMMDDTHHMMSS_FORMAT_REQUIRED)

def mapQueryRelativeTimes(query, keywords):
  QUOTES = '\'"'
  for kw in keywords:
    pattern = re.compile(rf'({kw})\s*([<>]=?|=|!=)\s*[{QUOTES}]?(now|today|[+-]\d+[mhdwy])', re.IGNORECASE)
    pos = 0
    while True:
      mg = pattern.search(query, pos)
      if not mg:
        break
      if mg.groups()[2] is not None:
        deltaTime = getDelta(mg.group(3).upper(), DELTA_TIME_PATTERN)
        if deltaTime:
          query = query[:mg.start(3)]+ISOformatTimeStamp(deltaTime)+query[mg.end(3):]
      pos = mg.end()
  return query

class StartEndTime():
  def __init__(self, startkw='starttime', endkw='endtime', mode='time'):
    self.startTime = self.endTime = self.startDateTime = self.endDateTime = None
    self._startkw = startkw
    self._endkw = endkw
    self._getValueOrDeltaFromNow = getTimeOrDeltaFromNow if mode == 'time' else getDateOrDeltaFromNow

  def Get(self, myarg):
    if myarg in {'start', self._startkw}:
      self.startDateTime, _, self.startTime = self._getValueOrDeltaFromNow(True)
    elif myarg in {'end', self._endkw}:
      self.endDateTime, _, self.endTime = self._getValueOrDeltaFromNow(True)
    elif myarg == 'yesterday':
      currDate = todaysDate()
      self.startDateTime = currDate+datetime.timedelta(days=-1)
      self.startTime = ISOformatTimeStamp(self.startDateTime)
      self.endDateTime = currDate+datetime.timedelta(seconds=-1)
      self.endTime = ISOformatTimeStamp(self.endDateTime)
    elif myarg == 'today':
      currDate = todaysDate()
      self.startDateTime = currDate
      self.startTime = ISOformatTimeStamp(self.startDateTime)
    elif myarg == 'range':
      self.startDateTime, _, self.startTime = self._getValueOrDeltaFromNow(True)
      self.endDateTime, _, self.endTime = self._getValueOrDeltaFromNow(True)
    else: #elif myarg in {'thismonth', 'previousmonths'}
      if myarg == 'thismonth':
        firstMonth = 0
      else:
        firstMonth = getInteger(minVal=1, maxVal=6)
      currDate = todaysDate()
      self.startDateTime = currDate+relativedelta(months=-firstMonth, day=1, hour=0, minute=0, second=0, microsecond=0)
      self.startTime = ISOformatTimeStamp(self.startDateTime)
      if myarg == 'thismonth':
        self.endDateTime = todaysTime()
      else:
        self.endDateTime = currDate+relativedelta(day=1, hour=23, minute=59, second=59, microsecond=0)+relativedelta(days=-1)
      self.endTime = ISOformatTimeStamp(self.endDateTime)
    if self.startDateTime and self.endDateTime and self.endDateTime < self.startDateTime:
      Cmd.Backup()
      usageErrorExit(Msg.INVALID_DATE_TIME_RANGE.format(self._endkw, self.endTime, self._startkw, self.startTime))

EVENTID_PATTERN = re.compile(r'^[a-v0-9]{5,1024}$')
EVENTID_FORMAT_REQUIRED = '[a-v0-9]{5,1024}'

def getEventID():
  if Cmd.ArgumentsRemaining():
    tg = EVENTID_PATTERN.match(Cmd.Current().strip())
    if tg:
      Cmd.Advance()
      return tg.group(0)
    invalidArgumentExit(EVENTID_FORMAT_REQUIRED)
  missingArgumentExit(EVENTID_FORMAT_REQUIRED)

EVENT_TIME_FORMAT_REQUIRED = 'allday yyyy-mm-dd | '+YYYYMMDDTHHMMSS_FORMAT_REQUIRED

def getEventTime():
  if Cmd.ArgumentsRemaining():
    if Cmd.Current().strip().lower() == 'allday':
      Cmd.Advance()
      return {'date': getYYYYMMDD()}
    return {'dateTime': getTimeOrDeltaFromNow()}
  missingArgumentExit(EVENT_TIME_FORMAT_REQUIRED)

AGE_TIME_PATTERN = re.compile(r'^(\d+)([mhdw])$')
AGE_TIME_FORMAT_REQUIRED = '<Number>(m|h|d|w)'

def getAgeTime():
  if Cmd.ArgumentsRemaining():
    tg = AGE_TIME_PATTERN.match(Cmd.Current().strip().lower())
    if tg:
      age = int(tg.group(1))
      age_unit = tg.group(2)
      now = int(time.time())
      if age_unit == 'm':
        age = now-(age*SECONDS_PER_MINUTE)
      elif age_unit == 'h':
        age = now-(age*SECONDS_PER_HOUR)
      elif age_unit == 'd':
        age = now-(age*SECONDS_PER_DAY)
      else: # age_unit == 'w':
        age = now-(age*SECONDS_PER_WEEK)
      Cmd.Advance()
      return age*1000
    invalidArgumentExit(AGE_TIME_FORMAT_REQUIRED)
  missingArgumentExit(AGE_TIME_FORMAT_REQUIRED)

CALENDAR_REMINDER_METHODS = ['email', 'popup']
CALENDAR_REMINDER_MAX_MINUTES = 40320

def getCalendarReminder(allowClearNone=False):
  methods = CALENDAR_REMINDER_METHODS[:]
  if allowClearNone:
    methods += Cmd.CLEAR_NONE_ARGUMENT
  if Cmd.ArgumentsRemaining():
    method = Cmd.Current().strip()
    if not method.isdigit():
      method = getChoice(methods)
      minutes = getInteger(minVal=0, maxVal=CALENDAR_REMINDER_MAX_MINUTES)
    else:
      minutes = getInteger(minVal=0, maxVal=CALENDAR_REMINDER_MAX_MINUTES)
      method = getChoice(methods)
    return {'method': method, 'minutes': minutes}
  missingChoiceExit(methods)

def getCharacter():
  if Cmd.ArgumentsRemaining():
    argstr = codecs.escape_decode(bytes(Cmd.Current(), UTF8))[0].decode(UTF8)
    if argstr:
      if len(argstr) == 1:
        Cmd.Advance()
        return argstr
      invalidArgumentExit(f'{integerLimits(1, 1, Msg.STRING_LENGTH)} for {Cmd.OB_CHARACTER}')
    emptyArgumentExit(Cmd.OB_CHARACTER)
  missingArgumentExit(Cmd.OB_CHARACTER)

def getDelimiter():
  if not checkArgumentPresent('delimiter'):
    return None
  return getCharacter()

def getJSON(deleteFields):
  if not checkArgumentPresent('file'):
    encoding = getCharSet()
    if not Cmd.ArgumentsRemaining():
      missingArgumentExit(Cmd.OB_JSON_DATA)
    argstr = Cmd.Current()
#    argstr = Cmd.Current().replace(r'\\"', r'\"')
    Cmd.Advance()
    try:
      if encoding == UTF8:
        jsonData = json.loads(argstr)
      else:
        jsonData = json.loads(argstr.encode(encoding).decode(UTF8))
    except (IndexError, KeyError, SyntaxError, TypeError, ValueError) as e:
      Cmd.Backup()
      usageErrorExit(f'{str(e)}: {argstr if encoding == UTF8 else argstr.encode(encoding).decode(UTF8)}')
  else:
    filename = getString(Cmd.OB_FILE_NAME)
    encoding = getCharSet()
    try:
      jsonData = json.loads(readFile(filename, encoding=encoding))
    except (IndexError, KeyError, SyntaxError, TypeError, ValueError) as e:
      Cmd.Backup()
      usageErrorExit(Msg.JSON_ERROR.format(str(e), filename))
  for field in deleteFields:
    jsonData.pop(field, None)
  return jsonData

def getMatchSkipFields(fieldNames):
  matchFields = {}
  skipFields = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'matchfield', 'skipfield'}:
      matchField = getString(Cmd.OB_FIELD_NAME).strip('~')
      if (not matchField) or (matchField not in fieldNames):
        csvFieldErrorExit(matchField, fieldNames, backupArg=True)
      if myarg == 'matchfield':
        matchFields[matchField] = getREPattern()
      else:
        skipFields[matchField] = getREPattern()
    else:
      Cmd.Backup()
      break
  return (matchFields, skipFields)

def checkMatchSkipFields(row, fieldnames, matchFields, skipFields):
  for matchField, matchPattern in iter(matchFields.items()):
    if (matchField not in row) or not matchPattern.search(row[matchField]):
      return False
  for skipField, matchPattern in iter(skipFields.items()):
    if (skipField in row) and matchPattern.search(row[skipField]):
      return False
  if fieldnames and (GC.Values[GC.CSV_INPUT_ROW_FILTER] or GC.Values[GC.CSV_INPUT_ROW_DROP_FILTER]):
    return RowFilterMatch(row, fieldnames,
                          GC.Values[GC.CSV_INPUT_ROW_FILTER], GC.Values[GC.CSV_INPUT_ROW_FILTER_MODE],
                          GC.Values[GC.CSV_INPUT_ROW_DROP_FILTER], GC.Values[GC.CSV_INPUT_ROW_DROP_FILTER_MODE])
  return True

def checkSubkeyField():
  if not GM.Globals[GM.CSV_SUBKEY_FIELD]:
    Cmd.Backup()
    usageErrorExit(Msg.NO_CSV_FILE_SUBKEYS_SAVED)
  chkSubkeyField = getString(Cmd.OB_FIELD_NAME, checkBlank=True)
  if chkSubkeyField != GM.Globals[GM.CSV_SUBKEY_FIELD]:
    Cmd.Backup()
    usageErrorExit(Msg.SUBKEY_FIELD_MISMATCH.format(chkSubkeyField, GM.Globals[GM.CSV_SUBKEY_FIELD]))

def checkDataField():
  if not GM.Globals[GM.CSV_DATA_FIELD]:
    Cmd.Backup()
    usageErrorExit(Msg.NO_CSV_FILE_DATA_SAVED)
  chkDataField = getString(Cmd.OB_FIELD_NAME, checkBlank=True)
  if chkDataField != GM.Globals[GM.CSV_DATA_FIELD]:
    Cmd.Backup()
    usageErrorExit(Msg.DATA_FIELD_MISMATCH.format(chkDataField, GM.Globals[GM.CSV_DATA_FIELD]))

MAX_MESSAGE_BYTES_PATTERN = re.compile(r'^(\d+)([mkb]?)$')
MAX_MESSAGE_BYTES_FORMAT_REQUIRED = '<Number>[m|k|b]'

def getMaxMessageBytes(oneKiloBytes, oneMegaBytes):
  if Cmd.ArgumentsRemaining():
    tg = MAX_MESSAGE_BYTES_PATTERN.match(Cmd.Current().strip().lower())
    if tg:
      mmb = int(tg.group(1))
      mmb_unit = tg.group(2)
      if mmb_unit == 'm':
        mmb *= oneMegaBytes
      elif mmb_unit == 'k':
        mmb *= oneKiloBytes
      Cmd.Advance()
      return mmb
    invalidArgumentExit(MAX_MESSAGE_BYTES_FORMAT_REQUIRED)
  missingArgumentExit(MAX_MESSAGE_BYTES_FORMAT_REQUIRED)

# Get domain from email address
def getEmailAddressDomain(emailAddress):
  atLoc = emailAddress.find('@')
  if atLoc == -1:
    return GC.Values[GC.DOMAIN]
  return emailAddress[atLoc+1:].lower()

# Get user name from email address
def getEmailAddressUsername(emailAddress):
  atLoc = emailAddress.find('@')
  if atLoc == -1:
    return emailAddress.lower()
  return emailAddress[:atLoc].lower()

# Split email address into user and domain
def splitEmailAddress(emailAddress):
  atLoc = emailAddress.find('@')
  if atLoc == -1:
    return (emailAddress.lower(), GC.Values[GC.DOMAIN])
  return (emailAddress[:atLoc].lower(), emailAddress[atLoc+1:].lower())

def formatFileSize(fileSize):
  if fileSize == 0:
    return '0kb'
  if fileSize < ONE_KILO_10_BYTES:
    return '1kb'
  if fileSize < ONE_MEGA_10_BYTES:
    return f'{fileSize//ONE_KILO_10_BYTES}kb'
  if fileSize < ONE_GIGA_10_BYTES:
    return f'{fileSize//ONE_MEGA_10_BYTES}mb'
  return f'{fileSize//ONE_GIGA_10_BYTES}gb'

def formatLocalTime(dateTimeStr):
  if dateTimeStr in {NEVER_TIME, NEVER_TIME_NOMS}:
    return GC.Values[GC.NEVER_TIME]
  try:
    timestamp, _ = iso8601.parse_date(dateTimeStr)
    if not GC.Values[GC.OUTPUT_TIMEFORMAT]:
      if GM.Globals[GM.CONVERT_TO_LOCAL_TIME]:
        return ISOformatTimeStamp(timestamp.astimezone(GC.Values[GC.TIMEZONE]))
      return timestamp.strftime(YYYYMMDDTHHMMSSZ_FORMAT)
    if GM.Globals[GM.CONVERT_TO_LOCAL_TIME]:
      return timestamp.astimezone(GC.Values[GC.TIMEZONE]).strftime(GC.Values[GC.OUTPUT_TIMEFORMAT])
    return timestamp.strftime(GC.Values[GC.OUTPUT_TIMEFORMAT])
  except (iso8601.ParseError, OverflowError):
    return dateTimeStr

def formatLocalSecondsTimestamp(timestamp):
  if not GC.Values[GC.OUTPUT_TIMEFORMAT]:
    return ISOformatTimeStamp(datetime.datetime.fromtimestamp(int(timestamp), GC.Values[GC.TIMEZONE]))
  return datetime.datetime.fromtimestamp(int(timestamp), GC.Values[GC.TIMEZONE]).strftime(GC.Values[GC.OUTPUT_TIMEFORMAT])

def formatLocalTimestamp(timestamp):
  if not GC.Values[GC.OUTPUT_TIMEFORMAT]:
    return ISOformatTimeStamp(datetime.datetime.fromtimestamp(int(timestamp)//1000, GC.Values[GC.TIMEZONE]))
  return datetime.datetime.fromtimestamp(int(timestamp)//1000, GC.Values[GC.TIMEZONE]).strftime(GC.Values[GC.OUTPUT_TIMEFORMAT])

def formatLocalTimestampUTC(timestamp):
  return ISOformatTimeStamp(datetime.datetime.fromtimestamp(int(timestamp)//1000, iso8601.UTC))

def formatLocalDatestamp(timestamp):
  try:
    if not GC.Values[GC.OUTPUT_DATEFORMAT]:
      return datetime.datetime.fromtimestamp(int(timestamp)//1000, GC.Values[GC.TIMEZONE]).strftime(YYYYMMDD_FORMAT)
    return datetime.datetime.fromtimestamp(int(timestamp)//1000, GC.Values[GC.TIMEZONE]).strftime(GC.Values[GC.OUTPUT_DATEFORMAT])
  except OverflowError:
    return NEVER_DATE

def formatMaxMessageBytes(maxMessageBytes, oneKiloBytes, oneMegaBytes):
  if maxMessageBytes < oneKiloBytes:
    return maxMessageBytes
  if maxMessageBytes < oneMegaBytes:
    return f'{maxMessageBytes//oneKiloBytes}K'
  return f'{maxMessageBytes//oneMegaBytes}M'

def formatMilliSeconds(millis):
  seconds, millis = divmod(millis, 1000)
  minutes, seconds = divmod(seconds, 60)
  hours, minutes = divmod(minutes, 60)
  return f'{hours:02d}:{minutes:02d}:{seconds:02d}'

def getPhraseDNEorSNA(email):
  return Msg.DOES_NOT_EXIST if getEmailAddressDomain(email) == GC.Values[GC.DOMAIN] else Msg.SERVICE_NOT_APPLICABLE

def formatHTTPError(http_status, reason, message):
  return f'{http_status}: {reason} - {message}'

def getHTTPError(responses, http_status, reason, message):
  if reason in responses:
    return responses[reason]
  return formatHTTPError(http_status, reason, message)

# Warnings
def badRequestWarning(entityType, itemType, itemValue):
  printWarningMessage(BAD_REQUEST_RC,
                      f'{Msg.GOT} 0 {Ent.Plural(entityType)}: {Msg.INVALID} {Ent.Singular(itemType)} - {itemValue}')

def emptyQuery(query, entityType):
  return f'{Ent.Singular(Ent.QUERY)} ({query}) {Msg.NO_ENTITIES_FOUND.format(Ent.Plural(entityType))}'

def invalidQuery(query):
  return f'{Ent.Singular(Ent.QUERY)} ({query}) {Msg.INVALID}'

def invalidMember(query):
  if query:
    badRequestWarning(Ent.GROUP, Ent.QUERY, invalidQuery(query))
    return True
  return False

def invalidUserSchema(schema):
  if isinstance(schema, list):
    return f'{Ent.Singular(Ent.USER_SCHEMA)} ({",".join(schema)}) {Msg.INVALID}'
  return f'{Ent.Singular(Ent.USER_SCHEMA)} {schema}) {Msg.INVALID}'

def userServiceNotEnabledWarning(entityName, service, i=0, count=0):
  setSysExitRC(SERVICE_NOT_APPLICABLE_RC)
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 [Ent.Singular(Ent.USER), entityName, Msg.SERVICE_NOT_ENABLED.format(service)],
                                 currentCountNL(i, count)))

def userAlertsServiceNotEnabledWarning(entityName, i=0, count=0):
  userServiceNotEnabledWarning(entityName, 'Alerts', i, count)

def userAnalyticsServiceNotEnabledWarning(entityName, i=0, count=0):
  userServiceNotEnabledWarning(entityName, 'Alerts', i, count)

def userCalServiceNotEnabledWarning(entityName, i=0, count=0):
  userServiceNotEnabledWarning(entityName, 'Calendar', i, count)

def userChatServiceNotEnabledWarning(entityName, i=0, count=0):
  userServiceNotEnabledWarning(entityName, 'Chat', i, count)

def userContactDelegateServiceNotEnabledWarning(entityName, i=0, count=0):
  userServiceNotEnabledWarning(entityName, 'Contact Delegate', i, count)

def userDriveServiceNotEnabledWarning(user, errMessage, i=0, count=0):
#  if errMessage.find('Drive apps') == -1 and errMessage.find('Active session is invalid') == -1:
#    entityServiceNotApplicableWarning(Ent.USER, user, i, count)
  if errMessage.find('Drive apps') >= 0 or errMessage.find('Active session is invalid') >= 0:
    userServiceNotEnabledWarning(user, 'Drive', i, count)
  else:
    entityActionNotPerformedWarning([Ent.USER, user], errMessage, i, count)

def userKeepServiceNotEnabledWarning(entityName, i=0, count=0):
  userServiceNotEnabledWarning(entityName, 'Keep', i, count)

def userGmailServiceNotEnabledWarning(entityName, i=0, count=0):
  userServiceNotEnabledWarning(entityName, 'Gmail', i, count)

def userLookerStudioServiceNotEnabledWarning(entityName, i=0, count=0):
  userServiceNotEnabledWarning(entityName, 'Looker Studio', i, count)

def userPeopleServiceNotEnabledWarning(entityName, i=0, count=0):
  userServiceNotEnabledWarning(entityName, 'People', i, count)

def userTasksServiceNotEnabledWarning(entityName, i=0, count=0):
  userServiceNotEnabledWarning(entityName, 'Tasks', i, count)

def userYouTubeServiceNotEnabledWarning(entityName, i=0, count=0):
  userServiceNotEnabledWarning(entityName, 'YouTube', i, count)

def entityServiceNotApplicableWarning(entityType, entityName, i=0, count=0):
  setSysExitRC(SERVICE_NOT_APPLICABLE_RC)
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 [Ent.Singular(entityType), entityName, Msg.SERVICE_NOT_APPLICABLE],
                                 currentCountNL(i, count)))

def entityDoesNotExistWarning(entityType, entityName, i=0, count=0):
  setSysExitRC(ENTITY_DOES_NOT_EXIST_RC)
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 [Ent.Singular(entityType), entityName, Msg.DOES_NOT_EXIST],
                                 currentCountNL(i, count)))

def entityListDoesNotExistWarning(entityValueList, i=0, count=0):
  setSysExitRC(ENTITY_DOES_NOT_EXIST_RC)
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[Msg.DOES_NOT_EXIST],
                                 currentCountNL(i, count)))

def entityUnknownWarning(entityType, entityName, i=0, count=0):
  domain = getEmailAddressDomain(entityName)
  if (domain.endswith(GC.Values[GC.DOMAIN])) or (domain.endswith('google.com')):
    entityDoesNotExistWarning(entityType, entityName, i, count)
  else:
    entityServiceNotApplicableWarning(entityType, entityName, i, count)

def entityOrEntityUnknownWarning(entity1Type, entity1Name, entity2Type, entity2Name, i=0, count=0):
  setSysExitRC(ENTITY_DOES_NOT_EXIST_RC)
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 [f'{Msg.EITHER} {Ent.Singular(entity1Type)}', entity1Name, getPhraseDNEorSNA(entity1Name), None,
                                  f'{Msg.OR} {Ent.Singular(entity2Type)}', entity2Name, getPhraseDNEorSNA(entity2Name)],
                                 currentCountNL(i, count)))

def entityDoesNotHaveItemWarning(entityValueList, i=0, count=0):
  setSysExitRC(ENTITY_DOES_NOT_EXIST_RC)
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[Msg.DOES_NOT_EXIST],
                                 currentCountNL(i, count)))

def duplicateAliasGroupUserWarning(cd, entityValueList, i=0, count=0):
  email = entityValueList[1]
  try:
    result = callGAPI(cd.users(), 'get',
                      throwReasons=GAPI.USER_GET_THROW_REASONS,
                      userKey=email, fields='id,primaryEmail')
    if (result['primaryEmail'].lower() == email) or (result['id'] == email):
      kvList = [Ent.USER, email]
    else:
      kvList = [Ent.USER_ALIAS, email, Ent.USER, result['primaryEmail']]
  except (GAPI.userNotFound, GAPI.badRequest,
          GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.backendError, GAPI.systemError):
    try:
      result = callGAPI(cd.groups(), 'get',
                        throwReasons=GAPI.GROUP_GET_THROW_REASONS,
                        groupKey=email, fields='id,email')
      if (result['email'].lower() == email) or (result['id'] == email):
        kvList = [Ent.GROUP, email]
      else:
        kvList = [Ent.GROUP_ALIAS, email, Ent.GROUP, result['email']]
    except (GAPI.groupNotFound,
            GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest):
      kvList = [Ent.EMAIL, email]
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+
                                 [Act.Failed(), Msg.DUPLICATE]+
                                 Ent.FormatEntityValueList(kvList),
                                 currentCountNL(i, count)))
  setSysExitRC(ENTITY_DUPLICATE_RC)
  return kvList[0]

def entityDuplicateWarning(entityValueList, i=0, count=0):
  setSysExitRC(ENTITY_DUPLICATE_RC)
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[Act.Failed(), Msg.DUPLICATE],
                                 currentCountNL(i, count)))

def entityActionFailedWarning(entityValueList, errMessage, i=0, count=0):
  setSysExitRC(ACTION_FAILED_RC)
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[Act.Failed(), errMessage],
                                 currentCountNL(i, count)))

def entityModifierItemValueListActionFailedWarning(entityValueList, modifier, infoTypeValueList, errMessage, i=0, count=0):
  setSysExitRC(ACTION_FAILED_RC)
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {modifier}', None]+Ent.FormatEntityValueList(infoTypeValueList)+[Act.Failed(), errMessage],
                                 currentCountNL(i, count)))

def entityModifierActionFailedWarning(entityValueList, modifier, errMessage, i=0, count=0):
  setSysExitRC(ACTION_FAILED_RC)
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {modifier}', Act.Failed(), errMessage],
                                 currentCountNL(i, count)))

def entityModifierNewValueActionFailedWarning(entityValueList, modifier, newValue, errMessage, i=0, count=0):
  setSysExitRC(ACTION_FAILED_RC)
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {modifier}', newValue, Act.Failed(), errMessage],
                                 currentCountNL(i, count)))

def entityNumEntitiesActionFailedWarning(entityType, entityName, itemType, itemCount, errMessage, i=0, count=0):
  setSysExitRC(ACTION_FAILED_RC)
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 [Ent.Singular(entityType), entityName,
                                  Ent.Choose(itemType, itemCount), itemCount,
                                  Act.Failed(), errMessage],
                                 currentCountNL(i, count)))

def entityActionNotPerformedWarning(entityValueList, errMessage, i=0, count=0):
  setSysExitRC(ACTION_NOT_PERFORMED_RC)
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[Act.NotPerformed(), errMessage],
                                 currentCountNL(i, count)))

def entityItemValueListActionNotPerformedWarning(entityValueList, infoTypeValueList, errMessage, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[Act.NotPerformed(), '']+Ent.FormatEntityValueList(infoTypeValueList)+[errMessage],
                                 currentCountNL(i, count)))

def entityModifierItemValueListActionNotPerformedWarning(entityValueList, modifier, infoTypeValueList, errMessage, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.NotPerformed()} {modifier}', None]+Ent.FormatEntityValueList(infoTypeValueList)+[errMessage],
                                 currentCountNL(i, count)))

def entityNumEntitiesActionNotPerformedWarning(entityValueList, itemType, itemCount, errMessage, i=0, count=0):
  setSysExitRC(ACTION_NOT_PERFORMED_RC)
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[Ent.Choose(itemType, itemCount), itemCount, Act.NotPerformed(), errMessage],
                                 currentCountNL(i, count)))

def entityBadRequestWarning(entityValueList, errMessage, i=0, count=0):
  setSysExitRC(BAD_REQUEST_RC)
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[ERROR, errMessage],
                                 currentCountNL(i, count)))

# Getting ... utilities
def printGettingAllAccountEntities(entityType, query='', qualifier='', accountType=Ent.ACCOUNT):
  if GC.Values[GC.SHOW_GETTINGS]:
    if query:
      Ent.SetGettingQuery(entityType, query)
    elif qualifier:
      Ent.SetGettingQualifier(entityType, qualifier)
    else:
      Ent.SetGetting(entityType)
    writeStderr(f'{Msg.GETTING_ALL} {Ent.PluralGetting()}{Ent.GettingPreQualifier()}{Ent.MayTakeTime(accountType)}\n')

def printGotAccountEntities(count):
  if GC.Values[GC.SHOW_GETTINGS]:
    writeStderr(f'{Msg.GOT} {count} {Ent.ChooseGetting(count)}{Ent.GettingPostQualifier()}\n')

def setGettingAllEntityItemsForWhom(entityItem, forWhom, query='', qualifier=''):
  if GC.Values[GC.SHOW_GETTINGS]:
    if query:
      Ent.SetGettingQuery(entityItem, query)
    elif qualifier:
      Ent.SetGettingQualifier(entityItem, qualifier)
    else:
      Ent.SetGetting(entityItem)
    Ent.SetGettingForWhom(forWhom)

def printGettingAllEntityItemsForWhom(entityItem, forWhom, i=0, count=0, query='', qualifier='', entityType=None):
  if GC.Values[GC.SHOW_GETTINGS]:
    setGettingAllEntityItemsForWhom(entityItem, forWhom, query=query, qualifier=qualifier)
    writeStderr(f'{Msg.GETTING_ALL} {Ent.PluralGetting()}{Ent.GettingPreQualifier()} {Msg.FOR} {forWhom}{Ent.MayTakeTime(entityType)}{currentCountNL(i, count)}')

def printGotEntityItemsForWhom(count):
  if GC.Values[GC.SHOW_GETTINGS]:
    writeStderr(f'{Msg.GOT} {count} {Ent.ChooseGetting(count)}{Ent.GettingPostQualifier()} {Msg.FOR} {Ent.GettingForWhom()}\n')

def printGettingEntityItem(entityType, entityItem, i=0, count=0):
  if GC.Values[GC.SHOW_GETTINGS]:
    writeStderr(f'{Msg.GETTING} {Ent.Singular(entityType)} {entityItem}{currentCountNL(i, count)}')

def printGettingEntityItemForWhom(entityItem, forWhom, i=0, count=0):
  if GC.Values[GC.SHOW_GETTINGS]:
    Ent.SetGetting(entityItem)
    Ent.SetGettingForWhom(forWhom)
    writeStderr(f'{Msg.GETTING} {Ent.PluralGetting()} {Msg.FOR} {forWhom}{currentCountNL(i, count)}')

def stderrEntityMessage(entityValueList, message, i=0, count=0):
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[message],
                                 currentCountNL(i, count)))

FIRST_ITEM_MARKER = '%%first_item%%'
LAST_ITEM_MARKER = '%%last_item%%'
TOTAL_ITEMS_MARKER = '%%total_items%%'

def getPageMessage(showFirstLastItems=False, showDate=None):
  if not GC.Values[GC.SHOW_GETTINGS]:
    return None
  pageMessage = f'{Msg.GOT} {TOTAL_ITEMS_MARKER} {{0}}'
  if showDate:
    pageMessage += f' on {showDate}'
  if showFirstLastItems:
    pageMessage += f': {FIRST_ITEM_MARKER} - {LAST_ITEM_MARKER}'
  else:
    pageMessage += '...'
  if GC.Values[GC.SHOW_GETTINGS_GOT_NL]:
    pageMessage += '\n'
  else:
    GM.Globals[GM.LAST_GOT_MSG_LEN] = 0
  return pageMessage

def getPageMessageForWhom(forWhom=None, showFirstLastItems=False, showDate=None, clearLastGotMsgLen=True):
  if not GC.Values[GC.SHOW_GETTINGS]:
    return None
  if forWhom:
    Ent.SetGettingForWhom(forWhom)
  pageMessage = f'{Msg.GOT} {TOTAL_ITEMS_MARKER} {{0}}{Ent.GettingPostQualifier()} {Msg.FOR} {Ent.GettingForWhom()}'
  if showDate:
    pageMessage += f' on {showDate}'
  if showFirstLastItems:
    pageMessage += f': {FIRST_ITEM_MARKER} - {LAST_ITEM_MARKER}'
  else:
    pageMessage += '...'
  if GC.Values[GC.SHOW_GETTINGS_GOT_NL]:
    pageMessage += '\n'
  elif clearLastGotMsgLen:
    GM.Globals[GM.LAST_GOT_MSG_LEN] = 0
  return pageMessage

def printLine(message):
  writeStdout(message+'\n')

def printBlankLine():
  writeStdout('\n')

def printKeyValueList(kvList):
  writeStdout(formatKeyValueList(Ind.Spaces(), kvList, '\n'))

def printKeyValueListWithCount(kvList, i, count):
  writeStdout(formatKeyValueList(Ind.Spaces(), kvList, currentCountNL(i, count)))

def printKeyValueDict(kvDict):
  for key, value in iter(kvDict.items()):
    writeStdout(formatKeyValueList(Ind.Spaces(), [key, value], '\n'))

def printKeyValueWithCRsNLs(key, value):
  if value.find('\n') >= 0 or value.find('\r') >= 0:
    if GC.Values[GC.SHOW_CONVERT_CR_NL]:
      printKeyValueList([key, escapeCRsNLs(value)])
    else:
      printKeyValueList([key, ''])
      Ind.Increment()
      printKeyValueList([Ind.MultiLineText(value)])
      Ind.Decrement()
  else:
    printKeyValueList([key, value])

def printJSONKey(key):
  writeStdout(formatKeyValueList(Ind.Spaces(), [key, None], ''))

def printJSONValue(value):
  writeStdout(formatKeyValueList(' ', [value], '\n'))

def printEntity(entityValueList, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList),
                                 currentCountNL(i, count)))

def printEntityMessage(entityValueList, message, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[message],
                                 currentCountNL(i, count)))

def printEntitiesCount(entityType, entityList):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 [Ent.Plural(entityType), None if entityList is None else f'({len(entityList)})'],
                                 '\n'))

def printEntityKVList(entityValueList, infoKVList, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+infoKVList,
                                 currentCountNL(i, count)))

def performAction(entityType, entityValue, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 [f'{Act.ToPerform()} {Ent.Singular(entityType)} {entityValue}'],
                                 currentCountNL(i, count)))

def performActionNumItems(itemCount, itemType, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 [f'{Act.ToPerform()} {itemCount} {Ent.Choose(itemType, itemCount)}'],
                                 currentCountNL(i, count)))

def performActionModifierNumItems(modifier, itemCount, itemType, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 [f'{Act.ToPerform()} {modifier} {itemCount} {Ent.Choose(itemType, itemCount)}'],
                                 currentCountNL(i, count)))

def actionPerformedNumItems(itemCount, itemType, i=0, count=0):
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 [f'{itemCount} {Ent.Choose(itemType, itemCount)} {Act.Performed()} '],
                                 currentCountNL(i, count)))

def actionFailedNumItems(itemCount, itemType, errMessage, i=0, count=0):
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 [f'{itemCount} {Ent.Choose(itemType, itemCount)} {Act.Failed()}: {errMessage} '],
                                 currentCountNL(i, count)))

def actionNotPerformedNumItemsWarning(itemCount, itemType, errMessage, i=0, count=0):
  setSysExitRC(ACTION_NOT_PERFORMED_RC)
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 [Ent.Choose(itemType, itemCount), itemCount, Act.NotPerformed(), errMessage],
                                 currentCountNL(i, count)))

def entityPerformAction(entityValueList, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()}'],
                                 currentCountNL(i, count)))

def entityPerformActionNumItems(entityValueList, itemCount, itemType, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {itemCount} {Ent.Choose(itemType, itemCount)}'],
                                 currentCountNL(i, count)))

def entityPerformActionModifierNumItems(entityValueList, modifier, itemCount, itemType, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {modifier} {itemCount} {Ent.Choose(itemType, itemCount)}'],
                                 currentCountNL(i, count)))

def entityPerformActionNumItemsModifier(entityValueList, itemCount, itemType, modifier, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {itemCount} {Ent.Choose(itemType, itemCount)} {modifier}'],
                                 currentCountNL(i, count)))

def entityPerformActionSubItemModifierNumItems(entityValueList, subitemType, modifier, itemCount, itemType, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {Ent.Plural(subitemType)} {modifier} {itemCount} {Ent.Choose(itemType, itemCount)}'],
                                 currentCountNL(i, count)))

def entityPerformActionSubItemModifierNumItemsModifierNewValue(entityValueList, subitemType, modifier1, itemCount, itemType, modifier2, newValue, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+
                                 [f'{Act.ToPerform()} {Ent.Plural(subitemType)} {modifier1} {itemCount} {Ent.Choose(itemType, itemCount)} {modifier2}', newValue],
                                 currentCountNL(i, count)))

def entityPerformActionModifierNumItemsModifier(entityValueList, modifier1, itemCount, itemType, modifier2, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {modifier1} {itemCount} {Ent.Choose(itemType, itemCount)} {modifier2}'],
                                 currentCountNL(i, count)))

def entityPerformActionModifierItemValueList(entityValueList, modifier, infoTypeValueList, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {modifier}', None]+Ent.FormatEntityValueList(infoTypeValueList),
                                 currentCountNL(i, count)))

def entityPerformActionModifierNewValue(entityValueList, modifier, newValue, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {modifier}', newValue],
                                 currentCountNL(i, count)))

def entityPerformActionModifierNewValueItemValueList(entityValueList, modifier, newValue, infoTypeValueList, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {modifier}', newValue]+Ent.FormatEntityValueList(infoTypeValueList),
                                 currentCountNL(i, count)))

def entityPerformActionItemValue(entityValueList, itemType, itemValue, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[Act.ToPerform(), None, Ent.Singular(itemType), itemValue],
                                 currentCountNL(i, count)))

def entityPerformActionInfo(entityValueList, infoValue, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[Act.ToPerform(), infoValue],
                                 currentCountNL(i, count)))

def entityActionPerformed(entityValueList, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[Act.Performed()],
                                 currentCountNL(i, count)))

def entityActionPerformedMessage(entityValueList, message, i=0, count=0):
  if message:
    writeStdout(formatKeyValueList(Ind.Spaces(),
                                   Ent.FormatEntityValueList(entityValueList)+[Act.Performed(), message],
                                   currentCountNL(i, count)))
  else:
    writeStdout(formatKeyValueList(Ind.Spaces(),
                                   Ent.FormatEntityValueList(entityValueList)+[Act.Performed()],
                                   currentCountNL(i, count)))

def entityNumItemsActionPerformed(entityValueList, itemCount, itemType, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{itemCount} {Ent.Choose(itemType, itemCount)} {Act.Performed()}'],
                                 currentCountNL(i, count)))

def entityModifierActionPerformed(entityValueList, modifier, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.Performed()} {modifier}', None],
                                 currentCountNL(i, count)))

def entityModifierItemValueListActionPerformed(entityValueList, modifier, infoTypeValueList, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.Performed()} {modifier}', None]+Ent.FormatEntityValueList(infoTypeValueList),
                                 currentCountNL(i, count)))

def entityModifierNewValueActionPerformed(entityValueList, modifier, newValue, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.Performed()} {modifier}', newValue],
                                 currentCountNL(i, count)))

def entityModifierNewValueItemValueListActionPerformed(entityValueList, modifier, newValue, infoTypeValueList, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.Performed()} {modifier}', newValue]+Ent.FormatEntityValueList(infoTypeValueList),
                                 currentCountNL(i, count)))

def entityModifierNewValueKeyValueActionPerformed(entityValueList, modifier, newValue, infoKey, infoValue, i=0, count=0):
  writeStdout(formatKeyValueList(Ind.Spaces(),
                                 Ent.FormatEntityValueList(entityValueList)+[f'{Act.Performed()} {modifier}', newValue, infoKey, infoValue],
                                 currentCountNL(i, count)))

def cleanFilename(filename):
  return sanitize_filename(filename, '_')

def setFilePath(fileName):
  if fileName.startswith('./') or fileName.startswith('.\\'):
    fileName = os.path.join(os.getcwd(), fileName[2:])
  else:
    fileName = os.path.expanduser(fileName)
  if not os.path.isabs(fileName):
    fileName = os.path.join(GC.Values[GC.DRIVE_DIR], fileName)
  return fileName

def uniqueFilename(targetFolder, filetitle, overwrite, extension=None):
  filename = filetitle
  y = 0
  while True:
    if extension is not None and filename.lower()[-len(extension):] != extension.lower():
      filename += extension
    filepath = os.path.join(targetFolder, filename)
    if overwrite or not os.path.isfile(filepath):
      return (filepath, filename)
    y += 1
    filename = f'({y})-{filetitle}'

def cleanFilepath(filepath):
  return sanitize_filepath(filepath, platform='auto')

def fileErrorMessage(filename, e, entityType=Ent.FILE):
  return f'{Ent.Singular(entityType)}: {filename}, {str(e)}'

def fdErrorMessage(f, defaultFilename, e):
  return fileErrorMessage(getattr(f, 'name') if hasattr(f, 'name') else defaultFilename, e)

# Set file encoding to handle UTF8 BOM
def setEncoding(mode, encoding):
  if 'b' in mode:
    return {}
  if not encoding:
    encoding = GM.Globals[GM.SYS_ENCODING]
  if 'r' in mode and encoding.lower().replace('-', '') == 'utf8':
    encoding = UTF8_SIG
  return {'encoding': encoding}

def StringIOobject(initbuff=None):
  if initbuff is None:
    return io.StringIO()
  return io.StringIO(initbuff)

# Open a file
def openFile(filename, mode=DEFAULT_FILE_READ_MODE, encoding=None, errors=None, newline=None,
             continueOnError=False, displayError=True, stripUTFBOM=False):
  try:
    if filename != '-':
      kwargs = setEncoding(mode, encoding)
      f = open(os.path.expanduser(filename), mode, errors=errors, newline=newline, **kwargs)
      if stripUTFBOM:
        if 'b' in mode:
          if f.read(3) != b'\xef\xbb\xbf':
            f.seek(0)
        elif not kwargs['encoding'].lower().startswith('utf'):
          if f.read(3).encode('iso-8859-1', 'replace') != codecs.BOM_UTF8:
            f.seek(0)
        else:
          if f.read(1) != '\ufeff':
            f.seek(0)
      return f
    if 'r' in mode:
      return StringIOobject(str(sys.stdin.read()))
    if 'b' not in mode:
      return sys.stdout
    return os.fdopen(os.dup(sys.stdout.fileno()), 'wb')
  except (IOError, LookupError, UnicodeDecodeError, UnicodeError) as e:
    if continueOnError:
      if displayError:
        stderrWarningMsg(fileErrorMessage(filename, e))
        setSysExitRC(FILE_ERROR_RC)
      return None
    systemErrorExit(FILE_ERROR_RC, fileErrorMessage(filename, e))

# Close a file
def closeFile(f, forceFlush=False):
  try:
    if forceFlush:
      # Necessary to make sure file is flushed by both Python and OS
      # https://stackoverflow.com/a/13762137/1503886
      f.flush()
      os.fsync(f.fileno())
    f.close()
    return True
  except IOError as e:
    stderrErrorMsg(fdErrorMessage(f, UNKNOWN, e))
    setSysExitRC(FILE_ERROR_RC)
    return False

# Read a file
def readFile(filename, mode=DEFAULT_FILE_READ_MODE, encoding=None, newline=None,
             continueOnError=False, displayError=True):
  try:
    if filename != '-':
      kwargs = setEncoding(mode, encoding)
      with open(os.path.expanduser(filename), mode, newline=newline, **kwargs) as f:
        return f.read()
    return str(sys.stdin.read())
  except (IOError, LookupError, UnicodeDecodeError, UnicodeError) as e:
    if continueOnError:
      if displayError:
        stderrWarningMsg(fileErrorMessage(filename, e))
        setSysExitRC(FILE_ERROR_RC)
      return None
    systemErrorExit(FILE_ERROR_RC, fileErrorMessage(filename, e))

# Write a file
def writeFile(filename, data, mode=DEFAULT_FILE_WRITE_MODE,
              continueOnError=False, displayError=True):
  try:
    if filename != '-':
      kwargs = setEncoding(mode, None)
      with open(os.path.expanduser(filename), mode, **kwargs) as f:
        f.write(data)
      return True
    GM.Globals[GM.STDOUT].get(GM.REDIRECT_MULTI_FD, sys.stdout).write(data)
    return True
  except (IOError, LookupError, UnicodeDecodeError, UnicodeError) as e:
    if continueOnError:
      if displayError:
        stderrErrorMsg(fileErrorMessage(filename, e))
      setSysExitRC(FILE_ERROR_RC)
      return False
    systemErrorExit(FILE_ERROR_RC, fileErrorMessage(filename, e))

# Write a file, return error
def writeFileReturnError(filename, data, mode=DEFAULT_FILE_WRITE_MODE):
  try:
    kwargs = {'encoding': GM.Globals[GM.SYS_ENCODING]} if 'b' not in mode else {}
    with open(os.path.expanduser(filename), mode, **kwargs) as f:
      f.write(data)
    return (True, None)
  except (IOError, LookupError, UnicodeDecodeError, UnicodeError) as e:
    return (False, e)

# Delete a file
def deleteFile(filename, continueOnError=False, displayError=True):
  if os.path.isfile(filename):
    try:
      os.remove(filename)
    except OSError as e:
      if continueOnError:
        if displayError:
          stderrWarningMsg(fileErrorMessage(filename, e))
        return
      systemErrorExit(FILE_ERROR_RC, fileErrorMessage(filename, e))

def getGDocSheetDataRetryWarning(entityValueList, errMsg, i=0, count=0):
  action = Act.Get()
  Act.Set(Act.RETRIEVE_DATA)
  stderrWarningMsg(formatKeyValueList(Ind.Spaces(),
                                      Ent.FormatEntityValueList(entityValueList)+[Act.NotPerformed(), errMsg, 'Retry', ''],
                                      currentCountNL(i, count)))
  Act.Set(action)

def getGDocSheetDataFailedExit(entityValueList, errMsg, i=0, count=0):
  Act.Set(Act.RETRIEVE_DATA)
  systemErrorExit(ACTION_FAILED_RC, formatKeyValueList(Ind.Spaces(),
                                                       Ent.FormatEntityValueList(entityValueList)+[Act.NotPerformed(), errMsg],
                                                       currentCountNL(i, count)))

GDOC_FORMAT_MIME_TYPES = {
  'gcsv': MIMETYPE_TEXT_CSV,
  'gdoc': MIMETYPE_TEXT_PLAIN,
  'ghtml': MIMETYPE_TEXT_HTML,
  }

# gdoc <EmailAddress> <DriveFileIDEntity>|<DriveFileNameEntity>
def getGDocData(gformat):
  mimeType = GDOC_FORMAT_MIME_TYPES[gformat]
  user = getEmailAddress()
  fileIdEntity = getDriveFileEntity(queryShortcutsOK=False)
  user, drive, jcount = _validateUserGetFileIDs(user, 0, 0, fileIdEntity)
  if not drive:
    sys.exit(GM.Globals[GM.SYSEXITRC])
  if jcount == 0:
    getGDocSheetDataFailedExit([Ent.USER, user], Msg.NO_ENTITIES_FOUND.format(Ent.Singular(Ent.DRIVE_FILE)))
  if jcount > 1:
    getGDocSheetDataFailedExit([Ent.USER, user], Msg.MULTIPLE_ENTITIES_FOUND.format(Ent.Plural(Ent.DRIVE_FILE), jcount, ','.join(fileIdEntity['list'])))
  fileId = fileIdEntity['list'][0]
  try:
    result = callGAPI(drive.files(), 'get',
                      throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                      fileId=fileId, fields='name,mimeType,exportLinks',
                      supportsAllDrives=True)
# Google Doc
    if 'exportLinks' in result:
      if mimeType not in result['exportLinks']:
        getGDocSheetDataFailedExit([Ent.USER, user, Ent.DRIVE_FILE, result['name']],
                                   Msg.INVALID_MIMETYPE.format(result['mimeType'], mimeType))
      f = TemporaryFile(mode='w+', encoding=UTF8)
      _, content = drive._http.request(uri=result['exportLinks'][mimeType], method='GET')
      f.write(content.decode(UTF8_SIG))
      f.seek(0)
      return f
# Drive File
    if result['mimeType'] != mimeType:
      getGDocSheetDataFailedExit([Ent.USER, user, Ent.DRIVE_FILE, result['name']],
                                 Msg.INVALID_MIMETYPE.format(result['mimeType'], mimeType))
    fb = TemporaryFile(mode='wb+')
    request = drive.files().get_media(fileId=fileId)
    downloader = googleapiclient.http.MediaIoBaseDownload(fb, request)
    done = False
    while not done:
      _, done = downloader.next_chunk()
    f = TemporaryFile(mode='w+', encoding=UTF8)
    fb.seek(0)
    f.write(fb.read().decode(UTF8_SIG))
    fb.close()
    f.seek(0)
    return f
  except GAPI.fileNotFound:
    getGDocSheetDataFailedExit([Ent.USER, user, Ent.DOCUMENT, fileId], Msg.DOES_NOT_EXIST)
  except (IOError, httplib2.HttpLib2Error, google.auth.exceptions.TransportError, RuntimeError) as e:
    if f:
      f.close()
    getGDocSheetDataFailedExit([Ent.USER, user, Ent.DOCUMENT, fileId], str(e))
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
    userDriveServiceNotEnabledWarning(user, str(e))
    sys.exit(GM.Globals[GM.SYSEXITRC])

HTML_TITLE_PATTERN = re.compile(r'.*<title>(.+)</title>')

# gsheet <EmailAddress> <DriveFileIDEntity>|<DriveFileNameEntity> <SheetEntity>
def getGSheetData():
  user = getEmailAddress()
  fileIdEntity = getDriveFileEntity(queryShortcutsOK=False)
  sheetEntity = getSheetEntity(False)
  user, drive, jcount = _validateUserGetFileIDs(user, 0, 0, fileIdEntity)
  if not drive:
    sys.exit(GM.Globals[GM.SYSEXITRC])
  if jcount == 0:
    getGDocSheetDataFailedExit([Ent.USER, user], Msg.NO_ENTITIES_FOUND.format(Ent.Singular(Ent.DRIVE_FILE)))
  if jcount > 1:
    getGDocSheetDataFailedExit([Ent.USER, user], Msg.MULTIPLE_ENTITIES_FOUND.format(Ent.Plural(Ent.DRIVE_FILE), jcount, ','.join(fileIdEntity['list'])))
  _, sheet = buildGAPIServiceObject(API.SHEETS, user)
  if not sheet:
    sys.exit(GM.Globals[GM.SYSEXITRC])
  fileId = fileIdEntity['list'][0]
  try:
    result = callGAPI(drive.files(), 'get',
                      throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                      fileId=fileId, fields='name,mimeType', supportsAllDrives=True)
    if result['mimeType'] != MIMETYPE_GA_SPREADSHEET:
      getGDocSheetDataFailedExit([Ent.USER, user, Ent.DRIVE_FILE, result['name']],
                                 Msg.INVALID_MIMETYPE.format(result['mimeType'], MIMETYPE_GA_SPREADSHEET))
    spreadsheet = callGAPI(sheet.spreadsheets(), 'get',
                           throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                           spreadsheetId=fileId, fields='spreadsheetUrl,sheets(properties(sheetId,title))')
    sheetId = getSheetIdFromSheetEntity(spreadsheet, sheetEntity)
    if sheetId is None:
      getGDocSheetDataFailedExit([Ent.USER, user, Ent.SPREADSHEET, result['name'], sheetEntity['sheetType'], sheetEntity['sheetValue']], Msg.NOT_FOUND)
    spreadsheetUrl = f'{re.sub("/edit.*$", "/export", spreadsheet["spreadsheetUrl"])}?format=csv&id={fileId}&gid={sheetId}'
    f = TemporaryFile(mode='w+', encoding=UTF8)
    if GC.Values[GC.DEBUG_LEVEL] > 0:
      sys.stderr.write(f'Debug: spreadsheetUrl: {spreadsheetUrl}\n')
    triesLimit = 3
    for n in range(1, triesLimit+1):
      _, content = drive._http.request(uri=spreadsheetUrl, method='GET')
# Check for HTML error message instead of data
      if content[0:15] != b'<!DOCTYPE html>':
        break
      tg = HTML_TITLE_PATTERN.match(content[0:600].decode('utf-8'))
      errMsg = tg.group(1) if tg else 'Unknown error'
      getGDocSheetDataRetryWarning([Ent.USER, user, Ent.SPREADSHEET, result['name'], sheetEntity['sheetType'], sheetEntity['sheetValue']], errMsg, n, triesLimit)
      time.sleep(20)
    else:
      getGDocSheetDataFailedExit([Ent.USER, user, Ent.SPREADSHEET, result['name'], sheetEntity['sheetType'], sheetEntity['sheetValue']], errMsg)
    f.write(content.decode(UTF8_SIG))
    f.seek(0)
    return f
  except GAPI.fileNotFound:
    getGDocSheetDataFailedExit([Ent.USER, user, Ent.SPREADSHEET, fileId], Msg.DOES_NOT_EXIST)
  except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
          GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
          GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
    getGDocSheetDataFailedExit([Ent.USER, user, Ent.SPREADSHEET, fileId, sheetEntity['sheetType'], sheetEntity['sheetValue']], str(e))
  except (IOError, httplib2.HttpLib2Error) as e:
    if f:
      f.close()
    getGDocSheetDataFailedExit([Ent.USER, user, Ent.SPREADSHEET, fileId, sheetEntity['sheetType'], sheetEntity['sheetValue']], str(e))
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
    userDriveServiceNotEnabledWarning(user, str(e))
    sys.exit(GM.Globals[GM.SYSEXITRC])


BUCKET_OBJECT_PATTERNS = [
  {'pattern': re.compile(r'https://storage.(?:googleapis|cloud.google).com/(.+?)/(.+)'), 'unquote': True},
  {'pattern': re.compile(r'gs://(.+?)/(.+)'), 'unquote': False},
  {'pattern': re.compile(r'(.+?)/(.+)'), 'unquote': False},
  ]

def getBucketObjectName():
  uri = getString(Cmd.OB_STRING)
  for pattern in BUCKET_OBJECT_PATTERNS:
    mg = re.search(pattern['pattern'], uri)
    if mg:
      bucket = mg.group(1)
      s_object = mg.group(2) if not pattern['unquote'] else unquote(mg.group(2))
      return (bucket, s_object, f'{bucket}/{s_object}')
  systemErrorExit(ACTION_NOT_PERFORMED_RC, f'Invalid <StorageBucketObjectName>: {uri}')

GCS_FORMAT_MIME_TYPES = {
  'gcscsv': MIMETYPE_TEXT_CSV,
  'gcsdoc': MIMETYPE_TEXT_PLAIN,
  'gcshtml': MIMETYPE_TEXT_HTML,
  }

# gcscsv|gcshtml|gcsdoc <StorageBucketObjectName>
def getStorageFileData(gcsformat, returnData=True):
  mimeType = GCS_FORMAT_MIME_TYPES[gcsformat]
  bucket, s_object, bucketObject = getBucketObjectName()
  s = buildGAPIObject(API.STORAGEREAD)
  try:
    result = callGAPI(s.objects(), 'get',
                      throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                      bucket=bucket, object=s_object, projection='noAcl', fields='contentType')
  except GAPI.notFound:
    entityDoesNotExistExit(Ent.CLOUD_STORAGE_FILE, bucketObject)
  except GAPI.forbidden as e:
    entityActionFailedExit([Ent.CLOUD_STORAGE_FILE, bucketObject], str(e))
  if result['contentType'] != mimeType:
    getGDocSheetDataFailedExit([Ent.CLOUD_STORAGE_FILE, bucketObject],
                               Msg.INVALID_MIMETYPE.format(result['contentType'], mimeType))
  fb = TemporaryFile(mode='wb+')
  try:
    request = s.objects().get_media(bucket=bucket, object=s_object)
    downloader = googleapiclient.http.MediaIoBaseDownload(fb, request)
    done = False
    while not done:
      _, done = downloader.next_chunk()
    fb.seek(0)
    if returnData:
      data = fb.read().decode(UTF8)
      fb.close()
      return data
    f = TemporaryFile(mode='w+', encoding=UTF8)
    f.write(fb.read().decode(UTF8_SIG))
    fb.close()
    f.seek(0)
    return f
  except googleapiclient.http.HttpError as e:
    mg = HTTP_ERROR_PATTERN.match(str(e))
    getGDocSheetDataFailedExit([Ent.CLOUD_STORAGE_FILE, bucketObject], mg.group(1) if mg else str(e))

# <CSVFileInput>
def openCSVFileReader(filename, fieldnames=None):
  filenameLower = filename.lower()
  if filenameLower == 'gsheet':
    f = getGSheetData()
    getCharSet()
  elif filenameLower in {'gcsv', 'gdoc'}:
    f = getGDocData(filenameLower)
    getCharSet()
  elif filenameLower in {'gcscsv', 'gcsdoc'}:
    f = getStorageFileData(filenameLower, False)
    getCharSet()
  else:
    encoding = getCharSet()
    f = openFile(filename, mode=DEFAULT_CSV_READ_MODE, encoding=encoding)
  if checkArgumentPresent('warnifnodata'):
    loc = f.tell()
    try:
      if not f.readline() or not f.readline():
        stderrWarningMsg(fileErrorMessage(filename, Msg.NO_CSV_FILE_DATA_FOUND))
        sys.exit(NO_ENTITIES_FOUND_RC)
      f.seek(loc)
    except (IOError, UnicodeDecodeError, UnicodeError) as e:
      systemErrorExit(FILE_ERROR_RC, fileErrorMessage(filename, e))
  if checkArgumentPresent('columndelimiter'):
    columnDelimiter = getCharacter()
  else:
    columnDelimiter = GC.Values[GC.CSV_INPUT_COLUMN_DELIMITER]
  if checkArgumentPresent('noescapechar'):
    noEscapeChar = getBoolean()
  else:
    noEscapeChar = GC.Values[GC.CSV_INPUT_NO_ESCAPE_CHAR]
  if checkArgumentPresent('quotechar'):
    quotechar = getCharacter()
  else:
    quotechar = GC.Values[GC.CSV_INPUT_QUOTE_CHAR]
  if not checkArgumentPresent('endcsv') and checkArgumentPresent('fields'):
    fieldnames = shlexSplitList(getString(Cmd.OB_FIELD_NAME_LIST))
  try:
    csvFile = csv.DictReader(f, fieldnames=fieldnames,
                             delimiter=columnDelimiter,
                             escapechar='\\' if not noEscapeChar else None,
                             quotechar=quotechar)
    return (f, csvFile, csvFile.fieldnames if csvFile.fieldnames is not None else [])
  except (csv.Error, UnicodeDecodeError, UnicodeError) as e:
    systemErrorExit(FILE_ERROR_RC, e)

def incrAPICallsRetryData(errMsg, delta):
  GM.Globals[GM.API_CALLS_RETRY_DATA].setdefault(errMsg, [0, 0.0])
  GM.Globals[GM.API_CALLS_RETRY_DATA][errMsg][0] += 1
  GM.Globals[GM.API_CALLS_RETRY_DATA][errMsg][1] += delta

def initAPICallsRateCheck():
  GM.Globals[GM.RATE_CHECK_COUNT] = 0
  GM.Globals[GM.RATE_CHECK_START] = time.time()

def checkAPICallsRate():
  GM.Globals[GM.RATE_CHECK_COUNT] += 1
  if GM.Globals[GM.RATE_CHECK_COUNT] >= GC.Values[GC.API_CALLS_RATE_LIMIT]:
    current = time.time()
    delta = int(current-GM.Globals[GM.RATE_CHECK_START])
    if 0 <= delta < 60:
      delta = (60-delta)+3
      error_message = f'API calls per 60 seconds limit {GC.Values[GC.API_CALLS_RATE_LIMIT]} exceeded'
      writeStderr(f'{WARNING_PREFIX}{error_message}: Backing off: {delta} seconds\n')
      flushStderr()
      time.sleep(delta)
      if GC.Values[GC.SHOW_API_CALLS_RETRY_DATA]:
        incrAPICallsRetryData(error_message, delta)
      GM.Globals[GM.RATE_CHECK_START] = time.time()
    else:
      GM.Globals[GM.RATE_CHECK_START] = current
    GM.Globals[GM.RATE_CHECK_COUNT] = 0

def openGAMCommandLog(Globals, name):
  try:
    Globals[GM.CMDLOG_LOGGER] = logging.getLogger(name)
    Globals[GM.CMDLOG_LOGGER].setLevel(logging.INFO)
    Globals[GM.CMDLOG_HANDLER] = RotatingFileHandler(GC.Values[GC.CMDLOG],
                                                     maxBytes=1024*GC.Values[GC.CMDLOG_MAX_KILO_BYTES],
                                                     backupCount=GC.Values[GC.CMDLOG_MAX_BACKUPS],
                                                     encoding=GC.Values[GC.CHARSET])
    Globals[GM.CMDLOG_LOGGER].addHandler(Globals[GM.CMDLOG_HANDLER])
  except Exception as e:
    Globals[GM.CMDLOG_LOGGER] = None
    systemErrorExit(CONFIG_ERROR_RC, Msg.LOGGING_INITIALIZATION_ERROR.format(str(e)))

def writeGAMCommandLog(Globals, logCmd, sysRC):
  Globals[GM.CMDLOG_LOGGER].info(f'{currentISOformatTimeStamp()},{sysRC},{logCmd}')

def closeGAMCommandLog(Globals):
  try:
    Globals[GM.CMDLOG_HANDLER].flush()
    Globals[GM.CMDLOG_HANDLER].close()
    Globals[GM.CMDLOG_LOGGER].removeHandler(Globals[GM.CMDLOG_HANDLER])
  except Exception:
    pass
  Globals[GM.CMDLOG_LOGGER] = None

# Set global variables from config file
# Return True if there are additional commands on the command line
def SetGlobalVariables():

  def _stringInQuotes(value):
    return (len(value) > 1) and (((value.startswith('"') and value.endswith('"'))) or ((value.startswith("'") and value.endswith("'"))))

  def _stripStringQuotes(value):
    if _stringInQuotes(value):
      return value[1:-1]
    return value

  def _quoteStringIfLeadingTrailingBlanks(value):
    if not value:
      return "''"
    if _stringInQuotes(value):
      return value
    if (value[0] != ' ') and (value[-1] != ' '):
      return value
    return f"'{value}'"

  def _getDefault(itemName, itemEntry, oldGamPath):
    if GC.VAR_SIGFILE in itemEntry:
      GC.Defaults[itemName] = itemEntry[GC.VAR_SFFT][os.path.isfile(os.path.join(oldGamPath, itemEntry[GC.VAR_SIGFILE]))]
    elif GC.VAR_ENVVAR in itemEntry:
      value = os.environ.get(itemEntry[GC.VAR_ENVVAR], GC.Defaults[itemName])
      if itemEntry[GC.VAR_TYPE] in [GC.TYPE_INTEGER, GC.TYPE_FLOAT]:
        try:
          number = int(value) if itemEntry[GC.VAR_TYPE] == GC.TYPE_INTEGER else float(value)
          minVal, maxVal = itemEntry[GC.VAR_LIMITS]
          if (minVal is not None) and (number < minVal):
            number = minVal
          elif (maxVal is not None) and (number > maxVal):
            number = maxVal
        except ValueError:
          number = GC.Defaults[itemName]
        value = str(number)
      elif itemEntry[GC.VAR_TYPE] == GC.TYPE_STRING:
        value = _quoteStringIfLeadingTrailingBlanks(value)
      GC.Defaults[itemName] = value

  def _selectSection():
    value = getString(Cmd.OB_SECTION_NAME, minLen=0)
    if (not value) or (value.upper() == configparser.DEFAULTSECT):
      return configparser.DEFAULTSECT
    if GM.Globals[GM.PARSER].has_section(value):
      return value
    Cmd.Backup()
    usageErrorExit(formatKeyValueList('', [Ent.Singular(Ent.SECTION), value, Msg.NOT_FOUND], ''))

  def _showSections():
    printKeyValueList([Ent.Singular(Ent.CONFIG_FILE), GM.Globals[GM.GAM_CFG_FILE]])
    Ind.Increment()
    for section in [configparser.DEFAULTSECT]+sorted(GM.Globals[GM.PARSER].sections()):
      printKeyValueList([f'{section}{" *" if section == sectionName else ""}'])
    Ind.Decrement()

  def _checkMakeDir(itemName):
    if not os.path.isdir(GC.Defaults[itemName]):
      try:
        os.makedirs(GC.Defaults[itemName])
        printKeyValueList([Act.PerformedName(Act.CREATE), GC.Defaults[itemName]])
      except OSError as e:
        if not os.path.isdir(GC.Defaults[itemName]):
          systemErrorExit(FILE_ERROR_RC, e)

  def _copyCfgFile(srcFile, targetDir, oldGamPath):
    if (not srcFile) or os.path.isabs(srcFile):
      return
    dstFile = os.path.join(GC.Defaults[targetDir], srcFile)
    if os.path.isfile(dstFile):
      return
    srcFile = os.path.join(oldGamPath, srcFile)
    if not os.path.isfile(srcFile):
      return
    data = readFile(srcFile, continueOnError=True, displayError=False)
    if (data is not None) and writeFile(dstFile, data, continueOnError=True):
      printKeyValueList([Act.PerformedName(Act.COPY), srcFile, Msg.TO, dstFile])

  def _printValueError(sectionName, itemName, value, errMessage, sysRC=CONFIG_ERROR_RC):
    kvlMsg = formatKeyValueList('',
                                [Ent.Singular(Ent.CONFIG_FILE), GM.Globals[GM.GAM_CFG_FILE],
                                 Ent.Singular(Ent.SECTION), sectionName,
                                 Ent.Singular(Ent.ITEM), itemName,
                                 Ent.Singular(Ent.VALUE), value,
                                 errMessage],
                                '')
    if sysRC != 0:
      status['errors'] = True
      printErrorMessage(sysRC, kvlMsg)
    else:
      writeStderr(formatKeyValueList(Ind.Spaces(), [WARNING, kvlMsg], '\n'))

  def _getCfgBoolean(sectionName, itemName):
    value = GM.Globals[GM.PARSER].get(sectionName, itemName).lower()
    if value in TRUE_VALUES:
      return True
    if value in FALSE_VALUES:
      return False
    _printValueError(sectionName, itemName, value, f'{Msg.EXPECTED}: {formatChoiceList(TRUE_FALSE)}')
    return False

  def _getCfgCharacter(sectionName, itemName):
    value = codecs.escape_decode(bytes(_stripStringQuotes(GM.Globals[GM.PARSER].get(sectionName, itemName)), UTF8))[0].decode(UTF8)
    if not value and (itemName == 'csv_output_field_delimiter'):
      return ' '
    if not value and (itemName in {'csv_input_escape_char', 'csv_output_escape_char'}):
      return None
    if len(value) == 1:
      return value
    _printValueError(sectionName, itemName, f'"{value}"', f'{Msg.EXPECTED}: {integerLimits(1, 1, Msg.STRING_LENGTH)}')
    return ''

  def _getCfgChoice(sectionName, itemName):
    value = _stripStringQuotes(GM.Globals[GM.PARSER].get(sectionName, itemName)).lower()
    choices = GC.VAR_INFO[itemName][GC.VAR_CHOICES]
    if value in choices:
      return choices[value]
    _printValueError(sectionName, itemName, f'"{value}"', f'{Msg.EXPECTED}: {",".join(choices)}')
    return ''

  def _getCfgLocale(sectionName, itemName):
    value = _stripStringQuotes(GM.Globals[GM.PARSER].get(sectionName, itemName)).lower().replace('_', '-')
    if value in LOCALE_CODES_MAP:
      return LOCALE_CODES_MAP[value]
    _printValueError(sectionName, itemName, f'"{value}"', f'{Msg.EXPECTED}: {",".join(LOCALE_CODES_MAP)}')
    return ''

  def _getCfgNumber(sectionName, itemName):
    value = GM.Globals[GM.PARSER].get(sectionName, itemName)
    minVal, maxVal = GC.VAR_INFO[itemName][GC.VAR_LIMITS]
    try:
      number = int(value) if GC.VAR_INFO[itemName][GC.VAR_TYPE] == GC.TYPE_INTEGER else float(value)
      if ((minVal is None) or (number >= minVal)) and ((maxVal is None) or (number <= maxVal)):
        return number
      if (minVal is not None) and (number < minVal):
        number = minVal
      else:
        number = maxVal
      _printValueError(sectionName, itemName, value, f'{Msg.EXPECTED}: {integerLimits(minVal, maxVal)}, {Msg.USED}: {number}', sysRC=0)
      return number
    except ValueError:
      pass
    _printValueError(sectionName, itemName, value, f'{Msg.EXPECTED}: {integerLimits(minVal, maxVal)}')
    return 0

  def _getCfgHeaderFilter(sectionName, itemName):
    value = GM.Globals[GM.PARSER].get(sectionName, itemName)
    headerFilters = []
    if not value or (len(value) == 2 and _stringInQuotes(value)):
      return headerFilters
    splitStatus, filters = shlexSplitListStatus(value)
    if splitStatus:
      for filterStr in filters:
        try:
          headerFilters.append(re.compile(filterStr, re.IGNORECASE))
        except re.error as e:
          _printValueError(sectionName, itemName, f'"{filterStr}"', f'{Msg.INVALID_RE}: {e}')
    else:
      _printValueError(sectionName, itemName, f'"{value}"', f'{Msg.INVALID_LIST}: {filters}')
    return headerFilters

  def _getCfgHeaderFilterFromForce(sectionName, itemName):
    headerFilters = []
    for filterStr in GC.Values[itemName]:
      try:
        headerFilters.append(re.compile(fr'^{filterStr}$'))
      except re.error as e:
        _printValueError(sectionName, itemName, f'"{filterStr}"', f'{Msg.INVALID_RE}: {e}')
    return headerFilters

  ROW_FILTER_ANY_ALL_PATTERN = re.compile(r'^(any:|all:)(.+)$', re.IGNORECASE)
  ROW_FILTER_COMP_PATTERN = re.compile(r'^(date|time|count|length)\s*([<>]=?|=|!=)(.+)$', re.IGNORECASE)
  ROW_FILTER_RANGE_PATTERN = re.compile(r'^(daterange|timerange|countrange|lengthrange)(=|!=)(\S+)/(\S+)$', re.IGNORECASE)
  ROW_FILTER_TIMEOFDAYRANGE_PATTERN = re.compile(r'^(timeofdayrange)(=|!=)(\d\d):(\d\d)/(\d\d):(\d\d)$', re.IGNORECASE)
  ROW_FILTER_BOOL_PATTERN = re.compile(r'^(boolean):(.+)$', re.IGNORECASE)
  ROW_FILTER_TEXT_PATTERN = re.compile(r'^(text)([<>]=?|=|!=)(.*)$', re.IGNORECASE)
  ROW_FILTER_TEXTRANGE_PATTERN = re.compile(r'^(textrange)(=|!=)(.*)/(.*)$', re.IGNORECASE)
  ROW_FILTER_RE_PATTERN = re.compile(r'^(regex|regexcs|notregex|notregexcs):(.*)$', re.IGNORECASE)
  ROW_FILTER_DATA_PATTERN = re.compile(r'^(data|notdata):(list|file|csvfile) +(.+)$', re.IGNORECASE)
  REGEX_CHARS = '^$*+|$[{('

  def _getCfgRowFilter(sectionName, itemName):
    value = GM.Globals[GM.PARSER].get(sectionName, itemName)
    rowFilters = []
    if not value:
      return rowFilters
    if value.startswith('{'):
      try:
        filterDict = json.loads(value.encode('unicode-escape').decode(UTF8))
      except (IndexError, KeyError, SyntaxError, TypeError, ValueError) as e:
        _printValueError(sectionName, itemName, f'"{value}"', f'{Msg.FAILED_TO_PARSE_AS_JSON}: {str(e)}')
        return rowFilters
    else:
      filterDict = {}
      status, filterList = shlexSplitListStatus(value)
      if not status:
        _printValueError(sectionName, itemName, f'"{value}"', f'{Msg.FAILED_TO_PARSE_AS_LIST}: {str(filterList)}')
        return rowFilters
      for filterVal in filterList:
        if not filterVal:
          continue
        try:
          filterTokens = shlexSplitList(filterVal, ':')
          column = filterTokens[0]
          filterStr = ':'.join(filterTokens[1:])
        except ValueError:
          _printValueError(sectionName, itemName, f'"{filterVal}"', f'{Msg.EXPECTED}: column:filter')
          continue
        filterDict[column] = filterStr
    for column, filterStr in iter(filterDict.items()):
      for c in REGEX_CHARS:
        if c in column:
          columnPat = column
          break
      else:
        columnPat = f'^{column}$'
      try:
        columnPat = re.compile(columnPat, re.IGNORECASE)
      except re.error as e:
        _printValueError(sectionName, itemName, f'"{column}"', f'{Msg.INVALID_RE}: {e}')
        continue
      anyMatch = True
      mg = ROW_FILTER_ANY_ALL_PATTERN.match(filterStr)
      if mg:
        anyMatch = mg.group(1).lower() == 'any:'
        filterStr = mg.group(2)
      mg = ROW_FILTER_COMP_PATTERN.match(filterStr)
      if mg:
        filterType = mg.group(1).lower()
        if filterType in {'date', 'time'}:
          if filterType == 'date':
            valid, filterValue = getRowFilterDateOrDeltaFromNow(mg.group(3))
          else:
            valid, filterValue = getRowFilterTimeOrDeltaFromNow(mg.group(3))
          if valid:
            rowFilters.append((columnPat, anyMatch, filterType, mg.group(2), filterValue))
          else:
            _printValueError(sectionName, itemName, f'"{column}": "{filterStr}"', f'{Msg.EXPECTED}: {filterValue}')
        else: # filterType in {'count', 'length'}:
          if mg.group(3).isdigit():
            rowFilters.append((columnPat, anyMatch, filterType, mg.group(2), int(mg.group(3))))
          else:
            _printValueError(sectionName, itemName, f'"{column}": "{filterStr}"', f'{Msg.EXPECTED}: <Number>')
        continue
      mg = ROW_FILTER_TEXT_PATTERN.match(filterStr)
      if mg:
        filterType = mg.group(1).lower()
        rowFilters.append((columnPat, anyMatch, filterType, mg.group(2), mg.group(3)))
        continue
      mg = ROW_FILTER_TEXTRANGE_PATTERN.match(filterStr)
      if mg:
        filterType = mg.group(1).lower()
        rowFilters.append((columnPat, anyMatch, filterType, mg.group(2), mg.group(3), mg.group(4)))
        continue
      mg = ROW_FILTER_RANGE_PATTERN.match(filterStr)
      if mg:
        filterType = mg.group(1).lower()
        if filterType in {'daterange', 'timerange'}:
          if filterType == 'daterange':
            valid1, filterValue1 = getRowFilterDateOrDeltaFromNow(mg.group(3))
            valid2, filterValue2 = getRowFilterDateOrDeltaFromNow(mg.group(4))
          else:
            valid1, filterValue1 = getRowFilterTimeOrDeltaFromNow(mg.group(3))
            valid2, filterValue2 = getRowFilterTimeOrDeltaFromNow(mg.group(4))
          if valid1 and valid2:
            rowFilters.append((columnPat, anyMatch, filterType, mg.group(2), filterValue1, filterValue2))
          else:
            _printValueError(sectionName, itemName, f'"{column}": "{filterStr}"', f'{Msg.EXPECTED}: {filterValue1}/{filterValue2}')
        else: #countrange|lengthrange
          if mg.group(3).isdigit() and mg.group(4).isdigit():
            rowFilters.append((columnPat, anyMatch, filterType, mg.group(2), int(mg.group(3)), int(mg.group(4))))
          else:
            _printValueError(sectionName, itemName, f'"{column}": "{filterStr}"', f'{Msg.EXPECTED}: <Number>/<Number>')
        continue
      mg = ROW_FILTER_TIMEOFDAYRANGE_PATTERN.match(filterStr)
      if mg:
        filterType = mg.group(1).lower()
        startHour = int(mg.group(3))
        startMinute = int(mg.group(4))
        endHour = int(mg.group(5))
        endMinute = int(mg.group(6))
        if startHour > 23 or startMinute > 59 or endHour > 23 or endMinute > 59 or \
           endHour < startHour or (endHour == startHour and endMinute < startMinute):
          Cmd.Backup()
          usageErrorExit(Msg.INVALID_TIMEOFDAY_RANGE.format(f'{startHour:02d}:{startMinute:02d}', f'{endHour:02d}:{endMinute:02d}'))
        rowFilters.append((columnPat, anyMatch, filterType, mg.group(2), f'{startHour:02d}:{startMinute:02d}', f'{endHour:02d}:{endMinute:02d}'))
        continue
      mg = ROW_FILTER_BOOL_PATTERN.match(filterStr)
      if mg:
        filterType = mg.group(1).lower()
        filterValue = mg.group(2).lower()
        if filterValue in TRUE_VALUES:
          rowFilters.append((columnPat, anyMatch, filterType, True))
        elif filterValue in FALSE_VALUES:
          rowFilters.append((columnPat, anyMatch, filterType, False))
        else:
          _printValueError(sectionName, itemName, f'"{column}": "{filterStr}"', f'{Msg.EXPECTED}: <Boolean>')
        continue
      mg = ROW_FILTER_RE_PATTERN.match(filterStr)
      if mg:
        filterType = mg.group(1).lower()
        try:
          if filterType.endswith('cs'):
            filterType = filterType[0:-2]
            flags = 0
          else:
            flags = re.IGNORECASE
          rowFilters.append((columnPat, anyMatch, filterType, re.compile(mg.group(2), flags)))
        except re.error as e:
          _printValueError(sectionName, itemName, f'"{column}": "{filterStr}"', f'{Msg.INVALID_RE}: {e}')
        continue
      mg = ROW_FILTER_DATA_PATTERN.match(filterStr)
      if mg:
        filterType = mg.group(1).lower()
        filterSubType = mg.group(2).lower()
        if filterSubType == 'list':
          rowFilters.append((columnPat, anyMatch, filterType, set(shlexSplitList(mg.group(3)))))
          continue
        Cmd.MergeArguments(shlexSplitList(mg.group(3), ' '))
        if filterSubType == 'file':
          rowFilters.append((columnPat, anyMatch, filterType, getEntitiesFromFile(False, returnSet=True)))
        else: #elif filterSubType == 'csvfile':
          rowFilters.append((columnPat, anyMatch, filterType, getEntitiesFromCSVFile(False, returnSet=True)))
        Cmd.RestoreArguments()
        continue
      _printValueError(sectionName, itemName, f'"{column}": "{filterStr}"', f'{Msg.EXPECTED}: <RowValueFilter>')
    return rowFilters

  def _getCfgSection(sectionName, itemName):
    value = _stripStringQuotes(GM.Globals[GM.PARSER].get(sectionName, itemName))
    if (not value) or (value.upper() == configparser.DEFAULTSECT):
      return configparser.DEFAULTSECT
    if GM.Globals[GM.PARSER].has_section(value):
      return value
    _printValueError(sectionName, itemName, value, Msg.NOT_FOUND)
    return configparser.DEFAULTSECT

  def _getCfgPassword(sectionName, itemName):
    value = GM.Globals[GM.PARSER].get(sectionName, itemName)
    if isinstance(value, bytes):
      return value
    value = _stripStringQuotes(value)
    if value.startswith("b'") and value.endswith("'"):
      return bytes(value[2:-1], UTF8)
    if value:
      return value
    return ''

  def _validateLicenseSKUs(sectionName, itemName, skuList):
    GM.Globals[GM.LICENSE_SKUS] = []
    for sku in skuList.split(','):
      if '/' not in sku:
        productId, sku = SKU.getProductAndSKU(sku)
        if not productId:
          _printValueError(sectionName, itemName, sku, f'{Msg.EXPECTED}: {",".join(SKU.getSortedSKUList())}')
      else:
        (productId, sku) = sku.split('/')
      if (productId, sku) not in GM.Globals[GM.LICENSE_SKUS]:
        GM.Globals[GM.LICENSE_SKUS].append((productId, sku))

  def _getCfgString(sectionName, itemName):
    value = _stripStringQuotes(GM.Globals[GM.PARSER].get(sectionName, itemName))
    if itemName == GC.DOMAIN:
      value = value.strip()
    minLen, maxLen = GC.VAR_INFO[itemName].get(GC.VAR_LIMITS, (None, None))
    if ((minLen is None) or (len(value) >= minLen)) and ((maxLen is None) or (len(value) <= maxLen)):
      if itemName == GC.LICENSE_SKUS and value:
        _validateLicenseSKUs(sectionName, itemName, value)
      return value
    _printValueError(sectionName, itemName, f'"{value}"', f'{Msg.EXPECTED}: {integerLimits(minLen, maxLen, Msg.STRING_LENGTH)}')
    return ''

  def _getCfgStringList(sectionName, itemName):
    value = GM.Globals[GM.PARSER].get(sectionName, itemName)
    stringlist = []
    if not value or (len(value) == 2 and _stringInQuotes(value)):
      return stringlist
    splitStatus, stringlist = shlexSplitListStatus(value)
    if not splitStatus:
      _printValueError(sectionName, itemName, f'"{value}"', f'{Msg.INVALID_LIST}: {stringlist}')
    return stringlist

  def _getCfgTimezone(sectionName, itemName):
    value = _stripStringQuotes(GM.Globals[GM.PARSER].get(sectionName, itemName).lower())
    if value == 'utc':
      GM.Globals[GM.CONVERT_TO_LOCAL_TIME] = False
      return iso8601.UTC
    GM.Globals[GM.CONVERT_TO_LOCAL_TIME] = True
    if value == 'local':
      return iso8601.Local
    try:
      return iso8601.parse_timezone_str(value)
    except (iso8601.ParseError, OverflowError):
      _printValueError(sectionName, itemName, value, f'{Msg.EXPECTED}: {TIMEZONE_FORMAT_REQUIRED}')
      GM.Globals[GM.CONVERT_TO_LOCAL_TIME] = False
      return iso8601.UTC

  def _getCfgDirectory(sectionName, itemName):
    dirPath = os.path.expanduser(_stripStringQuotes(GM.Globals[GM.PARSER].get(sectionName, itemName)))
    if (not dirPath) and (itemName in {GC.GMAIL_CSE_INCERT_DIR, GC.GMAIL_CSE_INKEY_DIR}):
      return dirPath
    if (not dirPath) or (not os.path.isabs(dirPath) and dirPath != '.'):
      if (sectionName != configparser.DEFAULTSECT) and (GM.Globals[GM.PARSER].has_option(sectionName, itemName)):
        dirPath = os.path.join(os.path.expanduser(_stripStringQuotes(GM.Globals[GM.PARSER].get(configparser.DEFAULTSECT, itemName))), dirPath)
      if not os.path.isabs(dirPath):
        dirPath = os.path.join(GM.Globals[GM.GAM_CFG_PATH], dirPath)
    return dirPath

  def _getCfgFile(sectionName, itemName):
    value = os.path.expanduser(_stripStringQuotes(GM.Globals[GM.PARSER].get(sectionName, itemName)))
    if value and not os.path.isabs(value):
      value = os.path.expanduser(os.path.join(_getCfgDirectory(sectionName, GC.CONFIG_DIR), value))
    elif not value and itemName == GC.CACERTS_PEM:
      if hasattr(sys, '_MEIPASS'):
        value = os.path.join(sys._MEIPASS, GC.FN_CACERTS_PEM) #pylint: disable=no-member
      else:
        value = os.path.join(GM.Globals[GM.GAM_PATH], GC.FN_CACERTS_PEM)
    return value

  def _readGamCfgFile(config, fileName):
    try:
      with open(fileName, DEFAULT_FILE_READ_MODE, encoding=GM.Globals[GM.SYS_ENCODING]) as f:
        config.read_file(f)
    except (configparser.DuplicateOptionError, configparser.DuplicateSectionError,
            configparser.MissingSectionHeaderError, configparser.ParsingError) as e:
      systemErrorExit(CONFIG_ERROR_RC, formatKeyValueList('',
                                                          [Ent.Singular(Ent.CONFIG_FILE), fileName,
                                                           Msg.INVALID, str(e)],
                                                          ''))
    except IOError as e:
      systemErrorExit(FILE_ERROR_RC, fileErrorMessage(fileName, e, Ent.CONFIG_FILE))

  def _writeGamCfgFile(config, fileName, action):
    GM.Globals[GM.SECTION] = None # No need to save section for inner gams
    try:
      with open(fileName, DEFAULT_FILE_WRITE_MODE, encoding=GM.Globals[GM.SYS_ENCODING]) as f:
        config.write(f)
      printKeyValueList([Ent.Singular(Ent.CONFIG_FILE), fileName, Act.PerformedName(action)])
    except IOError as e:
      stderrErrorMsg(fileErrorMessage(fileName, e, Ent.CONFIG_FILE))

  def _verifyValues(sectionName, inputFilterSectionName, outputFilterSectionName):
    printKeyValueList([Ent.Singular(Ent.SECTION), sectionName]) # Do not use printEntity
    Ind.Increment()
    for itemName, itemEntry in iter(GC.VAR_INFO.items()):
      sectName = sectionName
      if itemName in GC.CSV_INPUT_ROW_FILTER_ITEMS:
        if inputFilterSectionName:
          sectName = inputFilterSectionName
      elif itemName in GC.CSV_OUTPUT_ROW_FILTER_ITEMS:
        if outputFilterSectionName:
          sectName = outputFilterSectionName
      cfgValue = GM.Globals[GM.PARSER].get(sectName, itemName)
      varType = itemEntry[GC.VAR_TYPE]
      if varType == GC.TYPE_CHOICE:
        for choice, value in iter(itemEntry[GC.VAR_CHOICES].items()):
          if cfgValue == value:
            cfgValue = choice
            break
      elif varType not in [GC.TYPE_BOOLEAN, GC.TYPE_INTEGER, GC.TYPE_FLOAT, GC.TYPE_PASSWORD]:
        cfgValue = _quoteStringIfLeadingTrailingBlanks(cfgValue)
      if varType == GC.TYPE_FILE:
        expdValue = _getCfgFile(sectName, itemName)
        if cfgValue not in ("''", expdValue):
          cfgValue = f'{cfgValue} ; {expdValue}'
      elif varType == GC.TYPE_DIRECTORY:
        expdValue = _getCfgDirectory(sectName, itemName)
        if cfgValue not in ("''", expdValue):
          cfgValue = f'{cfgValue} ; {expdValue}'
      elif (itemName == GC.SECTION) and (sectName != configparser.DEFAULTSECT):
        continue
      printLine(f'{Ind.Spaces()}{itemName} = {cfgValue}')
    Ind.Decrement()

  def _chkCfgDirectories(sectionName):
    for itemName, itemEntry in iter(GC.VAR_INFO.items()):
      if itemEntry[GC.VAR_TYPE] == GC.TYPE_DIRECTORY:
        dirPath = GC.Values[itemName]
        if (not dirPath) and (itemName in {GC.GMAIL_CSE_INCERT_DIR, GC.GMAIL_CSE_INKEY_DIR}):
          return
        if (itemName != GC.CACHE_DIR or not GC.Values[GC.NO_CACHE]) and not os.path.isdir(dirPath):
          writeStderr(formatKeyValueList(WARNING_PREFIX,
                                         [Ent.Singular(Ent.CONFIG_FILE), GM.Globals[GM.GAM_CFG_FILE],
                                          Ent.Singular(Ent.SECTION), sectionName,
                                          Ent.Singular(Ent.ITEM), itemName,
                                          Ent.Singular(Ent.VALUE), dirPath,
                                          Msg.INVALID_PATH],
                                         '\n'))

  def _chkCfgFiles(sectionName):
    for itemName, itemEntry in iter(GC.VAR_INFO.items()):
      if itemEntry[GC.VAR_TYPE] == GC.TYPE_FILE:
        fileName = GC.Values[itemName]
        if (not fileName) and (itemName in {GC.EXTRA_ARGS, GC.CMDLOG}):
          continue
        if itemName == GC.CLIENT_SECRETS_JSON: # Added 6.57.01
          continue
        if GC.Values[GC.ENABLE_DASA] and itemName == GC.OAUTH2_TXT:
          continue
        if not os.path.isfile(fileName):
          writeStderr(formatKeyValueList([WARNING_PREFIX, ERROR_PREFIX][itemName == GC.CACERTS_PEM],
                                         [Ent.Singular(Ent.CONFIG_FILE), GM.Globals[GM.GAM_CFG_FILE],
                                          Ent.Singular(Ent.SECTION), sectionName,
                                          Ent.Singular(Ent.ITEM), itemName,
                                          Ent.Singular(Ent.VALUE), fileName,
                                          Msg.NOT_FOUND],
                                         '\n'))
          if itemName == GC.CACERTS_PEM:
            status['errors'] = True
        elif not os.access(fileName, itemEntry[GC.VAR_ACCESS]):
          if itemEntry[GC.VAR_ACCESS] == os.R_OK | os.W_OK:
            accessMsg = Msg.NEED_READ_WRITE_ACCESS
          elif itemEntry[GC.VAR_ACCESS] == os.R_OK:
            accessMsg = Msg.NEED_READ_ACCESS
          else:
            accessMsg = Msg.NEED_WRITE_ACCESS
          writeStderr(formatKeyValueList(ERROR_PREFIX,
                                         [Ent.Singular(Ent.CONFIG_FILE), GM.Globals[GM.GAM_CFG_FILE],
                                          Ent.Singular(Ent.SECTION), sectionName,
                                          Ent.Singular(Ent.ITEM), itemName,
                                          Ent.Singular(Ent.VALUE), fileName,
                                          accessMsg],
                                         '\n'))
          status['errors'] = True

  def _setCSVFile(fileName, mode, encoding, writeHeader, multi):
    if fileName != '-':
      fileName = setFilePath(fileName)
    GM.Globals[GM.CSVFILE][GM.REDIRECT_NAME] = fileName
    GM.Globals[GM.CSVFILE][GM.REDIRECT_MODE] = mode
    GM.Globals[GM.CSVFILE][GM.REDIRECT_ENCODING] = encoding
    GM.Globals[GM.CSVFILE][GM.REDIRECT_WRITE_HEADER] = writeHeader
    GM.Globals[GM.CSVFILE][GM.REDIRECT_MULTIPROCESS] = multi
    GM.Globals[GM.CSVFILE][GM.REDIRECT_QUEUE] = None

  def _setSTDFile(stdtype, fileName, mode, multi):
    if stdtype == GM.STDOUT:
      GM.Globals[GM.SAVED_STDOUT] = None
    GM.Globals[stdtype][GM.REDIRECT_STD] = False
    if fileName == 'null':
      GM.Globals[stdtype][GM.REDIRECT_FD] = open(os.devnull, mode, encoding=UTF8)
    elif fileName == '-':
      GM.Globals[stdtype][GM.REDIRECT_STD] = True
      if stdtype == GM.STDOUT:
        GM.Globals[stdtype][GM.REDIRECT_FD] = os.fdopen(os.dup(sys.stdout.fileno()), mode, encoding=GM.Globals[GM.SYS_ENCODING])
      else:
        GM.Globals[stdtype][GM.REDIRECT_FD] = os.fdopen(os.dup(sys.stderr.fileno()), mode, encoding=GM.Globals[GM.SYS_ENCODING])
    else:
      fileName = setFilePath(fileName)
      if multi and mode == DEFAULT_FILE_WRITE_MODE:
        deleteFile(fileName)
        mode = DEFAULT_FILE_APPEND_MODE
      GM.Globals[stdtype][GM.REDIRECT_FD] = openFile(fileName, mode)
    GM.Globals[stdtype][GM.REDIRECT_MULTI_FD] = GM.Globals[stdtype][GM.REDIRECT_FD] if not multi else StringIOobject()
    if (stdtype == GM.STDOUT) and (GC.Values[GC.DEBUG_LEVEL] > 0):
      GM.Globals[GM.SAVED_STDOUT] = sys.stdout
      sys.stdout = GM.Globals[stdtype][GM.REDIRECT_MULTI_FD]
    GM.Globals[stdtype][GM.REDIRECT_NAME] = fileName
    GM.Globals[stdtype][GM.REDIRECT_MODE] = mode
    GM.Globals[stdtype][GM.REDIRECT_MULTIPROCESS] = multi
    GM.Globals[stdtype][GM.REDIRECT_QUEUE] = 'stdout' if stdtype == GM.STDOUT else 'stderr'

  MULTIPROCESS_EXIT_COMP_PATTERN = re.compile(r'^rc([<>]=?|=|!=)(.+)$', re.IGNORECASE)
  MULTIPROCESS_EXIT_RANGE_PATTERN = re.compile(r'^rcrange(=|!=)(\S+)/(\S+)$', re.IGNORECASE)

  def _setMultiprocessExit():
    rcStr = getString(Cmd.OB_STRING)
    mg = MULTIPROCESS_EXIT_COMP_PATTERN.match(rcStr)
    if mg:
      if not mg.group(2).isdigit():
        usageErrorExit(f'{Msg.EXPECTED}: rc<Operator><Value>')
      GM.Globals[GM.MULTIPROCESS_EXIT_CONDITION] = {'comp': mg.group(1), 'value': int(mg.group(2))}
      return
    mg = MULTIPROCESS_EXIT_RANGE_PATTERN.match(rcStr)
    if mg:
      if not mg.group(2).isdigit() or not  mg.group(3).isdigit():
        usageErrorExit(f'{Msg.EXPECTED}: rcrange<Operator><Value>/Value>')
      GM.Globals[GM.MULTIPROCESS_EXIT_CONDITION] = {'range': mg.group(1), 'low': int(mg.group(2)), 'high': int(mg.group(3))}
      return
    usageErrorExit(f'{Msg.EXPECTED}: (rc<Operator><Value>)|(rcrange<Operator><Value>/Value>)')

  if not GM.Globals[GM.PARSER]:
    homePath = os.path.expanduser('~')
    GM.Globals[GM.GAM_CFG_PATH] = os.environ.get(EV_GAMCFGDIR, None)
    if GM.Globals[GM.GAM_CFG_PATH]:
      GM.Globals[GM.GAM_CFG_PATH] = os.path.expanduser(GM.Globals[GM.GAM_CFG_PATH])
    else:
      GM.Globals[GM.GAM_CFG_PATH] = os.path.join(homePath, '.gam')
    GC.Defaults[GC.CONFIG_DIR] = GM.Globals[GM.GAM_CFG_PATH]
    GC.Defaults[GC.CACHE_DIR] = os.path.join(GM.Globals[GM.GAM_CFG_PATH], 'gamcache')
    GC.Defaults[GC.DRIVE_DIR] = os.path.join(homePath, 'Downloads')
    GM.Globals[GM.GAM_CFG_FILE] = os.path.join(GM.Globals[GM.GAM_CFG_PATH], FN_GAM_CFG)
    if not os.path.isfile(GM.Globals[GM.GAM_CFG_FILE]):
      for itemName, itemEntry in iter(GC.VAR_INFO.items()):
        if itemEntry[GC.VAR_TYPE] == GC.TYPE_DIRECTORY:
          _getDefault(itemName, itemEntry, None)
      oldGamPath = os.environ.get(EV_OLDGAMPATH, GC.Defaults[GC.CONFIG_DIR])
      for itemName, itemEntry in iter(GC.VAR_INFO.items()):
        if itemEntry[GC.VAR_TYPE] != GC.TYPE_DIRECTORY:
          _getDefault(itemName, itemEntry, oldGamPath)
      GM.Globals[GM.PARSER] = configparser.RawConfigParser(defaults=collections.OrderedDict(sorted(list(GC.Defaults.items()), key=lambda t: t[0])))
      _checkMakeDir(GC.CONFIG_DIR)
      _checkMakeDir(GC.CACHE_DIR)
      _checkMakeDir(GC.DRIVE_DIR)
      for itemName, itemEntry in iter(GC.VAR_INFO.items()):
        if itemEntry[GC.VAR_TYPE] == GC.TYPE_FILE:
          srcFile = os.path.expanduser(_stripStringQuotes(GM.Globals[GM.PARSER].get(configparser.DEFAULTSECT, itemName)))
          _copyCfgFile(srcFile, GC.CONFIG_DIR, oldGamPath)
      _writeGamCfgFile(GM.Globals[GM.PARSER], GM.Globals[GM.GAM_CFG_FILE], Act.INITIALIZE)
    else:
      GM.Globals[GM.PARSER] = configparser.RawConfigParser(defaults=collections.OrderedDict(sorted(list(GC.Defaults.items()), key=lambda t: t[0])))
      _readGamCfgFile(GM.Globals[GM.PARSER], GM.Globals[GM.GAM_CFG_FILE])
  status = {'errors': False}
  inputFilterSectionName = outputFilterSectionName = None
  GM.Globals[GM.GAM_CFG_SECTION] = os.environ.get(EV_GAMCFGSECTION, None)
  if GM.Globals[GM.GAM_CFG_SECTION]:
    sectionName = GM.Globals[GM.GAM_CFG_SECTION]
    GM.Globals[GM.SECTION] = sectionName # Save section for inner gams
    if not GM.Globals[GM.PARSER].has_section(sectionName):
      usageErrorExit(formatKeyValueList('', [EV_GAMCFGSECTION, sectionName, Msg.NOT_FOUND], ''))
    if checkArgumentPresent(Cmd.SELECT_CMD):
      Cmd.Backup()
      usageErrorExit(formatKeyValueList('', [EV_GAMCFGSECTION, sectionName, 'select', Msg.NOT_ALLOWED], ''))
  else:
    sectionName = _getCfgSection(configparser.DEFAULTSECT, GC.SECTION)
# select <SectionName> [save] [verify]
    if checkArgumentPresent(Cmd.SELECT_CMD):
      sectionName = _selectSection()
      GM.Globals[GM.SECTION] = sectionName # Save section for inner gams
      while Cmd.ArgumentsRemaining():
        if checkArgumentPresent('save'):
          GM.Globals[GM.PARSER].set(configparser.DEFAULTSECT, GC.SECTION, sectionName)
          _writeGamCfgFile(GM.Globals[GM.PARSER], GM.Globals[GM.GAM_CFG_FILE], Act.SAVE)
        elif checkArgumentPresent('verify'):
          _verifyValues(sectionName, inputFilterSectionName, outputFilterSectionName)
        else:
          break
  GM.Globals[GM.GAM_CFG_SECTION_NAME] = sectionName
# showsections
  if checkArgumentPresent(Cmd.SHOWSECTIONS_CMD):
    _showSections()
# selectfilter|selectoutputfilter|selectinputfilter <SectionName>
  while True:
    filterCommand = getChoice([Cmd.SELECTFILTER_CMD, Cmd.SELECTOUTPUTFILTER_CMD, Cmd.SELECTINPUTFILTER_CMD], defaultChoice=None)
    if filterCommand is None:
      break
    if filterCommand != Cmd.SELECTINPUTFILTER_CMD:
      outputFilterSectionName = _selectSection()
    else:
      inputFilterSectionName = _selectSection()
# Handle todrive_nobrowser and todrive_noemail if not present
  value = GM.Globals[GM.PARSER].get(configparser.DEFAULTSECT, GC.TODRIVE_NOBROWSER)
  if value == '':
    GM.Globals[GM.PARSER].set(configparser.DEFAULTSECT, GC.TODRIVE_NOBROWSER, str(_getCfgBoolean(configparser.DEFAULTSECT, GC.NO_BROWSER)).lower())
  value = GM.Globals[GM.PARSER].get(configparser.DEFAULTSECT, GC.TODRIVE_NOEMAIL)
  if value == '':
    GM.Globals[GM.PARSER].set(configparser.DEFAULTSECT, GC.TODRIVE_NOEMAIL, str(not _getCfgBoolean(configparser.DEFAULTSECT, GC.NO_BROWSER)).lower())
# Handle todrive_sheet_timestamp and todrive_sheet_timeformat if not present
  for section in [sectionName, configparser.DEFAULTSECT]:
    value = GM.Globals[GM.PARSER].get(section, GC.TODRIVE_SHEET_TIMESTAMP)
    if value == 'copy':
      GM.Globals[GM.PARSER].set(section, GC.TODRIVE_SHEET_TIMESTAMP, str(_getCfgBoolean(section, GC.TODRIVE_TIMESTAMP)).lower())
    value = GM.Globals[GM.PARSER].get(section, GC.TODRIVE_SHEET_TIMEFORMAT)
    if value == 'copy':
      GM.Globals[GM.PARSER].set(section, GC.TODRIVE_SHEET_TIMEFORMAT, _getCfgString(section, GC.TODRIVE_TIMEFORMAT))
# Fix mistyped keyword cmdlog_max__backups
  for section in [configparser.DEFAULTSECT, sectionName]:
    if GM.Globals[GM.PARSER].has_option(section, GC.CMDLOG_MAX__BACKUPS):
      GM.Globals[GM.PARSER].set(section, GC.CMDLOG_MAX_BACKUPS, GM.Globals[GM.PARSER].get(section, GC.CMDLOG_MAX__BACKUPS))
      GM.Globals[GM.PARSER].remove_option(section, GC.CMDLOG_MAX__BACKUPS)
# config (<VariableName> [=] <Value>)* [save] [verify]
  if checkArgumentPresent(Cmd.CONFIG_CMD):
    while Cmd.ArgumentsRemaining():
      if checkArgumentPresent('save'):
        _writeGamCfgFile(GM.Globals[GM.PARSER], GM.Globals[GM.GAM_CFG_FILE], Act.SAVE)
      elif checkArgumentPresent('verify'):
        _verifyValues(sectionName, inputFilterSectionName, outputFilterSectionName)
      else:
        itemName = getChoice(GC.VAR_INFO, defaultChoice=None)
        if itemName is None:
          break
        itemEntry = GC.VAR_INFO[itemName]
        checkArgumentPresent('=')
        varType = itemEntry[GC.VAR_TYPE]
        if varType == GC.TYPE_BOOLEAN:
          value = TRUE if getBoolean(None) else FALSE
        elif varType == GC.TYPE_CHARACTER:
          value = getCharacter()
        elif varType == GC.TYPE_CHOICE:
          value = getChoice(itemEntry[GC.VAR_CHOICES])
        elif varType == GC.TYPE_INTEGER:
          minVal, maxVal = itemEntry[GC.VAR_LIMITS]
          value = str(getInteger(minVal=minVal, maxVal=maxVal))
        elif varType == GC.TYPE_FLOAT:
          minVal, maxVal = itemEntry[GC.VAR_LIMITS]
          value = str(getFloat(minVal=minVal, maxVal=maxVal))
        elif varType == GC.TYPE_LOCALE:
          value = getLanguageCode(LOCALE_CODES_MAP)
        elif varType == GC.TYPE_PASSWORD:
          minLen, maxLen = itemEntry[GC.VAR_LIMITS]
          value = getString(Cmd.OB_STRING, checkBlank=True, minLen=minLen, maxLen=maxLen)
          if value and value.startswith("b'") and value.endswith("'"):
            value = bytes(value[2:-1], UTF8)
        elif varType == GC.TYPE_TIMEZONE:
          value = getString(Cmd.OB_STRING, checkBlank=True)
        else: # GC.TYPE_STRING, GC.TYPE_STRINGLIST
          minLen, maxLen = itemEntry.get(GC.VAR_LIMITS, (0, None))
          value = _quoteStringIfLeadingTrailingBlanks(getString(Cmd.OB_STRING, minLen=minLen, maxLen=maxLen))
        GM.Globals[GM.PARSER].set(sectionName, itemName, value)
  prevExtraArgsTxt = GC.Values.get(GC.EXTRA_ARGS, None)
  prevOauth2serviceJson = GC.Values.get(GC.OAUTH2SERVICE_JSON, None)
# Assign global variables, directories, timezone first as other variables depend on them
  for itemName, itemEntry in sorted(iter(GC.VAR_INFO.items())):
    varType = itemEntry[GC.VAR_TYPE]
    if varType == GC.TYPE_DIRECTORY:
      GC.Values[itemName] = _getCfgDirectory(sectionName, itemName)
    elif varType == GC.TYPE_TIMEZONE:
      GC.Values[itemName] = _getCfgTimezone(sectionName, itemName)
  GM.Globals[GM.DATETIME_NOW] = datetime.datetime.now(GC.Values[GC.TIMEZONE])
# Everything else except row filters
  for itemName, itemEntry in sorted(iter(GC.VAR_INFO.items())):
    varType = itemEntry[GC.VAR_TYPE]
    if varType == GC.TYPE_BOOLEAN:
      GC.Values[itemName] = _getCfgBoolean(sectionName, itemName)
    elif varType == GC.TYPE_CHARACTER:
      GC.Values[itemName] = _getCfgCharacter(sectionName, itemName)
    elif varType == GC.TYPE_CHOICE:
      GC.Values[itemName] = _getCfgChoice(sectionName, itemName)
    elif varType in [GC.TYPE_INTEGER, GC.TYPE_FLOAT]:
      GC.Values[itemName] = _getCfgNumber(sectionName, itemName)
    elif varType == GC.TYPE_HEADERFILTER:
      GC.Values[itemName] = _getCfgHeaderFilter(sectionName, itemName)
    elif varType == GC.TYPE_LOCALE:
      GC.Values[itemName] = _getCfgLocale(sectionName, itemName)
    elif varType == GC.TYPE_PASSWORD:
      GC.Values[itemName] = _getCfgPassword(sectionName, itemName)
    elif varType == GC.TYPE_STRING:
      GC.Values[itemName] = _getCfgString(sectionName, itemName)
    elif varType in {GC.TYPE_STRINGLIST, GC.TYPE_HEADERFORCE, GC.TYPE_HEADERORDER}:
      GC.Values[itemName] = _getCfgStringList(sectionName, itemName)
    elif varType == GC.TYPE_FILE:
      GC.Values[itemName] = _getCfgFile(sectionName, itemName)
# Row filters
  for itemName, itemEntry in sorted(iter(GC.VAR_INFO.items())):
    varType = itemEntry[GC.VAR_TYPE]
    if varType == GC.TYPE_ROWFILTER:
      GC.Values[itemName] = _getCfgRowFilter(sectionName, itemName)
# Process selectfilter|selectoutputfilter|selectinputfilter
  if inputFilterSectionName:
    GC.Values[GC.CSV_INPUT_ROW_FILTER] = _getCfgRowFilter(inputFilterSectionName, GC.CSV_INPUT_ROW_FILTER)
    GC.Values[GC.CSV_INPUT_ROW_FILTER_MODE] = _getCfgChoice(inputFilterSectionName, GC.CSV_INPUT_ROW_FILTER_MODE)
    GC.Values[GC.CSV_INPUT_ROW_DROP_FILTER] = _getCfgRowFilter(inputFilterSectionName, GC.CSV_INPUT_ROW_DROP_FILTER)
    GC.Values[GC.CSV_INPUT_ROW_DROP_FILTER_MODE] = _getCfgChoice(inputFilterSectionName, GC.CSV_INPUT_ROW_DROP_FILTER_MODE)
    GC.Values[GC.CSV_INPUT_ROW_LIMIT] = _getCfgNumber(inputFilterSectionName, GC.CSV_INPUT_ROW_LIMIT)
  if outputFilterSectionName:
    GC.Values[GC.CSV_OUTPUT_HEADER_FORCE] = _getCfgStringList(outputFilterSectionName, GC.CSV_OUTPUT_HEADER_FORCE)
    if GC.Values[GC.CSV_OUTPUT_HEADER_FORCE]:
      GC.Values[GC.CSV_OUTPUT_HEADER_FILTER] = _getCfgHeaderFilterFromForce(outputFilterSectionName, GC.CSV_OUTPUT_HEADER_FORCE)
    else:
      GC.Values[GC.CSV_OUTPUT_HEADER_FILTER] = _getCfgHeaderFilter(outputFilterSectionName, GC.CSV_OUTPUT_HEADER_FILTER)
    GC.Values[GC.CSV_OUTPUT_HEADER_DROP_FILTER] = _getCfgHeaderFilter(outputFilterSectionName, GC.CSV_OUTPUT_HEADER_DROP_FILTER)
    GC.Values[GC.CSV_OUTPUT_HEADER_ORDER] = _getCfgStringList(outputFilterSectionName, GC.CSV_OUTPUT_HEADER_ORDER)
    GC.Values[GC.CSV_OUTPUT_ROW_FILTER] = _getCfgRowFilter(outputFilterSectionName, GC.CSV_OUTPUT_ROW_FILTER)
    GC.Values[GC.CSV_OUTPUT_ROW_FILTER_MODE] = _getCfgChoice(outputFilterSectionName, GC.CSV_OUTPUT_ROW_FILTER_MODE)
    GC.Values[GC.CSV_OUTPUT_ROW_DROP_FILTER] = _getCfgRowFilter(outputFilterSectionName, GC.CSV_OUTPUT_ROW_DROP_FILTER)
    GC.Values[GC.CSV_OUTPUT_ROW_DROP_FILTER_MODE] = _getCfgChoice(outputFilterSectionName, GC.CSV_OUTPUT_ROW_DROP_FILTER_MODE)
    GC.Values[GC.CSV_OUTPUT_ROW_LIMIT] = _getCfgNumber(outputFilterSectionName, GC.CSV_OUTPUT_ROW_LIMIT)
    GC.Values[GC.CSV_OUTPUT_SORT_HEADERS] = _getCfgStringList(outputFilterSectionName, GC.CSV_OUTPUT_SORT_HEADERS)
  elif GC.Values[GC.CSV_OUTPUT_HEADER_FORCE]:
    GC.Values[GC.CSV_OUTPUT_HEADER_FILTER] = _getCfgHeaderFilterFromForce(sectionName, GC.CSV_OUTPUT_HEADER_FORCE)
  if status['errors']:
    sys.exit(CONFIG_ERROR_RC)
# Global values cleanup
  GC.Values[GC.DOMAIN] = GC.Values[GC.DOMAIN].lower()
  if not GC.Values[GC.SMTP_FQDN]:
    GC.Values[GC.SMTP_FQDN] = None
# Inherit debug_level, output_dateformat, output_timeformat if not locally defined
  if GM.Globals[GM.PID] != 0:
    if GC.Values[GC.DEBUG_LEVEL] == 0:
      GC.Values[GC.DEBUG_LEVEL] = GM.Globals[GM.DEBUG_LEVEL]
    if not GC.Values[GC.OUTPUT_DATEFORMAT]:
      GC.Values[GC.OUTPUT_DATEFORMAT] = GM.Globals[GM.OUTPUT_DATEFORMAT]
    if not GC.Values[GC.OUTPUT_TIMEFORMAT]:
      GC.Values[GC.OUTPUT_TIMEFORMAT] = GM.Globals[GM.OUTPUT_TIMEFORMAT]
# Define lockfile: oauth2.txt.lock
  GM.Globals[GM.OAUTH2_TXT_LOCK] = f'{GC.Values[GC.OAUTH2_TXT]}.lock'
# Override httplib2 settings
  httplib2.debuglevel = GC.Values[GC.DEBUG_LEVEL]
# Reset global variables if required
  if prevExtraArgsTxt != GC.Values[GC.EXTRA_ARGS]:
    GM.Globals[GM.EXTRA_ARGS_LIST] = [('prettyPrint', GC.Values[GC.DEBUG_LEVEL] > 0)]
    if GC.Values[GC.EXTRA_ARGS]:
      ea_config = configparser.ConfigParser()
      ea_config.optionxform = str
      ea_config.read(GC.Values[GC.EXTRA_ARGS])
      GM.Globals[GM.EXTRA_ARGS_LIST].extend(ea_config.items('extra-args'))
  if prevOauth2serviceJson != GC.Values[GC.OAUTH2SERVICE_JSON]:
    GM.Globals[GM.OAUTH2SERVICE_JSON_DATA] = {}
    GM.Globals[GM.OAUTH2SERVICE_CLIENT_ID] = None
  Cmd.SetEncoding(GM.Globals[GM.SYS_ENCODING])
# multiprocessexit (rc<Operator><Number>)|(rcrange=<Number>/<Number>)|(rcrange!=<Number>/<Number>)
  if checkArgumentPresent(Cmd.MULTIPROCESSEXIT_CMD):
    _setMultiprocessExit()
# redirect csv <FileName> [multiprocess] [append] [noheader] [charset <CharSet>]
#	       [columndelimiter <Character>] [quotechar <Character>]] [noescapechar [<Boolean>]]
#	       [sortheaders <StringList>] [timestampcolumn <String>] [transpose [<Boolean>]]
#	       [todrive <ToDriveAttribute>*]
# redirect stdout <FileName> [multiprocess] [append]
# redirect stdout null
# redirect stderr <FileName> [multiprocess] [append]
# redirect stderr stdout
# redirect stderr null
  while checkArgumentPresent(Cmd.REDIRECT_CMD):
    myarg = getChoice(['csv', 'stdout', 'stderr'])
    filename = re.sub(r'{{Section}}', sectionName, getString(Cmd.OB_FILE_NAME, checkBlank=True))
    if myarg == 'csv':
      multi = checkArgumentPresent('multiprocess')
      mode = DEFAULT_FILE_APPEND_MODE if checkArgumentPresent('append') else DEFAULT_FILE_WRITE_MODE
      writeHeader = not checkArgumentPresent('noheader')
      encoding = getCharSet()
      if checkArgumentPresent('columndelimiter'):
        GM.Globals[GM.CSV_OUTPUT_COLUMN_DELIMITER] = GC.Values[GC.CSV_OUTPUT_COLUMN_DELIMITER] = getCharacter()
      if checkArgumentPresent('quotechar'):
        GM.Globals[GM.CSV_OUTPUT_QUOTE_CHAR] = GC.Values[GC.CSV_OUTPUT_QUOTE_CHAR] = getCharacter()
      if checkArgumentPresent('noescapechar'):
        GM.Globals[GM.CSV_OUTPUT_NO_ESCAPE_CHAR] = GC.Values[GC.CSV_OUTPUT_NO_ESCAPE_CHAR] = getBoolean()
      if checkArgumentPresent('sortheaders'):
        GM.Globals[GM.CSV_OUTPUT_SORT_HEADERS] = GC.Values[GC.CSV_OUTPUT_SORT_HEADERS] = getString(Cmd.OB_STRING_LIST, minLen=0).replace(',', ' ').split()
      if checkArgumentPresent('timestampcolumn'):
        GM.Globals[GM.CSV_OUTPUT_TIMESTAMP_COLUMN] = GC.Values[GC.CSV_OUTPUT_TIMESTAMP_COLUMN] = getString(Cmd.OB_STRING, minLen=0)
      if checkArgumentPresent('transpose'):
        GM.Globals[GM.CSV_OUTPUT_TRANSPOSE] = getBoolean()
      _setCSVFile(filename, mode, encoding, writeHeader, multi)
      GM.Globals[GM.CSVFILE][GM.REDIRECT_QUEUE_CSVPF] = CSVPrintFile()
      if checkArgumentPresent('todrive'):
        GM.Globals[GM.CSVFILE][GM.REDIRECT_QUEUE_CSVPF].GetTodriveParameters()
        GM.Globals[GM.CSV_TODRIVE] = GM.Globals[GM.CSVFILE][GM.REDIRECT_QUEUE_CSVPF].todrive.copy()
    elif myarg == 'stdout':
      if filename.lower() == 'null':
        multi = checkArgumentPresent('multiprocess')
        _setSTDFile(GM.STDOUT, 'null', DEFAULT_FILE_WRITE_MODE, multi)
      else:
        multi = checkArgumentPresent('multiprocess')
        mode = DEFAULT_FILE_APPEND_MODE if checkArgumentPresent('append') else DEFAULT_FILE_WRITE_MODE
        _setSTDFile(GM.STDOUT, filename, mode, multi)
    else: # myarg == 'stderr'
      if filename.lower() == 'null':
        multi = checkArgumentPresent('multiprocess')
        _setSTDFile(GM.STDERR, 'null', DEFAULT_FILE_WRITE_MODE, multi)
      elif filename.lower() != 'stdout':
        multi = checkArgumentPresent('multiprocess')
        mode = DEFAULT_FILE_APPEND_MODE if checkArgumentPresent('append') else DEFAULT_FILE_WRITE_MODE
        _setSTDFile(GM.STDERR, filename, mode, multi)
      else:
        multi = checkArgumentPresent('multiprocess')
        if not GM.Globals[GM.STDOUT]:
          _setSTDFile(GM.STDOUT, '-', DEFAULT_FILE_WRITE_MODE, multi)
        GM.Globals[GM.STDERR] = GM.Globals[GM.STDOUT].copy()
        GM.Globals[GM.STDERR][GM.REDIRECT_NAME] = 'stdout'
  if not GM.Globals[GM.STDOUT]:
    _setSTDFile(GM.STDOUT, '-', DEFAULT_FILE_WRITE_MODE, False)
  if not GM.Globals[GM.STDERR]:
    _setSTDFile(GM.STDERR, '-', DEFAULT_FILE_WRITE_MODE, False)
# If both csv and stdout are redirected to - with same multiprocess setting and csv doesn't have any todrive parameters, collapse csv onto stdout
  if (GM.Globals[GM.PID] == 0  and GM.Globals[GM.CSVFILE] and
      GM.Globals[GM.CSVFILE][GM.REDIRECT_NAME] == '-' and GM.Globals[GM.STDOUT][GM.REDIRECT_NAME] == '-' and
      GM.Globals[GM.CSVFILE][GM.REDIRECT_MULTIPROCESS] == GM.Globals[GM.STDOUT][GM.REDIRECT_MULTIPROCESS] and
      GM.Globals[GM.CSVFILE].get(GM.REDIRECT_QUEUE_CSVPF) and not GM.Globals[GM.CSVFILE][GM.REDIRECT_QUEUE_CSVPF].todrive):
    _setCSVFile('-', GM.Globals[GM.STDOUT].get(GM.REDIRECT_MODE, DEFAULT_FILE_WRITE_MODE), GC.Values[GC.CHARSET],
                GM.Globals[GM.CSVFILE].get(GM.REDIRECT_WRITE_HEADER, True), GM.Globals[GM.STDOUT][GM.REDIRECT_MULTIPROCESS])
  elif not GM.Globals[GM.CSVFILE]:
    _setCSVFile('-', GM.Globals[GM.STDOUT].get(GM.REDIRECT_MODE, DEFAULT_FILE_WRITE_MODE), GC.Values[GC.CHARSET], True, False)
  initAPICallsRateCheck()
# Main process
# Clear input row filters/limit from parser, children can define but shouldn't inherit global value
# Clear output header/row filters/limit from parser, children can define or they will inherit global value if not defined
  if GM.Globals[GM.PID] == 0:
    for itemName, itemEntry in sorted(iter(GC.VAR_INFO.items())):
      varType = itemEntry[GC.VAR_TYPE]
      if varType in {GC.TYPE_HEADERFILTER, GC.TYPE_HEADERFORCE, GC.TYPE_HEADERORDER, GC.TYPE_ROWFILTER}:
        GM.Globals[GM.PARSER].set(sectionName, itemName, '')
      elif (varType == GC.TYPE_INTEGER) and itemName in {GC.CSV_INPUT_ROW_LIMIT, GC.CSV_OUTPUT_ROW_LIMIT}:
        GM.Globals[GM.PARSER].set(sectionName, itemName, '0')
# Child process
# Inherit main process output header/row filters/limit, print defaults if not locally defined
  else:
    if not GC.Values[GC.CSV_OUTPUT_HEADER_FILTER]:
      GC.Values[GC.CSV_OUTPUT_HEADER_FILTER] = GM.Globals[GM.CSV_OUTPUT_HEADER_FILTER][:]
    if not GC.Values[GC.CSV_OUTPUT_HEADER_DROP_FILTER]:
      GC.Values[GC.CSV_OUTPUT_HEADER_DROP_FILTER] = GM.Globals[GM.CSV_OUTPUT_HEADER_DROP_FILTER][:]
    if not GC.Values[GC.CSV_OUTPUT_HEADER_FORCE]:
      GC.Values[GC.CSV_OUTPUT_HEADER_FORCE] = GM.Globals[GM.CSV_OUTPUT_HEADER_FORCE][:]
    if not GC.Values[GC.CSV_OUTPUT_HEADER_ORDER]:
      GC.Values[GC.CSV_OUTPUT_HEADER_ORDER] = GM.Globals[GM.CSV_OUTPUT_HEADER_ORDER][:]
    if not GC.Values[GC.CSV_OUTPUT_ROW_FILTER]:
      GC.Values[GC.CSV_OUTPUT_ROW_FILTER] = GM.Globals[GM.CSV_OUTPUT_ROW_FILTER][:]
      GC.Values[GC.CSV_OUTPUT_ROW_FILTER_MODE] = GM.Globals[GM.CSV_OUTPUT_ROW_FILTER_MODE]
    if not GC.Values[GC.CSV_OUTPUT_ROW_DROP_FILTER]:
      GC.Values[GC.CSV_OUTPUT_ROW_DROP_FILTER] = GM.Globals[GM.CSV_OUTPUT_ROW_DROP_FILTER][:]
      GC.Values[GC.CSV_OUTPUT_ROW_DROP_FILTER_MODE] = GM.Globals[GM.CSV_OUTPUT_ROW_DROP_FILTER_MODE]
    if not GC.Values[GC.CSV_OUTPUT_ROW_LIMIT]:
      GC.Values[GC.CSV_OUTPUT_ROW_LIMIT] = GM.Globals[GM.CSV_OUTPUT_ROW_LIMIT]
    if not GC.Values[GC.PRINT_AGU_DOMAINS]:
      GC.Values[GC.PRINT_AGU_DOMAINS] = GM.Globals[GM.PRINT_AGU_DOMAINS]
    if not GC.Values[GC.PRINT_CROS_OUS]:
      GC.Values[GC.PRINT_CROS_OUS] = GM.Globals[GM.PRINT_CROS_OUS]
    if not GC.Values[GC.PRINT_CROS_OUS_AND_CHILDREN]:
      GC.Values[GC.PRINT_CROS_OUS_AND_CHILDREN] = GM.Globals[GM.PRINT_CROS_OUS_AND_CHILDREN]
    GC.Values[GC.SHOW_GETTINGS] = GM.Globals[GM.SHOW_GETTINGS]
    GC.Values[GC.SHOW_GETTINGS_GOT_NL] = GM.Globals[GM.SHOW_GETTINGS_GOT_NL]
# customer_id, domain and admin_email must be set when enable_dasa = true
  if GC.Values[GC.ENABLE_DASA]:
    errors = 0
    for itemName in [GC.CUSTOMER_ID, GC.DOMAIN, GC.ADMIN_EMAIL]:
      if not GC.Values[itemName] or (itemName == GC.CUSTOMER_ID and GC.Values[itemName] == GC.MY_CUSTOMER):
        stderrErrorMsg(formatKeyValueList('',
                                          [Ent.Singular(Ent.CONFIG_FILE), GM.Globals[GM.GAM_CFG_FILE],
                                           Ent.Singular(Ent.SECTION), sectionName,
                                           itemName, GC.Values[itemName] or '""',
                                           GC.ENABLE_DASA, GC.Values[GC.ENABLE_DASA],
                                           Msg.NOT_COMPATIBLE],
                                          '\n'))
        errors += 1
    if errors:
      sys.exit(USAGE_ERROR_RC)
# If no select/options commands were executed or some were and there are more arguments on the command line,
# warn if the json files are missing and return True
  if (Cmd.Location() == 1) or (Cmd.ArgumentsRemaining()):
    _chkCfgDirectories(sectionName)
    _chkCfgFiles(sectionName)
    if status['errors']:
      sys.exit(CONFIG_ERROR_RC)
    if GC.Values[GC.NO_CACHE]:
      GM.Globals[GM.CACHE_DIR] = None
      GM.Globals[GM.CACHE_DISCOVERY_ONLY] = False
    else:
      GM.Globals[GM.CACHE_DIR] = GC.Values[GC.CACHE_DIR]
      GM.Globals[GM.CACHE_DISCOVERY_ONLY] = GC.Values[GC.CACHE_DISCOVERY_ONLY]
# Set environment variables so GData API can find cacerts.pem
    os.environ['REQUESTS_CA_BUNDLE'] = GC.Values[GC.CACERTS_PEM]
    os.environ['DEFAULT_CA_BUNDLE_PATH'] = GC.Values[GC.CACERTS_PEM]
    os.environ['HTTPLIB2_CA_CERTS'] = GC.Values[GC.CACERTS_PEM]
    os.environ['SSL_CERT_FILE'] = GC.Values[GC.CACERTS_PEM]
    httplib2.CA_CERTS = GC.Values[GC.CACERTS_PEM]
# Needs to be set so oauthlib doesn't puke when Google changes our scopes
    os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = 'true'
# Set up command logging at top level only
    if (GM.Globals[GM.PID] == 0) and GC.Values[GC.CMDLOG]:
      openGAMCommandLog(GM.Globals, 'mainlog')
    return True
# We're done, nothing else to do
  return False

def handleServerError(e):
  errMsg = str(e)
  if 'setting tls' not in errMsg:
    systemErrorExit(NETWORK_ERROR_RC, errMsg)
  stderrErrorMsg(errMsg)
  writeStderr(Msg.DISABLE_TLS_MIN_MAX)
  systemErrorExit(NETWORK_ERROR_RC, None)

def getHttpObj(cache=None, timeout=None, override_min_tls=None, override_max_tls=None):
  tls_minimum_version = override_min_tls if override_min_tls else GC.Values[GC.TLS_MIN_VERSION] if GC.Values[GC.TLS_MIN_VERSION] else None
  tls_maximum_version = override_max_tls if override_max_tls else GC.Values[GC.TLS_MAX_VERSION] if GC.Values[GC.TLS_MAX_VERSION] else None
  httpObj = httplib2.Http(cache=cache,
                          timeout=timeout,
                          ca_certs=GC.Values[GC.CACERTS_PEM],
                          disable_ssl_certificate_validation=GC.Values[GC.NO_VERIFY_SSL],
                          tls_maximum_version=tls_maximum_version,
                          tls_minimum_version=tls_minimum_version)
  httpObj.redirect_codes = set(httpObj.redirect_codes) - {308}
  return httpObj

def _force_user_agent(user_agent):
  """Creates a decorator which can force a user agent in HTTP headers."""

  def decorator(request_method):
    """Wraps a request method to insert a user-agent in HTTP headers."""

    def wrapped_request_method(*args, **kwargs):
      """Modifies HTTP headers to include a specified user-agent."""
      if kwargs.get('headers') is not None:
        if kwargs['headers'].get('user-agent'):
          if user_agent not in kwargs['headers']['user-agent']:
            # Save the existing user-agent header and tack on our own.
            kwargs['headers']['user-agent'] = f'{user_agent} {kwargs["headers"]["user-agent"]}'
        else:
          kwargs['headers']['user-agent'] = user_agent
      else:
        kwargs['headers'] = {'user-agent': user_agent}
      return request_method(*args, **kwargs)

    return wrapped_request_method

  return decorator

class transportAgentRequest(google_auth_httplib2.Request):
  """A Request which forces a user agent."""

  @_force_user_agent(GAM_USER_AGENT)
  def __call__(self, *args, **kwargs): #pylint: disable=arguments-differ
    """Inserts the GAM user-agent header in requests."""
    return super().__call__(*args, **kwargs)

class transportAuthorizedHttp(google_auth_httplib2.AuthorizedHttp):
  """An AuthorizedHttp which forces a user agent during requests."""

  @_force_user_agent(GAM_USER_AGENT)
  def request(self, *args, **kwargs): #pylint: disable=arguments-differ
    """Inserts the GAM user-agent header in requests."""
    return super().request(*args, **kwargs)

def transportCreateRequest(httpObj=None):
  """Creates a uniform Request object with a default http, if not provided.

  Args:
    httpObj: Optional httplib2.Http compatible object to be used with the request.
      If not provided, a default HTTP will be used.

  Returns:
    Request: A google_auth_httplib2.Request compatible Request.
  """
  if not httpObj:
    httpObj = getHttpObj()
  return transportAgentRequest(httpObj)

def doGAMCheckForUpdates(forceCheck):
  def _gamLatestVersionNotAvailable():
    if forceCheck:
      systemErrorExit(NETWORK_ERROR_RC, Msg.GAM_LATEST_VERSION_NOT_AVAILABLE)

  try:
    _, c = getHttpObj(timeout=10).request(GAM_LATEST_RELEASE, 'GET', headers={'Accept': 'application/vnd.github.v3.text+json'})
    try:
      release_data = json.loads(c)
    except (IndexError, KeyError, SyntaxError, TypeError, ValueError):
      _gamLatestVersionNotAvailable()
      return
    if not isinstance(release_data, dict) or 'tag_name' not in release_data:
      _gamLatestVersionNotAvailable()
      return
    current_version = __version__
    latest_version = release_data['tag_name']
    if latest_version[0].lower() == 'v':
      latest_version = latest_version[1:]
      printKeyValueList(['Version Check', None])
      Ind.Increment()
      printKeyValueList(['Current', current_version])
      printKeyValueList([' Latest', latest_version])
      Ind.Decrement()
    if forceCheck < 0:
      setSysExitRC(1 if latest_version > current_version else 0)
      return
  except (httplib2.HttpLib2Error, httplib2.ServerNotFoundError,
          google.auth.exceptions.TransportError,
          RuntimeError, ConnectionError, OSError) as e:
    if forceCheck:
      handleServerError(e)

_DEFAULT_TOKEN_LIFETIME_SECS = 3600  # 1 hour in seconds

class signjwtJWTCredentials(google.auth.jwt.Credentials):
  ''' Class used for DASA '''
  def _make_jwt(self):
    now = datetime.datetime.utcnow()
    lifetime = datetime.timedelta(seconds=self._token_lifetime)
    expiry = now + lifetime
    payload = {
      "iat": google.auth._helpers.datetime_to_secs(now),
      "exp": google.auth._helpers.datetime_to_secs(expiry),
      "iss": self._issuer,
      "sub": self._subject,
    }
    if self._audience:
      payload["aud"] = self._audience
    payload.update(self._additional_claims)
    jwt = self._signer.sign(payload)
    return jwt, expiry

# Some Workforce Identity Federation endpoints such as GitHub Actions
# only allow TLS 1.2 as of April 2023.

def getTLSv1_2Request():
  httpc = getHttpObj(override_min_tls='TLSv1_2')
  return transportCreateRequest(httpc)

class signjwtCredentials(google.oauth2.service_account.Credentials):
  ''' Class used for DwD '''

  def _make_authorization_grant_assertion(self):
    now = datetime.datetime.utcnow()
    lifetime = datetime.timedelta(seconds=_DEFAULT_TOKEN_LIFETIME_SECS)
    expiry = now + lifetime
    payload = {
        "iat": google.auth._helpers.datetime_to_secs(now),
        "exp": google.auth._helpers.datetime_to_secs(expiry),
        "iss": self._service_account_email,
        "aud": API.GOOGLE_OAUTH2_TOKEN_ENDPOINT,
        "scope": google.auth._helpers.scopes_to_string(self._scopes or ()),
    }
    payload.update(self._additional_claims)
    # The subject can be a user email for domain-wide delegation.
    if self._subject:
      payload.setdefault("sub", self._subject)
    token = self._signer(payload)
    return token

class signjwtSignJwt(google.auth.crypt.Signer):
  ''' Signer class for SignJWT '''
  def __init__(self, service_account_info):
    self.service_account_email = service_account_info['client_email']
    self.name = f'projects/-/serviceAccounts/{self.service_account_email}'
    self._key_id = None

  @property  # type: ignore
  def key_id(self):
    return self._key_id

  def sign(self, message):
    ''' Call IAM Credentials SignJWT API to get our signed JWT '''
    try:
      credentials, _ = google.auth.default(scopes=[API.IAM_SCOPE],
                                           request=getTLSv1_2Request())
    except (google.auth.exceptions.DefaultCredentialsError, google.auth.exceptions.RefreshError) as e:
      systemErrorExit(API_ACCESS_DENIED_RC, str(e))
    httpObj = transportAuthorizedHttp(credentials, http=getHttpObj(override_min_tls='TLSv1_2'))
    iamc = getService(API.IAM_CREDENTIALS, httpObj)
    response = callGAPI(iamc.projects().serviceAccounts(), 'signJwt',
                        name=self.name, body={'payload': json.dumps(message)})
    signed_jwt = response.get('signedJwt')
    return signed_jwt

def handleOAuthTokenError(e, softErrors, displayError=False, i=0, count=0):
  errMsg = str(e).replace('.', '')
  if ((errMsg in API.OAUTH2_TOKEN_ERRORS) or
      errMsg.startswith('Invalid response') or
      errMsg.startswith('invalid_request: Invalid impersonation &quot;sub&quot; field')):
    if not GM.Globals[GM.CURRENT_SVCACCT_USER]:
      ClientAPIAccessDeniedExit()
    if softErrors:
      entityActionFailedWarning([Ent.USER, GM.Globals[GM.CURRENT_SVCACCT_USER], Ent.USER, None], errMsg, i, count)
      return None
    systemErrorExit(SERVICE_NOT_APPLICABLE_RC, Msg.SERVICE_NOT_APPLICABLE_THIS_ADDRESS.format(GM.Globals[GM.CURRENT_SVCACCT_USER]))
  if errMsg in API.OAUTH2_UNAUTHORIZED_ERRORS:
    if not GM.Globals[GM.CURRENT_SVCACCT_USER]:
      ClientAPIAccessDeniedExit()
    if softErrors:
      if displayError:
        apiOrScopes = API.getAPIName(GM.Globals[GM.CURRENT_SVCACCT_API]) if GM.Globals[GM.CURRENT_SVCACCT_API] else ','.join(sorted(GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES]))
        userServiceNotEnabledWarning(GM.Globals[GM.CURRENT_SVCACCT_USER], apiOrScopes, i, count)
      return None
    SvcAcctAPIAccessDeniedExit()
  if errMsg in API.REFRESH_PERM_ERRORS:
    if softErrors:
      return None
    if not GM.Globals[GM.CURRENT_SVCACCT_USER]:
      expiredRevokedOauth2TxtExit()
  stderrErrorMsg(f'Authentication Token Error - {errMsg}')
  APIAccessDeniedExit()

def getOauth2TxtCredentials(exitOnError=True, api=None, noDASA=False, refreshOnly=False, noScopes=False):
  if not noDASA and GC.Values[GC.ENABLE_DASA]:
    jsonData = readFile(GC.Values[GC.OAUTH2SERVICE_JSON], continueOnError=True, displayError=False)
    if jsonData:
      try:
        if api in API.APIS_NEEDING_ACCESS_TOKEN:
          return (False, getSvcAcctCredentials(API.APIS_NEEDING_ACCESS_TOKEN[api], userEmail=None, forceOauth=True))
        jsonDict = json.loads(jsonData)
        api, _, _ = API.getVersion(api)
        audience = f'https://{api}.googleapis.com/'
        key_type = jsonDict.get('key_type', 'default')
        if key_type == 'default':
          return (True, JWTCredentials.from_service_account_info(jsonDict, audience=audience))
        if key_type == 'yubikey':
          yksigner = yubikey.YubiKey(jsonDict)
          return (True, JWTCredentials._from_signer_and_info(yksigner, jsonDict, audience=audience))
        if key_type == 'signjwt':
          sjsigner = signjwtSignJwt(jsonDict)
          return (True, signjwtJWTCredentials._from_signer_and_info(sjsigner, jsonDict, audience=audience))
      except (IndexError, KeyError, SyntaxError, TypeError, ValueError) as e:
        invalidOauth2serviceJsonExit(str(e))
    invalidOauth2serviceJsonExit(Msg.NO_DATA)
  jsonData = readFile(GC.Values[GC.OAUTH2_TXT], continueOnError=True, displayError=False)
  if jsonData:
    try:
      jsonDict = json.loads(jsonData)
      if noScopes:
        jsonDict['scopes'] = []
      if 'client_id' in jsonDict:
        if not refreshOnly:
          if set(jsonDict.get('scopes', API.REQUIRED_SCOPES)) == API.REQUIRED_SCOPES_SET:
            if exitOnError:
              systemErrorExit(OAUTH2_TXT_REQUIRED_RC, Msg.NO_CLIENT_ACCESS_ALLOWED)
            return (False, None)
        else:
          GM.Globals[GM.CREDENTIALS_SCOPES] = set(jsonDict.pop('scopes', API.REQUIRED_SCOPES))
        token_expiry = jsonDict.get('token_expiry', REFRESH_EXPIRY)
        if GC.Values[GC.TRUNCATE_CLIENT_ID]:
          # chop off .apps.googleusercontent.com suffix as it's not needed and we need to keep things short for the Auth URL.
          jsonDict['client_id'] = re.sub(r'\.apps\.googleusercontent\.com$', '', jsonDict['client_id'])
        creds = google.oauth2.credentials.Credentials.from_authorized_user_info(jsonDict)
        if 'id_token_jwt' not in jsonDict:
          creds.token = jsonDict['token']
          creds._id_token = jsonDict['id_token']
          GM.Globals[GM.DECODED_ID_TOKEN] = jsonDict['decoded_id_token']
        else:
          creds.token = jsonDict['access_token']
          creds._id_token = jsonDict['id_token_jwt']
          GM.Globals[GM.DECODED_ID_TOKEN] = jsonDict['id_token']
        creds.expiry = datetime.datetime.strptime(token_expiry, YYYYMMDDTHHMMSSZ_FORMAT)
        return (not noScopes, creds)
      if jsonDict and exitOnError:
        invalidOauth2TxtExit(Msg.INVALID)
    except (IndexError, KeyError, SyntaxError, TypeError, ValueError) as e:
      if exitOnError:
        invalidOauth2TxtExit(str(e))
  if exitOnError:
    systemErrorExit(OAUTH2_TXT_REQUIRED_RC, Msg.NO_CLIENT_ACCESS_ALLOWED)
  return (False, None)

def _getValueFromOAuth(field, credentials=None):
  if not GM.Globals[GM.DECODED_ID_TOKEN]:
    request = transportCreateRequest()
    if credentials is None:
      credentials = getClientCredentials(refreshOnly=True)
    elif credentials.expired:
      credentials.refresh(request)
    try:
      GM.Globals[GM.DECODED_ID_TOKEN] = google.oauth2.id_token.verify_oauth2_token(credentials.id_token, request,
                                                                                   clock_skew_in_seconds=GC.Values[GC.CLOCK_SKEW_IN_SECONDS])
    except ValueError as e:
      if 'Token used too early' in str(e):
        stderrErrorMsg(Msg.PLEASE_CORRECT_YOUR_SYSTEM_TIME)
      systemErrorExit(SYSTEM_ERROR_RC, str(e))
  return GM.Globals[GM.DECODED_ID_TOKEN].get(field, UNKNOWN)

def _getAdminEmail():
  if GC.Values[GC.ADMIN_EMAIL]:
    return GC.Values[GC.ADMIN_EMAIL]
  return _getValueFromOAuth('email')

def writeClientCredentials(creds, filename):
  creds_data = {
    'client_id': creds.client_id,
    'client_secret': creds.client_secret,
    'id_token': creds.id_token,
    'refresh_token': creds.refresh_token,
    'scopes': sorted(creds.scopes or GM.Globals[GM.CREDENTIALS_SCOPES]),
    'token': creds.token,
    'token_expiry': creds.expiry.strftime(YYYYMMDDTHHMMSSZ_FORMAT),
    'token_uri': creds.token_uri,
    }
  expected_iss = ['https://accounts.google.com', 'accounts.google.com']
  if _getValueFromOAuth('iss', creds) not in expected_iss:
    systemErrorExit(OAUTH2_TXT_REQUIRED_RC, f'Wrong OAuth 2.0 credentials issuer. Got {_getValueFromOAuth("iss", creds)} expected one of {", ".join(expected_iss)}')
  request = transportCreateRequest()
  try:
    creds_data['decoded_id_token'] = google.oauth2.id_token.verify_oauth2_token(creds.id_token, request,
                                                                                clock_skew_in_seconds=GC.Values[GC.CLOCK_SKEW_IN_SECONDS])
  except ValueError as e:
    if 'Token used too early' in str(e):
      stderrErrorMsg(Msg.PLEASE_CORRECT_YOUR_SYSTEM_TIME)
    systemErrorExit(SYSTEM_ERROR_RC, str(e))
  GM.Globals[GM.DECODED_ID_TOKEN] = creds_data['decoded_id_token']
  if filename != '-':
    writeFile(filename, json.dumps(creds_data, indent=2, sort_keys=True)+'\n')
  else:
    writeStdout(json.dumps(creds_data, ensure_ascii=False, sort_keys=True, indent=2)+'\n')

URL_SHORTENER_ENDPOINT = 'https://gam-shortn.appspot.com/create'

def shortenURL(long_url):
  if GC.Values[GC.NO_SHORT_URLS]:
    return long_url
  httpObj = getHttpObj(timeout=10)
  try:
    payload = json.dumps({'long_url': long_url})
    resp, content = httpObj.request(URL_SHORTENER_ENDPOINT, 'POST',
                                    payload,
                                    headers={'Content-Type': 'application/json',
                                             'User-Agent': GAM_USER_AGENT})
  except:
    return long_url
  if resp.status != 200:
    return long_url
  try:
    if isinstance(content, bytes):
      content = content.decode()
    return json.loads(content).get('short_url', long_url)
  except:
    return long_url

def runSqliteQuery(db_file, query):
  conn = sqlite3.connect(db_file)
  curr = conn.cursor()
  curr.execute(query)
  return curr.fetchone()[0]

def refreshCredentialsWithReauth(credentials):
  def gcloudError():
    writeStderr(f'Failed to run gcloud as {admin_email}. Please make sure it\'s setup')
    e = Msg.REAUTHENTICATION_IS_NEEDED
    handleOAuthTokenError(e, False)

  writeStderr(Msg.CALLING_GCLOUD_FOR_REAUTH)
  if 'termios' in sys.modules:
    old_settings = termios.tcgetattr(sys.stdin)
  admin_email = _getAdminEmail()
  # First makes sure gcloud has a valid access token and thus
  # should also have a valid RAPT token
  try:
    devnull = open(os.devnull, 'w', encoding=UTF8)
    subprocess.run(['gcloud',
                    'auth',
                    'print-identity-token',
                    '--no-user-output-enabled'],
                   stderr=devnull,
                   check=False)
    devnull.close()
    # now determine gcloud's config path and token file
    gcloud_path_result = subprocess.run(['gcloud',
                                         'info',
                                         '--format=value(config.paths.global_config_dir)'],
            capture_output=True, check=False)
  except KeyboardInterrupt as e:
    # avoids loss of terminal echo on *nix
    if 'termios' in sys.modules:
      termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
    printBlankLine()
    raise KeyboardInterrupt from e
  token_path = gcloud_path_result.stdout.decode().strip()
  if not token_path:
    gcloudError()
  token_file = f'{token_path}/access_tokens.db'
  try:
    credentials._rapt_token = runSqliteQuery(token_file,
            f'SELECT rapt_token FROM access_tokens WHERE account_id = "{admin_email}"')
  except TypeError:
    gcloudError()
  if not credentials._rapt_token:
    systemErrorExit(SYSTEM_ERROR_RC,
            'Failed to retrieve reauth token from gcloud. You may need to wait until gcloud is also prompted for reauth.')

def getClientCredentials(forceRefresh=False, forceWrite=False, filename=None, api=None, noDASA=False, refreshOnly=False, noScopes=False):
  """Gets OAuth2 credentials which are guaranteed to be fresh and valid.
     Locks during read and possible write so that only one process will
     attempt refresh/write when running in parallel. """
  lock = FileLock(GM.Globals[GM.OAUTH2_TXT_LOCK])
  with lock:
    writeCreds, credentials = getOauth2TxtCredentials(api=api, noDASA=noDASA, refreshOnly=refreshOnly, noScopes=noScopes)
    if not credentials:
      invalidOauth2TxtExit('')
    if credentials.expired or forceRefresh:
      triesLimit = 3
      for n in range(1, triesLimit+1):
        try:
          credentials.refresh(transportCreateRequest())
          if writeCreds or forceWrite:
            writeClientCredentials(credentials, filename or GC.Values[GC.OAUTH2_TXT])
          break
        except (httplib2.HttpLib2Error, google.auth.exceptions.TransportError, RuntimeError) as e:
          if n != triesLimit:
            waitOnFailure(n, triesLimit, NETWORK_ERROR_RC, str(e))
            continue
          handleServerError(e)
        except google.auth.exceptions.RefreshError as e:
          if isinstance(e.args, tuple):
            e = e.args[0]
          if 'Reauthentication is needed' in str(e):
            if GC.Values[GC.ENABLE_GCLOUD_REAUTH]:
              refreshCredentialsWithReauth(credentials)
              continue
            e = Msg.REAUTHENTICATION_IS_NEEDED
          handleOAuthTokenError(e, False)
  return credentials

def waitOnFailure(n, triesLimit, error_code, error_message):
  delta = min(2 ** n, 60)+float(random.randint(1, 1000))/1000
  if n > 3:
    writeStderr(f'Temporary error: {error_code} - {error_message}, Backing off: {int(delta)} seconds, Retry: {n}/{triesLimit}\n')
    flushStderr()
  time.sleep(delta)
  if GC.Values[GC.SHOW_API_CALLS_RETRY_DATA]:
    incrAPICallsRetryData(error_message, delta)

def clearServiceCache(service):
  if hasattr(service._http, 'http') and hasattr(service._http.http, 'cache'):
    if service._http.http.cache is None:
      return False
    service._http.http.cache = None
    return True
  if hasattr(service._http, 'cache'):
    if service._http.cache is None:
      return False
    service._http.cache = None
    return True
  return False

DISCOVERY_URIS = [googleapiclient.discovery.V1_DISCOVERY_URI, googleapiclient.discovery.V2_DISCOVERY_URI]

# Used for API.CLOUDRESOURCEMANAGER, API.SERVICEUSAGE, API.IAM
def getAPIService(api, httpObj):
  api, version, v2discovery = API.getVersion(api)
  return googleapiclient.discovery.build(api, version, http=httpObj, cache_discovery=False,
                                         discoveryServiceUrl=DISCOVERY_URIS[v2discovery], static_discovery=False)

def getService(api, httpObj):
### Drive v3beta
#  mapDriveURL = api == API.DRIVE3 and GC.Values[GC.DRIVE_V3_BETA]
  hasLocalJSON = API.hasLocalJSON(api)
  api, version, v2discovery = API.getVersion(api)
  if api in GM.Globals[GM.CURRENT_API_SERVICES] and version in GM.Globals[GM.CURRENT_API_SERVICES][api]:
    service = googleapiclient.discovery.build_from_document(GM.Globals[GM.CURRENT_API_SERVICES][api][version], http=httpObj)
    if GM.Globals[GM.CACHE_DISCOVERY_ONLY]:
      clearServiceCache(service)
    return service
  if not hasLocalJSON:
    triesLimit = 3
    for n in range(1, triesLimit+1):
      try:
        service = googleapiclient.discovery.build(api, version, http=httpObj, cache_discovery=False,
                                                  discoveryServiceUrl=DISCOVERY_URIS[v2discovery], static_discovery=False)
        GM.Globals[GM.CURRENT_API_SERVICES].setdefault(api, {})
        GM.Globals[GM.CURRENT_API_SERVICES][api][version] = service._rootDesc.copy()
### Drive v3beta
#        if mapDriveURL:
#          setattr(service, '_baseUrl', getattr(service, '_baseUrl').replace('/v3/', '/v3beta/'))
        if GM.Globals[GM.CACHE_DISCOVERY_ONLY]:
          clearServiceCache(service)
        return service
      except googleapiclient.errors.UnknownApiNameOrVersion as e:
        systemErrorExit(GOOGLE_API_ERROR_RC, Msg.UNKNOWN_API_OR_VERSION.format(str(e), __author__))
      except (googleapiclient.errors.InvalidJsonError, KeyError, ValueError) as e:
        if n != triesLimit:
          waitOnFailure(n, triesLimit, INVALID_JSON_RC, str(e))
          continue
        systemErrorExit(INVALID_JSON_RC, str(e))
      except (http_client.ResponseNotReady, OSError, googleapiclient.errors.HttpError) as e:
        errMsg = f'Connection error: {str(e) or repr(e)}'
        if n != triesLimit:
          waitOnFailure(n, triesLimit, SOCKET_ERROR_RC, errMsg)
          continue
        systemErrorExit(SOCKET_ERROR_RC, errMsg)
      except (httplib2.HttpLib2Error, google.auth.exceptions.TransportError, RuntimeError) as e:
        if n != triesLimit:
          httpObj.connections = {}
          waitOnFailure(n, triesLimit, NETWORK_ERROR_RC, str(e))
          continue
        handleServerError(e)
  disc_file, discovery = readDiscoveryFile(f'{api}-{version}')
  try:
    service = googleapiclient.discovery.build_from_document(discovery, http=httpObj)
    GM.Globals[GM.CURRENT_API_SERVICES].setdefault(api, {})
    GM.Globals[GM.CURRENT_API_SERVICES][api][version] = service._rootDesc.copy()
    if GM.Globals[GM.CACHE_DISCOVERY_ONLY]:
      clearServiceCache(service)
    return service
  except (googleapiclient.errors.InvalidJsonError, KeyError, ValueError) as e:
    invalidDiscoveryJsonExit(disc_file, str(e))
  except IOError as e:
    systemErrorExit(FILE_ERROR_RC, str(e))

def defaultSvcAcctScopes():
  scopesList = API.getSvcAcctScopesList(GC.Values[GC.USER_SERVICE_ACCOUNT_ACCESS_ONLY], False)
  saScopes = {}
  for scope in scopesList:
    saScopes.setdefault(scope['api'], [])
    saScopes[scope['api']].append(scope['scope'])
  saScopes[API.DRIVEACTIVITY].append(API.DRIVE_SCOPE)
  saScopes[API.DRIVE2] = saScopes[API.DRIVE3]
  saScopes[API.DRIVETD] = saScopes[API.DRIVE3]
  saScopes[API.SHEETSTD] = saScopes[API.SHEETS]
  return saScopes

def _getSvcAcctData():
  if not GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]:
    jsonData = readFile(GC.Values[GC.OAUTH2SERVICE_JSON], continueOnError=True, displayError=True)
    if not jsonData:
      invalidOauth2serviceJsonExit(Msg.NO_DATA)
    try:
      GM.Globals[GM.OAUTH2SERVICE_JSON_DATA] = json.loads(jsonData)
    except (IndexError, KeyError, SyntaxError, TypeError, ValueError) as e:
      invalidOauth2serviceJsonExit(str(e))
    if not GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]:
      systemErrorExit(OAUTH2SERVICE_JSON_REQUIRED_RC, Msg.NO_SVCACCT_ACCESS_ALLOWED)
    requiredFields = ['client_email', 'client_id', 'project_id', 'token_uri']
    key_type = GM.Globals[GM.OAUTH2SERVICE_JSON_DATA].get('key_type', 'default')
    if key_type == 'default':
      requiredFields.extend(['private_key', 'private_key_id'])
    missingFields = []
    for field in requiredFields:
      if field not in GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]:
        missingFields.append(field)
    if missingFields:
      invalidOauth2serviceJsonExit(Msg.MISSING_FIELDS.format(','.join(missingFields)))
# Some old oauth2service.json files have: 'https://accounts.google.com/o/oauth2/auth' which no longer works
    if GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['token_uri'] == 'https://accounts.google.com/o/oauth2/auth':
      GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['token_uri'] = API.GOOGLE_OAUTH2_TOKEN_ENDPOINT
    if API.OAUTH2SA_SCOPES not in GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]:
      GM.Globals[GM.SVCACCT_SCOPES_DEFINED] = False
      GM.Globals[GM.SVCACCT_SCOPES] = defaultSvcAcctScopes()
    else:
      GM.Globals[GM.SVCACCT_SCOPES_DEFINED] = True
      GM.Globals[GM.SVCACCT_SCOPES] = GM.Globals[GM.OAUTH2SERVICE_JSON_DATA].pop(API.OAUTH2SA_SCOPES)

def getSvcAcctCredentials(scopesOrAPI, userEmail, softErrors=False, forceOauth=False):
  _getSvcAcctData()
  if isinstance(scopesOrAPI, str):
    GM.Globals[GM.CURRENT_SVCACCT_API] = scopesOrAPI
    if scopesOrAPI not in API.JWT_APIS:
      GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES] = GM.Globals[GM.SVCACCT_SCOPES].get(scopesOrAPI, [])
    else:
      GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES] = API.JWT_APIS[scopesOrAPI]
    if scopesOrAPI != API.CHAT_EVENTS and not GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES]:
      if softErrors:
        return None
      SvcAcctAPIAccessDeniedExit()
    if scopesOrAPI in {API.PEOPLE, API.PEOPLE_DIRECTORY, API.PEOPLE_OTHERCONTACTS}:
      GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES].append(API.USERINFO_PROFILE_SCOPE)
      if scopesOrAPI in {API.PEOPLE_OTHERCONTACTS}:
        GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES].append(API.PEOPLE_SCOPE)
    elif scopesOrAPI == API.CHAT_EVENTS:
      for chatAPI in [API.CHAT_SPACES, API.CHAT_MEMBERSHIPS, API.CHAT_MESSAGES]:
        GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES].extend(GM.Globals[GM.SVCACCT_SCOPES].get(chatAPI, []))
  else:
    GM.Globals[GM.CURRENT_SVCACCT_API] = ''
    GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES] = scopesOrAPI
  key_type = GM.Globals[GM.OAUTH2SERVICE_JSON_DATA].get('key_type', 'default')
  if not GM.Globals[GM.CURRENT_SVCACCT_API] or scopesOrAPI not in API.JWT_APIS or forceOauth:
    try:
      if key_type == 'default':
        credentials = google.oauth2.service_account.Credentials.from_service_account_info(GM.Globals[GM.OAUTH2SERVICE_JSON_DATA])
      elif key_type == 'yubikey':
        yksigner = yubikey.YubiKey(GM.Globals[GM.OAUTH2SERVICE_JSON_DATA])
        credentials = google.oauth2.service_account.Credentials._from_signer_and_info(yksigner,
                                                                                      GM.Globals[GM.OAUTH2SERVICE_JSON_DATA])
      elif key_type == 'signjwt':
        sjsigner = signjwtSignJwt(GM.Globals[GM.OAUTH2SERVICE_JSON_DATA])
        credentials = signjwtCredentials._from_signer_and_info(sjsigner.sign,
                                                               GM.Globals[GM.OAUTH2SERVICE_JSON_DATA])
    except (ValueError, IndexError, KeyError) as e:
      if softErrors:
        return None
      invalidOauth2serviceJsonExit(str(e))
    credentials = credentials.with_scopes(GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES])
  else:
    audience = f'https://{scopesOrAPI}.googleapis.com/'
    try:
      if key_type == 'default':
        credentials = JWTCredentials.from_service_account_info(GM.Globals[GM.OAUTH2SERVICE_JSON_DATA],
                                                               audience=audience)
      elif key_type == 'yubikey':
        yksigner = yubikey.YubiKey(GM.Globals[GM.OAUTH2SERVICE_JSON_DATA])
        credentials = JWTCredentials._from_signer_and_info(yksigner,
                                                           GM.Globals[GM.OAUTH2SERVICE_JSON_DATA],
                                                           audience=audience)
      elif key_type == 'signjwt':
        sjsigner = signjwtSignJwt(GM.Globals[GM.OAUTH2SERVICE_JSON_DATA])
        credentials = signjwtJWTCredentials._from_signer_and_info(sjsigner,
                                                                  GM.Globals[GM.OAUTH2SERVICE_JSON_DATA],
                                                                  audience=audience)
      credentials.project_id = GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['project_id']
    except (ValueError, IndexError, KeyError) as e:
      if softErrors:
        return None
      invalidOauth2serviceJsonExit(str(e))
  GM.Globals[GM.CURRENT_SVCACCT_USER] = userEmail
  if userEmail:
    credentials = credentials.with_subject(userEmail)
  GM.Globals[GM.ADMIN] = GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['client_email']
  GM.Globals[GM.OAUTH2SERVICE_CLIENT_ID] = GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['client_id']
  return credentials

def getGDataOAuthToken(gdataObj, credentials=None):
  if not credentials:
    credentials = getClientCredentials(refreshOnly=True)
  try:
    credentials.refresh(transportCreateRequest())
  except (httplib2.HttpLib2Error, google.auth.exceptions.TransportError, RuntimeError) as e:
    handleServerError(e)
  except google.auth.exceptions.RefreshError as e:
    if isinstance(e.args, tuple):
      e = e.args[0]
    handleOAuthTokenError(e, False)
  gdataObj.additional_headers['Authorization'] = f'Bearer {credentials.token}'
  if not GC.Values[GC.DOMAIN]:
    GC.Values[GC.DOMAIN] = GM.Globals[GM.DECODED_ID_TOKEN].get('hd', 'UNKNOWN').lower()
  if not GC.Values[GC.CUSTOMER_ID]:
    GC.Values[GC.CUSTOMER_ID] = GC.MY_CUSTOMER
  GM.Globals[GM.ADMIN] = GM.Globals[GM.DECODED_ID_TOKEN].get('email', 'UNKNOWN').lower()
  GM.Globals[GM.OAUTH2_CLIENT_ID] = credentials.client_id
  gdataObj.domain = GC.Values[GC.DOMAIN]
  gdataObj.source = GAM_USER_AGENT
  return True

def checkGDataError(e, service):
  error = e.args
  reason = error[0].get('reason', '')
  body = error[0].get('body', '').decode(UTF8)
  # First check for errors that need special handling
  if reason in ['Token invalid - Invalid token: Stateless token expired', 'Token invalid - Invalid token: Token not found', 'gone']:
    keep_domain = service.domain
    getGDataOAuthToken(service)
    service.domain = keep_domain
    return (GDATA.TOKEN_EXPIRED, reason)
  error_code = getattr(e, 'error_code', 600)
  if GC.Values[GC.DEBUG_LEVEL] > 0:
    writeStdout(f'{ERROR_PREFIX} {error_code}: {reason}, {body}\n')
  if error_code == 600:
    if (body.startswith('Quota exceeded for the current request') or
        body.startswith('Quota exceeded for quota metric') or
        body.startswith('Request rate higher than configured')):
      return (GDATA.QUOTA_EXCEEDED, body)
    if (body.startswith('Photo delete failed') or
        body.startswith('Upload photo failed') or
        body.startswith('Photo query failed')):
      return (GDATA.NOT_FOUND, body)
    if body.startswith(GDATA.API_DEPRECATED_MSG):
      return (GDATA.API_DEPRECATED, body)
    if reason == 'Too Many Requests':
      return (GDATA.QUOTA_EXCEEDED, reason)
    if reason == 'Bad Gateway':
      return (GDATA.BAD_GATEWAY, reason)
    if reason == 'Gateway Timeout':
      return (GDATA.GATEWAY_TIMEOUT, reason)
    if reason == 'Service Unavailable':
      return (GDATA.SERVICE_UNAVAILABLE, reason)
    if reason == 'Service <jotspot> disabled by G Suite admin.':
      return (GDATA.FORBIDDEN, reason)
    if reason == 'Internal Server Error':
      return (GDATA.INTERNAL_SERVER_ERROR, reason)
    if reason == 'Token invalid - Invalid token: Token disabled, revoked, or expired.':
      return (GDATA.TOKEN_INVALID, 'Token disabled, revoked, or expired. Please delete and re-create oauth.txt')
    if reason == 'Token invalid - AuthSub token has wrong scope':
      return (GDATA.INSUFFICIENT_PERMISSIONS, reason)
    if reason.startswith('Only administrators can request entries belonging to'):
      return (GDATA.INSUFFICIENT_PERMISSIONS, reason)
    if reason == 'You are not authorized to access this API':
      return (GDATA.INSUFFICIENT_PERMISSIONS, reason)
    if reason == 'Invalid domain.':
      return (GDATA.INVALID_DOMAIN, reason)
    if reason.startswith('You are not authorized to perform operations on the domain'):
      return (GDATA.INVALID_DOMAIN, reason)
    if reason == 'Bad Request':
      if 'already exists' in body:
        return (GDATA.ENTITY_EXISTS, Msg.DUPLICATE)
      return (GDATA.BAD_REQUEST, body)
    if reason == 'Forbidden':
      return (GDATA.FORBIDDEN, body)
    if reason == 'Not Found':
      return (GDATA.NOT_FOUND, Msg.DOES_NOT_EXIST)
    if reason == 'Not Implemented':
      return (GDATA.NOT_IMPLEMENTED, body)
    if reason == 'Precondition Failed':
      return (GDATA.PRECONDITION_FAILED, reason)
  elif error_code == 602:
    if body.startswith(GDATA.API_DEPRECATED_MSG):
      return (GDATA.API_DEPRECATED, body)
    if reason == 'Bad Request':
      return (GDATA.BAD_REQUEST, body)
  elif error_code == 610:
    if reason == 'Service <jotspot> disabled by G Suite admin.':
      return (GDATA.FORBIDDEN, reason)

  # We got a "normal" error, define the mapping below
  error_code_map = {
    1000: reason,
    1001: reason,
    1002: 'Unauthorized and forbidden',
    1100: 'User deleted recently',
    1200: 'Domain user limit exceeded',
    1201: 'Domain alias limit exceeded',
    1202: 'Domain suspended',
    1203: 'Domain feature unavailable',
    1300: f'Entity {getattr(e, "invalidInput", "<unknown>")} exists',
    1301: f'Entity {getattr(e, "invalidInput", "<unknown>")} Does Not Exist',
    1302: 'Entity Name Is Reserved',
    1303: f'Entity {getattr(e, "invalidInput", "<unknown>")} name not valid',
    1306: f'{getattr(e, "invalidInput", "<unknown>")} has members. Cannot delete.',
    1317: f'Invalid input {getattr(e, "invalidInput", "<unknown>")}, reason {getattr(e, "reason", "<unknown>")}',
    1400: 'Invalid Given Name',
    1401: 'Invalid Family Name',
    1402: 'Invalid Password',
    1403: 'Invalid Username',
    1404: 'Invalid Hash Function Name',
    1405: 'Invalid Hash Digest Length',
    1406: 'Invalid Email Address',
    1407: 'Invalid Query Parameter Value',
    1408: 'Invalid SSO Signing Key',
    1409: 'Invalid Encryption Public Key',
    1410: 'Feature Unavailable For User',
    1411: 'Invalid Encryption Public Key Format',
    1500: 'Too Many Recipients On Email List',
    1501: 'Too Many Aliases For User',
    1502: 'Too Many Delegates For User',
    1601: 'Duplicate Destinations',
    1602: 'Too Many Destinations',
    1603: 'Invalid Route Address',
    1700: 'Group Cannot Contain Cycle',
    1800: 'Group Cannot Contain Cycle',
    1801: f'Invalid value {getattr(e, "invalidInput", "<unknown>")}',
  }
  return (error_code, error_code_map.get(error_code, f'Unknown Error: {str(e)}'))

def callGData(service, function,
              bailOnInternalServerError=False, softErrors=False,
              throwErrors=None, retryErrors=None, triesLimit=0,
              **kwargs):
  if throwErrors is None:
    throwErrors = []
  if retryErrors is None:
    retryErrors = []
  if triesLimit == 0:
    triesLimit = GC.Values[GC.API_CALLS_TRIES_LIMIT]
  allRetryErrors = GDATA.NON_TERMINATING_ERRORS+retryErrors
  method = getattr(service, function)
  if GC.Values[GC.API_CALLS_RATE_CHECK]:
    checkAPICallsRate()
  for n in range(1, triesLimit+1):
    try:
      return method(**kwargs)
    except (gdata.service.RequestError, gdata.apps.service.AppsForYourDomainException) as e:
      error_code, error_message = checkGDataError(e, service)
      if (n != triesLimit) and (error_code in allRetryErrors):
        if (error_code == GDATA.INTERNAL_SERVER_ERROR and
            bailOnInternalServerError and n == GC.Values[GC.BAIL_ON_INTERNAL_ERROR_TRIES]):
          raise GDATA.ERROR_CODE_EXCEPTION_MAP[error_code](error_message)
        waitOnFailure(n, triesLimit, error_code, error_message)
        continue
      if error_code in throwErrors:
        if error_code in GDATA.ERROR_CODE_EXCEPTION_MAP:
          raise GDATA.ERROR_CODE_EXCEPTION_MAP[error_code](error_message)
        raise
      if softErrors:
        stderrErrorMsg(f'{error_code} - {error_message}{["", ": Giving up."][n > 1]}')
        return None
      if error_code == GDATA.INSUFFICIENT_PERMISSIONS:
        APIAccessDeniedExit()
      systemErrorExit(GOOGLE_API_ERROR_RC, f'{error_code} - {error_message}')
    except (httplib2.HttpLib2Error, google.auth.exceptions.TransportError, RuntimeError) as e:
      if n != triesLimit:
        waitOnFailure(n, triesLimit, NETWORK_ERROR_RC, str(e))
        continue
      handleServerError(e)
    except google.auth.exceptions.RefreshError as e:
      if isinstance(e.args, tuple):
        e = e.args[0]
      handleOAuthTokenError(e, GDATA.SERVICE_NOT_APPLICABLE in throwErrors)
      raise GDATA.ERROR_CODE_EXCEPTION_MAP[GDATA.SERVICE_NOT_APPLICABLE](str(e))
    except (http_client.ResponseNotReady, OSError) as e:
      errMsg = f'Connection error: {str(e) or repr(e)}'
      if n != triesLimit:
        waitOnFailure(n, triesLimit, SOCKET_ERROR_RC, errMsg)
        continue
      if softErrors:
        writeStderr(f'\n{ERROR_PREFIX}{errMsg} - Giving up.\n')
        return None
      systemErrorExit(SOCKET_ERROR_RC, errMsg)

def writeGotMessage(msg):
  if GC.Values[GC.SHOW_GETTINGS_GOT_NL]:
    writeStderr(msg)
  else:
    writeStderr('\r')
    msgLen = len(msg)
    if msgLen < GM.Globals[GM.LAST_GOT_MSG_LEN]:
      writeStderr(msg+' '*(GM.Globals[GM.LAST_GOT_MSG_LEN]-msgLen))
    else:
      writeStderr(msg)
    GM.Globals[GM.LAST_GOT_MSG_LEN] = msgLen
  flushStderr()

def callGDataPages(service, function,
                   pageMessage=None,
                   softErrors=False, throwErrors=None, retryErrors=None,
                   uri=None,
                   **kwargs):
  if throwErrors is None:
    throwErrors = []
  if retryErrors is None:
    retryErrors = []
  nextLink = None
  allResults = []
  totalItems = 0
  while True:
    this_page = callGData(service, function,
                          softErrors=softErrors, throwErrors=throwErrors, retryErrors=retryErrors,
                          uri=uri,
                          **kwargs)
    if this_page:
      nextLink = this_page.GetNextLink()
      pageItems = len(this_page.entry)
      if pageItems == 0:
        nextLink = None
      totalItems += pageItems
      allResults.extend(this_page.entry)
    else:
      nextLink = None
      pageItems = 0
    if pageMessage:
      show_message = pageMessage.replace(TOTAL_ITEMS_MARKER, str(totalItems))
      writeGotMessage(show_message.format(Ent.ChooseGetting(totalItems)))
    if nextLink is None:
      if pageMessage and (pageMessage[-1] != '\n'):
        writeStderr('\r\n')
        flushStderr()
      return allResults
    uri = nextLink.href
    if 'url_params' in kwargs:
      kwargs['url_params'].pop('start-index', None)

def checkGAPIError(e, softErrors=False, retryOnHttpError=False, mapNotFound=True):
  def makeErrorDict(code, reason, message):
    return {'error': {'code': code, 'errors': [{'reason': reason, 'message': message}]}}

  try:
    error = json.loads(e.content.decode(UTF8))
    if GC.Values[GC.DEBUG_LEVEL] > 0:
      writeStdout(f'{ERROR_PREFIX} JSON: {str(error)}\n')
  except (IndexError, KeyError, SyntaxError, TypeError, ValueError):
    eContent = e.content.decode(UTF8) if isinstance(e.content, bytes) else e.content
    lContent = eContent.lower()
    if GC.Values[GC.DEBUG_LEVEL] > 0:
      writeStdout(f'{ERROR_PREFIX} HTTP: {str(eContent)}\n')
    if eContent[0:15] != '<!DOCTYPE html>':
      if (e.resp['status'] == '403') and (lContent.startswith('request rate higher than configured')):
        return (e.resp['status'], GAPI.QUOTA_EXCEEDED, eContent)
      if (e.resp['status'] == '429') and (lContent.startswith('quota exceeded for quota metric')):
        return (e.resp['status'], GAPI.QUOTA_EXCEEDED, eContent)
      if (e.resp['status'] == '502') and ('bad gateway' in lContent):
        return (e.resp['status'], GAPI.BAD_GATEWAY, eContent)
      if (e.resp['status'] == '503') and (lContent.startswith('quota exceeded for the current request')):
        return (e.resp['status'], GAPI.QUOTA_EXCEEDED, eContent)
      if (e.resp['status'] == '504') and ('gateway timeout' in lContent):
        return (e.resp['status'], GAPI.GATEWAY_TIMEOUT, eContent)
    else:
      tg = HTML_TITLE_PATTERN.match(lContent)
      lContent = tg.group(1) if tg else 'bad request'
    if (e.resp['status'] == '403') and ('invalid domain.' in lContent):
      error = makeErrorDict(403, GAPI.NOT_FOUND, 'Domain not found')
    elif (e.resp['status'] == '403') and ('domain cannot use apis.' in lContent):
      error = makeErrorDict(403, GAPI.DOMAIN_CANNOT_USE_APIS, 'Domain cannot use apis')
    elif (e.resp['status'] == '400') and ('invalidssosigningkey' in lContent):
      error = makeErrorDict(400, GAPI.INVALID, 'InvalidSsoSigningKey')
    elif (e.resp['status'] == '400') and ('unknownerror' in lContent):
      error = makeErrorDict(400, GAPI.INVALID, 'UnknownError')
    elif (e.resp['status'] == '400') and ('featureunavailableforuser' in lContent):
      error = makeErrorDict(400, GAPI.SERVICE_NOT_AVAILABLE, 'Feature Unavailable For User')
    elif (e.resp['status'] == '400') and ('entitydoesnotexist' in lContent):
      error = makeErrorDict(400, GAPI.NOT_FOUND, 'Entity Does Not Exist')
    elif (e.resp['status'] == '400') and ('entitynamenotvalid' in lContent):
      error = makeErrorDict(400, GAPI.INVALID_INPUT, 'Entity Name Not Valid')
    elif (e.resp['status'] == '400') and ('failed to parse Content-Range header' in lContent):
      error = makeErrorDict(400, GAPI.BAD_REQUEST, 'Failed to parse Content-Range header')
    elif (e.resp['status'] == '400') and ('request contains an invalid argument' in lContent):
      error = makeErrorDict(400, GAPI.INVALID_ARGUMENT, 'Request contains an invalid argument')
    elif (e.resp['status'] == '404') and ('not found' in lContent):
      error = makeErrorDict(404, GAPI.NOT_FOUND, lContent)
    elif (e.resp['status'] == '404') and ('bad request' in lContent):
      error = makeErrorDict(404, GAPI.BAD_REQUEST, lContent)
    elif retryOnHttpError:
      return (-1, None, eContent)
    elif softErrors:
      stderrErrorMsg(eContent)
      return (0, None, None)
    else:
      systemErrorExit(HTTP_ERROR_RC, eContent)
  if 'error' in error:
    http_status = error['error']['code']
    reason = ''
    if 'errors' in error['error'] and 'message' in error['error']['errors'][0]:
      message = error['error']['errors'][0]['message']
      if 'reason' in error['error']['errors'][0]:
        reason = error['error']['errors'][0]['reason']
    elif 'errors' in error['error'] and 'Unknown Error' in error['error']['message'] and 'reason' in error['error']['errors'][0]:
      message = error['error']['errors'][0]['reason']
    else:
      message = error['error']['message']
    status = error['error'].get('status', '')
    lmessage = message.lower() if message is not None else ''
    if http_status == 500:
      if not lmessage or status == 'UNKNOWN':
        if not lmessage:
          message = Msg.UNKNOWN
        error = makeErrorDict(http_status, GAPI.UNKNOWN_ERROR, message)
      elif 'backend error' in lmessage:
        error = makeErrorDict(http_status, GAPI.BACKEND_ERROR, message)
      elif 'internal error encountered' in lmessage:
        error = makeErrorDict(http_status, GAPI.INTERNAL_ERROR, message)
      elif 'role assignment exists: roleassignment' in lmessage:
        error = makeErrorDict(http_status, GAPI.DUPLICATE, message)
      elif 'role assignment exists: roleid' in lmessage:
        error = makeErrorDict(http_status, GAPI.DUPLICATE, message)
      elif 'operation not supported' in lmessage:
        error = makeErrorDict(http_status, GAPI.OPERATION_NOT_SUPPORTED, message)
      elif 'failed status in update settings response' in lmessage:
        error = makeErrorDict(http_status, GAPI.INVALID_INPUT, message)
      elif 'cannot delete a field in use.resource.fields' in lmessage:
        error = makeErrorDict(http_status, GAPI.FIELD_IN_USE, message)
      elif status == 'INTERNAL':
        error = makeErrorDict(http_status, GAPI.INTERNAL_ERROR, message)
    elif http_status == 502:
      if 'bad gateway' in lmessage:
        error = makeErrorDict(http_status, GAPI.BAD_GATEWAY, message)
    elif http_status == 503:
      if message.startswith('quota exceeded for the current request'):
        error = makeErrorDict(http_status, GAPI.QUOTA_EXCEEDED, message)
      elif status == 'UNAVAILABLE' or 'the service is currently unavailable' in lmessage:
        error = makeErrorDict(http_status, GAPI.SERVICE_NOT_AVAILABLE, message)
    elif http_status == 504:
      if 'gateway timeout' in lmessage:
        error = makeErrorDict(http_status, GAPI.GATEWAY_TIMEOUT, message)
    elif http_status == 400:
      if '@attachmentnotvisible' in lmessage:
        error = makeErrorDict(http_status, GAPI.BAD_REQUEST, message)
      elif status == 'INVALID_ARGUMENT':
        error = makeErrorDict(http_status, GAPI.INVALID_ARGUMENT, message)
      elif status == 'FAILED_PRECONDITION' or 'precondition check failed' in lmessage:
        error = makeErrorDict(http_status, GAPI.FAILED_PRECONDITION, message)
      elif 'does not match' in lmessage or 'invalid' in lmessage:
        error = makeErrorDict(http_status, GAPI.INVALID, message)
    elif http_status == 401:
      if 'active session is invalid' in lmessage and reason == 'authError':
#        message += ' Drive SDK API access disabled'
#        message = Msg.SERVICE_NOT_ENABLED.format('Drive')
        error = makeErrorDict(http_status, GAPI.AUTH_ERROR, message)
      elif status == 'PERMISSION_DENIED':
        error = makeErrorDict(http_status, GAPI.PERMISSION_DENIED, message)
      elif status == 'UNAUTHENTICATED':
        error = makeErrorDict(http_status, GAPI.AUTH_ERROR, message)
    elif http_status == 403:
      if 'quota exceeded for quota metric' in lmessage:
        error = makeErrorDict(http_status, GAPI.QUOTA_EXCEEDED, message)
      elif 'the authenticated user cannot access this service' in lmessage:
        error = makeErrorDict(http_status, GAPI.SERVICE_NOT_AVAILABLE, message)
      elif status == 'PERMISSION_DENIED' or 'the caller does not have permission' in lmessage or 'permission iam.serviceaccountkeys' in lmessage:
        error = makeErrorDict(http_status, GAPI.PERMISSION_DENIED, message)
    elif http_status == 404:
      if status == 'NOT_FOUND' or 'requested entity was not found' in lmessage or 'does not exist' in lmessage:
        error = makeErrorDict(http_status, GAPI.NOT_FOUND, message)
    elif http_status == 409:
      if status == 'ALREADY_EXISTS' or 'requested entity already exists' in lmessage:
        error = makeErrorDict(http_status, GAPI.ALREADY_EXISTS, message)
      elif status == 'ABORTED' or 'the operation was aborted' in lmessage:
        error = makeErrorDict(http_status, GAPI.ABORTED, message)
    elif http_status == 412:
      if 'insufficient archived user licenses' in lmessage:
        error = makeErrorDict(http_status, GAPI.INSUFFICIENT_ARCHIVED_USER_LICENSES, message)
    elif http_status == 413:
      if 'request too large' in lmessage:
        error = makeErrorDict(http_status, GAPI.UPLOAD_TOO_LARGE, message)
    elif http_status == 429:
      if status == 'RESOURCE_EXHAUSTED' or 'quota exceeded' in lmessage or 'insufficient quota' in lmessage:
        error = makeErrorDict(http_status, GAPI.QUOTA_EXCEEDED, message)
  else:
    if 'error_description' in error:
      if error['error_description'] == 'Invalid Value':
        message = error['error_description']
        http_status = 400
        error = makeErrorDict(http_status, GAPI.INVALID, message)
      else:
        systemErrorExit(GOOGLE_API_ERROR_RC, str(error))
    else:
      systemErrorExit(GOOGLE_API_ERROR_RC, str(error))
  try:
    reason = error['error']['errors'][0]['reason']
    for messageItem in GAPI.REASON_MESSAGE_MAP.get(reason, []):
      if messageItem[0] in message:
        if reason in [GAPI.NOT_FOUND, GAPI.RESOURCE_NOT_FOUND] and mapNotFound:
          message = Msg.DOES_NOT_EXIST
        reason = messageItem[1]
        break
    if reason == GAPI.INVALID_SHARING_REQUEST:
      loc = message.find('User message: ')
      if loc != -1:
        message = message[loc+15:]
    else:
      loc = message.find('User message: ""')
      if loc != -1:
        message = message[:loc+14]+f'"{reason}"'
  except KeyError:
    reason = f'{http_status}'
  return (http_status, reason, message)

def callGAPI(service, function,
             bailOnInternalError=False, bailOnTransientError=False, bailOnInvalidError=False,
             softErrors=False, mapNotFound=True,
             throwReasons=None, retryReasons=None, triesLimit=0,
             **kwargs):
  if throwReasons is None:
    throwReasons = []
  if retryReasons is None:
    retryReasons = []
  if triesLimit == 0:
    triesLimit = GC.Values[GC.API_CALLS_TRIES_LIMIT]
  allRetryReasons = GAPI.DEFAULT_RETRY_REASONS+retryReasons
  method = getattr(service, function)
  svcparms = dict(list(kwargs.items())+GM.Globals[GM.EXTRA_ARGS_LIST])
  if GC.Values[GC.API_CALLS_RATE_CHECK]:
    checkAPICallsRate()
  for n in range(1, triesLimit+1):
    try:
      return method(**svcparms).execute()
    except googleapiclient.errors.HttpError as e:
      http_status, reason, message = checkGAPIError(e, softErrors=softErrors, retryOnHttpError=n < 3, mapNotFound=mapNotFound)
      if http_status == -1:
        # The error detail indicated that we should retry this request
        # We'll refresh credentials and make another pass
        try:
#          service._http.credentials.refresh(getHttpObj())
          service._http.credentials.refresh(transportCreateRequest())
        except TypeError:
          systemErrorExit(HTTP_ERROR_RC, message)
        continue
      if http_status == 0:
        return None
      if (n != triesLimit) and ((reason in allRetryReasons) or
                             (GC.Values[GC.RETRY_API_SERVICE_NOT_AVAILABLE] and (reason == GAPI.SERVICE_NOT_AVAILABLE))):
        if (reason in [GAPI.INTERNAL_ERROR, GAPI.BACKEND_ERROR] and
            bailOnInternalError and n == GC.Values[GC.BAIL_ON_INTERNAL_ERROR_TRIES]):
          raise GAPI.REASON_EXCEPTION_MAP[reason](message)
        if (reason in [GAPI.INVALID] and
            bailOnInvalidError and n == GC.Values[GC.BAIL_ON_INTERNAL_ERROR_TRIES]):
          raise GAPI.REASON_EXCEPTION_MAP[reason](message)
        waitOnFailure(n, triesLimit, reason, message)
        if reason == GAPI.TRANSIENT_ERROR and bailOnTransientError:
          raise GAPI.REASON_EXCEPTION_MAP[reason](message)
        continue
      if reason in throwReasons:
        if reason in GAPI.REASON_EXCEPTION_MAP:
          raise GAPI.REASON_EXCEPTION_MAP[reason](message)
        raise e
      if softErrors:
        stderrErrorMsg(f'{http_status}: {reason} - {message}{["", ": Giving up."][n > 1]}')
        return None
      if reason == GAPI.INSUFFICIENT_PERMISSIONS:
        APIAccessDeniedExit()
      systemErrorExit(HTTP_ERROR_RC, formatHTTPError(http_status, reason, message))
    except googleapiclient.errors.MediaUploadSizeError as e:
      raise e
    except (httplib2.HttpLib2Error, google.auth.exceptions.TransportError, RuntimeError) as e:
      if n != triesLimit:
        service._http.connections = {}
        waitOnFailure(n, triesLimit, NETWORK_ERROR_RC, str(e))
        continue
      handleServerError(e)
    except google.auth.exceptions.RefreshError as e:
      if isinstance(e.args, tuple):
        e = e.args[0]
      handleOAuthTokenError(e, GAPI.SERVICE_NOT_AVAILABLE in throwReasons)
      raise GAPI.REASON_EXCEPTION_MAP[GAPI.SERVICE_NOT_AVAILABLE](str(e))
    except (http_client.ResponseNotReady, OSError) as e:
      errMsg = f'Connection error: {str(e) or repr(e)}'
      if n != triesLimit:
        waitOnFailure(n, triesLimit, SOCKET_ERROR_RC, errMsg)
        continue
      if softErrors:
        writeStderr(f'\n{ERROR_PREFIX}{errMsg} - Giving up.\n')
        return None
      systemErrorExit(SOCKET_ERROR_RC, errMsg)
    except ValueError as e:
      if clearServiceCache(service):
        continue
      systemErrorExit(GOOGLE_API_ERROR_RC, str(e))
    except TypeError as e:
      systemErrorExit(GOOGLE_API_ERROR_RC, str(e))

def _showGAPIpagesResult(results, pageItems, totalItems, pageMessage, messageAttribute, entityType):
  showMessage = pageMessage.replace(TOTAL_ITEMS_MARKER, str(totalItems))
  if pageItems:
    if messageAttribute:
      firstItem = results[0] if pageItems > 0 else {}
      lastItem = results[-1] if pageItems > 1 else firstItem
      if isinstance(messageAttribute, str):
        firstItem = str(firstItem.get(messageAttribute, ''))
        lastItem = str(lastItem.get(messageAttribute, ''))
      else:
        for attr in messageAttribute:
          firstItem = firstItem.get(attr, {})
          lastItem = lastItem.get(attr, {})
        firstItem = str(firstItem)
        lastItem = str(lastItem)
      showMessage = showMessage.replace(FIRST_ITEM_MARKER, firstItem)
      showMessage = showMessage.replace(LAST_ITEM_MARKER, lastItem)
  else:
    showMessage = showMessage.replace(FIRST_ITEM_MARKER, '')
    showMessage = showMessage.replace(LAST_ITEM_MARKER, '')
  writeGotMessage(showMessage.replace('{0}', str(Ent.Choose(entityType, totalItems))))

def _processGAPIpagesResult(results, items, allResults, totalItems, pageMessage, messageAttribute, entityType):
  if results:
    pageToken = results.get('nextPageToken')
    if items in results:
      pageItems = len(results[items])
      totalItems += pageItems
      if allResults is not None:
        allResults.extend(results[items])
    else:
      results = {items: []}
      pageItems = 0
  else:
    pageToken = None
    results = {items: []}
    pageItems = 0
  if pageMessage:
    _showGAPIpagesResult(results[items], pageItems, totalItems, pageMessage, messageAttribute, entityType)
  return (pageToken, totalItems)

def _finalizeGAPIpagesResult(pageMessage):
  if pageMessage and (pageMessage[-1] != '\n'):
    writeStderr('\r\n')
    flushStderr()

def callGAPIpages(service, function, items,
                  pageMessage=None, messageAttribute=None, maxItems=0, noFinalize=False,
                  throwReasons=None, retryReasons=None,
                  pageArgsInBody=False,
                  **kwargs):
  if throwReasons is None:
    throwReasons = []
  if retryReasons is None:
    retryReasons = []
  allResults = []
  totalItems = 0
  maxArg = ''
  if maxItems:
    maxResults = kwargs.get('maxResults', 0)
    if maxResults:
      maxArg = 'maxResults'
    else:
      maxResults = kwargs.get('pageSize', 0)
      if maxResults:
        maxArg = 'pageSize'
  if pageArgsInBody:
    kwargs.setdefault('body', {})
  entityType = Ent.Getting() if pageMessage else None
  while True:
    if maxArg and maxItems-totalItems < maxResults:
      kwargs[maxArg] = maxItems-totalItems
    results = callGAPI(service, function,
                       throwReasons=throwReasons, retryReasons=retryReasons,
                       **kwargs)
    pageToken, totalItems = _processGAPIpagesResult(results, items, allResults, totalItems, pageMessage, messageAttribute, entityType)
    if not pageToken or (maxItems and totalItems >= maxItems):
      if not noFinalize:
        _finalizeGAPIpagesResult(pageMessage)
      return allResults
    if pageArgsInBody:
      kwargs['body']['pageToken'] = pageToken
    else:
      kwargs['pageToken'] = pageToken

def yieldGAPIpages(service, function, items,
                   pageMessage=None, messageAttribute=None, maxItems=0, noFinalize=False,
                   throwReasons=None, retryReasons=None,
                   pageArgsInBody=False,
                   **kwargs):
  if throwReasons is None:
    throwReasons = []
  if retryReasons is None:
    retryReasons = []
  totalItems = 0
  maxArg = ''
  if maxItems:
    maxResults = kwargs.get('maxResults', 0)
    if maxResults:
      maxArg = 'maxResults'
    else:
      maxResults = kwargs.get('pageSize', 0)
      if maxResults:
        maxArg = 'pageSize'
  if pageArgsInBody:
    kwargs.setdefault('body', {})
  entityType = Ent.Getting() if pageMessage else None
  while True:
    if maxArg and maxItems-totalItems < maxResults:
      kwargs[maxArg] = maxItems-totalItems
    results = callGAPI(service, function,
                       throwReasons=throwReasons, retryReasons=retryReasons,
                       **kwargs)
    if results:
      pageToken = results.get('nextPageToken')
      if items in results:
        pageItems = len(results[items])
        totalItems += pageItems
      else:
        results = {items: []}
        pageItems = 0
    else:
      pageToken = None
      results = {items: []}
      pageItems = 0
    if pageMessage:
      _showGAPIpagesResult(results[items], pageItems, totalItems, pageMessage, messageAttribute, entityType)
    yield results[items]
    if not pageToken or (maxItems and totalItems >= maxItems):
      if not noFinalize:
        _finalizeGAPIpagesResult(pageMessage)
      return
    if pageArgsInBody:
      kwargs['body']['pageToken'] = pageToken
    else:
      kwargs['pageToken'] = pageToken

def callGAPIitems(service, function, items,
                  throwReasons=None, retryReasons=None,
                  **kwargs):
  if throwReasons is None:
    throwReasons = []
  if retryReasons is None:
    retryReasons = []
  results = callGAPI(service, function,
                     throwReasons=throwReasons, retryReasons=retryReasons,
                     **kwargs)
  if results:
    return results.get(items, [])
  return []

def readDiscoveryFile(api_version):
  disc_filename = f'{api_version}.json'
  disc_file = os.path.join(GM.Globals[GM.GAM_PATH], disc_filename)
  if hasattr(sys, '_MEIPASS'):
    json_string = readFile(os.path.join(sys._MEIPASS, disc_filename), continueOnError=True, displayError=True) #pylint: disable=no-member
  elif os.path.isfile(disc_file):
    json_string = readFile(disc_file, continueOnError=True, displayError=True)
  else:
    json_string = None
  if not json_string:
    invalidDiscoveryJsonExit(disc_file, Msg.NO_DATA)
  try:
    discovery = json.loads(json_string)
    return (disc_file, discovery)
  except (IndexError, KeyError, SyntaxError, TypeError, ValueError) as e:
    invalidDiscoveryJsonExit(disc_file, str(e))

def buildGAPIObject(api, credentials=None):
  if credentials is None:
    credentials = getClientCredentials(api=api, refreshOnly=True)
  httpObj = transportAuthorizedHttp(credentials, http=getHttpObj(cache=GM.Globals[GM.CACHE_DIR]))
  service = getService(api, httpObj)
  if not GC.Values[GC.ENABLE_DASA]:
    try:
      API_Scopes = set(list(service._rootDesc['auth']['oauth2']['scopes']))
    except KeyError:
      API_Scopes = set(API.VAULT_SCOPES) if api == API.VAULT else set()
    GM.Globals[GM.CURRENT_CLIENT_API] = api
    GM.Globals[GM.CURRENT_CLIENT_API_SCOPES] = API_Scopes.intersection(GM.Globals[GM.CREDENTIALS_SCOPES])
    if api not in API.SCOPELESS_APIS and not GM.Globals[GM.CURRENT_CLIENT_API_SCOPES]:
      systemErrorExit(NO_SCOPES_FOR_API_RC, Msg.NO_SCOPES_FOR_API.format(API.getAPIName(api)))
    if not GC.Values[GC.DOMAIN]:
      GC.Values[GC.DOMAIN] = GM.Globals[GM.DECODED_ID_TOKEN].get('hd', 'UNKNOWN').lower()
    if not GC.Values[GC.CUSTOMER_ID]:
      GC.Values[GC.CUSTOMER_ID] = GC.MY_CUSTOMER
    GM.Globals[GM.ADMIN] = GM.Globals[GM.DECODED_ID_TOKEN].get('email', 'UNKNOWN').lower()
    GM.Globals[GM.OAUTH2_CLIENT_ID] = credentials.client_id
  return service

def getSaUser(user):
  currentClientAPI = GM.Globals[GM.CURRENT_CLIENT_API]
  currentClientAPIScopes = GM.Globals[GM.CURRENT_CLIENT_API_SCOPES]
  userEmail = convertUIDtoEmailAddress(user) if user else None
  GM.Globals[GM.CURRENT_CLIENT_API] = currentClientAPI
  GM.Globals[GM.CURRENT_CLIENT_API_SCOPES] = currentClientAPIScopes
  return userEmail

def buildGAPIServiceObject(api, user, i=0, count=0, displayError=True):
  userEmail = getSaUser(user)
  httpObj = getHttpObj(cache=GM.Globals[GM.CACHE_DIR])
  service = getService(api, httpObj)
  if api == API.MEET_BETA:
    api = API.MEET
  credentials = getSvcAcctCredentials(api, userEmail)
  request = transportCreateRequest(httpObj)
  triesLimit = 3
  for n in range(1, triesLimit+1):
    try:
      credentials.refresh(request)
      service._http = transportAuthorizedHttp(credentials, http=httpObj)
      return (userEmail, service)
    except (httplib2.HttpLib2Error, google.auth.exceptions.TransportError, RuntimeError) as e:
      if n != triesLimit:
        httpObj.connections = {}
        waitOnFailure(n, triesLimit, NETWORK_ERROR_RC, str(e))
        continue
      handleServerError(e)
    except google.auth.exceptions.RefreshError as e:
      if isinstance(e.args, tuple):
        e = e.args[0]
      if n < triesLimit:
        if isinstance(e, str):
          eContent = e
        else:
          eContent = e.content.decode(UTF8) if isinstance(e.content, bytes) else e.content
        if eContent[0:15] == '<!DOCTYPE html>':
          if GC.Values[GC.DEBUG_LEVEL] > 0:
            writeStdout(f'{ERROR_PREFIX} HTTP: {str(eContent)}\n')
          lContent = eContent.lower()
          tg = HTML_TITLE_PATTERN.match(lContent)
          lContent = tg.group(1) if tg else ''
          if lContent.startswith('Error 502 (Server Error)'):
            time.sleep(30)
            continue
      handleOAuthTokenError(e, True, displayError, i, count)
      return (userEmail, None)

def buildGAPIObjectNoAuthentication(api):
  httpObj = getHttpObj(cache=GM.Globals[GM.CACHE_DIR])
  service = getService(api, httpObj)
  return service

def initGDataObject(gdataObj, api):
  GM.Globals[GM.CURRENT_CLIENT_API] = api
  credentials = getClientCredentials(noDASA=True, refreshOnly=True)
  GM.Globals[GM.CURRENT_CLIENT_API_SCOPES] = API.getClientScopesSet(api).intersection(GM.Globals[GM.CREDENTIALS_SCOPES])
  if not GM.Globals[GM.CURRENT_CLIENT_API_SCOPES]:
    systemErrorExit(NO_SCOPES_FOR_API_RC, Msg.NO_SCOPES_FOR_API.format(API.getAPIName(api)))
  getGDataOAuthToken(gdataObj, credentials)
  if GC.Values[GC.DEBUG_LEVEL] > 0:
    gdataObj.debug = True
  return gdataObj

def getGDataUserCredentials(api, user, i, count):
  userEmail = getSaUser(user)
  credentials = getSvcAcctCredentials(api, userEmail)
  request = transportCreateRequest()
  try:
    credentials.refresh(request)
    return (userEmail, credentials)
  except (httplib2.HttpLib2Error, google.auth.exceptions.TransportError, RuntimeError) as e:
    handleServerError(e)
  except google.auth.exceptions.RefreshError as e:
    if isinstance(e.args, tuple):
      e = e.args[0]
    handleOAuthTokenError(e, True, True, i, count)
    return (userEmail, None)

def getContactsObject():
  contactsObject = initGDataObject(gdata.apps.contacts.service.ContactsService(contactFeed=True),
                                   API.CONTACTS)
  return (GC.Values[GC.DOMAIN], contactsObject)

def getContactsQuery(**kwargs):
  if GC.Values[GC.NO_VERIFY_SSL]:
    ssl._create_default_https_context = ssl._create_unverified_context
  return gdata.apps.contacts.service.ContactsQuery(**kwargs)

def getEmailAuditObject():
  return initGDataObject(gdata.apps.audit.service.AuditService(), API.EMAIL_AUDIT)

def getUserEmailFromID(uid, cd):
  try:
    result = callGAPI(cd.users(), 'get',
                      throwReasons=GAPI.USER_GET_THROW_REASONS,
                      userKey=uid, fields='primaryEmail')
    return result.get('primaryEmail')
  except (GAPI.userNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.backendError, GAPI.systemError):
    return None

def getGroupEmailFromID(uid, cd):
  try:
    result = callGAPI(cd.groups(), 'get',
                      throwReasons=GAPI.GROUP_GET_THROW_REASONS,
                      groupKey=uid, fields='email')
    return result.get('email')
  except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest):
    return None

def getServiceAccountEmailFromID(account_id, sal=None):
  if sal is None:
    sal = buildGAPIObject(API.SERVICEACCOUNTLOOKUP)
  try:
    certs = callGAPI(sal.serviceaccounts(), 'lookup',
                     throwReasons = [GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.RESOURCE_NOT_FOUND,  GAPI.INVALID_ARGUMENT],
                     account=account_id)
  except (GAPI.badRequest, GAPI.notFound, GAPI.resourceNotFound, GAPI.invalidArgument):
    return None
  sa_cn_rx = r'CN=(.+)\.(.+)\.iam\.gservice.*'
  sa_emails = []
  for _, raw_cert in certs.items():
    cert = x509.load_pem_x509_certificate(raw_cert.encode(), default_backend())
    # suppress crytography warning due to long service account email
    with warnings.catch_warnings():
      warnings.filterwarnings('ignore', message='.*Attribute\'s length.*')
      mg = re.match(sa_cn_rx, cert.issuer.rfc4514_string())
    if mg:
      sa_email = f'{mg.group(1)}@{mg.group(2)}.iam.gserviceaccount.com'
      if sa_email not in sa_emails:
        sa_emails.append(sa_email)
  return GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER].join(sa_emails)

# Convert UID to email address and type
def convertUIDtoEmailAddressWithType(emailAddressOrUID, cd=None, sal=None, emailTypes=None,
                                     checkForCustomerId=False, ciGroupsAPI=False, aliasAllowed=True):
  if emailTypes is None:
    emailTypes = ['user']
  elif not isinstance(emailTypes, list):
    emailTypes = [emailTypes] if emailTypes != 'any' else ['user', 'group']
  if checkForCustomerId and (emailAddressOrUID == GC.Values[GC.CUSTOMER_ID]):
    return (emailAddressOrUID, 'email')
  normalizedEmailAddressOrUID = normalizeEmailAddressOrUID(emailAddressOrUID, ciGroupsAPI=ciGroupsAPI)
  if ciGroupsAPI and emailAddressOrUID.startswith('groups/'):
    return emailAddressOrUID
  if normalizedEmailAddressOrUID.find('@') > 0 and aliasAllowed:
    return (normalizedEmailAddressOrUID, 'email')
  if cd is None:
    cd = buildGAPIObject(API.DIRECTORY)
  if 'user' in emailTypes and 'group' in emailTypes:
    # Google User IDs *TEND* to be integers while groups tend to have letters
    # thus we can optimize which check we try first. We'll still check
    # both since there is no guarantee this will always be true.
    if normalizedEmailAddressOrUID.isdigit():
      uid = getUserEmailFromID(normalizedEmailAddressOrUID, cd)
      if uid:
        return (uid, 'user')
      uid = getGroupEmailFromID(normalizedEmailAddressOrUID, cd)
      if uid:
        return (uid, 'group')
    else:
      uid = getGroupEmailFromID(normalizedEmailAddressOrUID, cd)
      if uid:
        return (uid, 'group')
      uid = getUserEmailFromID(normalizedEmailAddressOrUID, cd)
      if uid:
        return (uid, 'user')
  elif 'user' in emailTypes:
    uid = getUserEmailFromID(normalizedEmailAddressOrUID, cd)
    if uid:
      return (uid, 'user')
  elif 'group' in emailTypes:
    uid = getGroupEmailFromID(normalizedEmailAddressOrUID, cd)
    if uid:
      return (uid, 'group')
  if 'resource' in emailTypes:
    try:
      result = callGAPI(cd.resources().calendars(), 'get',
                        throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                        calendarResourceId=normalizedEmailAddressOrUID,
                        customer=GC.Values[GC.CUSTOMER_ID], fields='resourceEmail')
      if 'resourceEmail' in result:
        return (result['resourceEmail'].lower(), 'resource')
    except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
      pass
  if 'serviceaccount' in emailTypes:
    uid = getServiceAccountEmailFromID(normalizedEmailAddressOrUID, sal)
    if uid:
      return (uid, 'serviceaccount')
  return (normalizedEmailAddressOrUID, 'unknown')

NON_EMAIL_MEMBER_PREFIXES = (
                              "cbcm-browser.",
                              "chrome-os-device.",
                            )
# Convert UID to email address
def convertUIDtoEmailAddress(emailAddressOrUID, cd=None, emailTypes=None,
                             checkForCustomerId=False, ciGroupsAPI=False, aliasAllowed=True):
  if ciGroupsAPI:
    if emailAddressOrUID.startswith(NON_EMAIL_MEMBER_PREFIXES):
      return emailAddressOrUID
    normalizedEmailAddressOrUID = normalizeEmailAddressOrUID(emailAddressOrUID, ciGroupsAPI=ciGroupsAPI)
    if normalizedEmailAddressOrUID.startswith(NON_EMAIL_MEMBER_PREFIXES):
      return normalizedEmailAddressOrUID
  email, _ = convertUIDtoEmailAddressWithType(emailAddressOrUID, cd, emailTypes,
                                              checkForCustomerId, ciGroupsAPI, aliasAllowed)
  return email

# Convert email address to User/Group UID; called immediately after getting email address from command line
def convertEmailAddressToUID(emailAddressOrUID, cd=None, emailType='user', savedLocation=None):
  normalizedEmailAddressOrUID = normalizeEmailAddressOrUID(emailAddressOrUID)
  if normalizedEmailAddressOrUID.find('@') == -1:
    return normalizedEmailAddressOrUID
  if cd is None:
    cd = buildGAPIObject(API.DIRECTORY)
  if emailType != 'group':
    try:
      return callGAPI(cd.users(), 'get',
                      throwReasons=GAPI.USER_GET_THROW_REASONS,
                      userKey=normalizedEmailAddressOrUID, fields='id')['id']
    except (GAPI.userNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
            GAPI.badRequest, GAPI.backendError, GAPI.systemError):
      if emailType == 'user':
        if savedLocation is not None:
          Cmd.SetLocation(savedLocation)
        entityDoesNotExistExit(Ent.USER, normalizedEmailAddressOrUID, errMsg=getPhraseDNEorSNA(normalizedEmailAddressOrUID))
  try:
    return callGAPI(cd.groups(), 'get',
                    throwReasons=GAPI.GROUP_GET_THROW_REASONS, retryReasons=GAPI.GROUP_GET_RETRY_REASONS,
                    groupKey=normalizedEmailAddressOrUID, fields='id')['id']
  except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.systemError):
    if savedLocation is not None:
      Cmd.SetLocation(savedLocation)
    entityDoesNotExistExit([Ent.USER, Ent.GROUP][emailType == 'group'], normalizedEmailAddressOrUID, errMsg=getPhraseDNEorSNA(normalizedEmailAddressOrUID))

# Convert User UID from API call to email address
def convertUserIDtoEmail(uid, cd=None):
  primaryEmail = GM.Globals[GM.MAP_USER_ID_TO_NAME].get(uid)
  if not primaryEmail:
    if cd is None:
      cd = buildGAPIObject(API.DIRECTORY)
    try:
      primaryEmail = callGAPI(cd.users(), 'get',
                              throwReasons=GAPI.USER_GET_THROW_REASONS,
                              userKey=uid, fields='primaryEmail')['primaryEmail']
    except (GAPI.userNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
            GAPI.badRequest, GAPI.backendError, GAPI.systemError):
      primaryEmail = f'uid:{uid}'
    GM.Globals[GM.MAP_USER_ID_TO_NAME][uid] = primaryEmail
  return primaryEmail

# Convert UID to split email address
# Return (foo@bar.com, foo, bar.com)
def splitEmailAddressOrUID(emailAddressOrUID):
  normalizedEmailAddressOrUID = normalizeEmailAddressOrUID(emailAddressOrUID)
  atLoc = normalizedEmailAddressOrUID.find('@')
  if atLoc > 0:
    return (normalizedEmailAddressOrUID, normalizedEmailAddressOrUID[:atLoc], normalizedEmailAddressOrUID[atLoc+1:])
  try:
    cd = buildGAPIObject(API.DIRECTORY)
    result = callGAPI(cd.users(), 'get',
                      throwReasons=GAPI.USER_GET_THROW_REASONS,
                      userKey=normalizedEmailAddressOrUID, fields='primaryEmail')
    if 'primaryEmail' in result:
      normalizedEmailAddressOrUID = result['primaryEmail'].lower()
      atLoc = normalizedEmailAddressOrUID.find('@')
      return (normalizedEmailAddressOrUID, normalizedEmailAddressOrUID[:atLoc], normalizedEmailAddressOrUID[atLoc+1:])
  except (GAPI.userNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.backendError, GAPI.systemError):
    pass
  return (normalizedEmailAddressOrUID, normalizedEmailAddressOrUID, GC.Values[GC.DOMAIN])

# Convert Org Unit Id to Org Unit Path
def convertOrgUnitIDtoPath(cd, orgUnitId):
  if orgUnitId.lower().startswith('orgunits/'):
    orgUnitId = f'id:{orgUnitId[9:]}'
  orgUnitPath = GM.Globals[GM.MAP_ORGUNIT_ID_TO_NAME].get(orgUnitId)
  if not orgUnitPath:
    if cd is None:
      cd = buildGAPIObject(API.DIRECTORY)
    try:
      orgUnitPath = callGAPI(cd.orgunits(), 'get',
                             throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                             customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=orgUnitId, fields='orgUnitPath')['orgUnitPath']
    except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError, GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
      orgUnitPath = orgUnitId
    GM.Globals[GM.MAP_ORGUNIT_ID_TO_NAME][orgUnitId] = orgUnitPath
  return orgUnitPath

def shlexSplitList(entity, dataDelimiter=' ,'):
  lexer = shlex.shlex(entity, posix=True)
  lexer.whitespace = dataDelimiter
  lexer.whitespace_split = True
  try:
    return list(lexer)
  except ValueError as e:
    Cmd.Backup()
    usageErrorExit(str(e))

def shlexSplitListStatus(entity, dataDelimiter=' ,'):
  lexer = shlex.shlex(entity, posix=True)
  lexer.whitespace = dataDelimiter
  lexer.whitespace_split = True
  try:
    return (True, list(lexer))
  except ValueError as e:
    return (False, str(e))

def getQueries(myarg):
  if myarg in {'query', 'filter'}:
    return [getString(Cmd.OB_QUERY)]
  return shlexSplitList(getString(Cmd.OB_QUERY_LIST))

def convertEntityToList(entity, shlexSplit=False, nonListEntityType=False):
  if not entity:
    return []
  if isinstance(entity, (list, set, dict)):
    return list(entity)
  if nonListEntityType:
    return [entity.strip()]
  if not shlexSplit:
    return entity.replace(',', ' ').split()
  return shlexSplitList(entity)

GROUP_ROLES_MAP = {
  'owner': Ent.ROLE_OWNER,
  'owners': Ent.ROLE_OWNER,
  'manager': Ent.ROLE_MANAGER,
  'managers': Ent.ROLE_MANAGER,
  'member': Ent.ROLE_MEMBER,
  'members': Ent.ROLE_MEMBER,
  }
ALL_GROUP_ROLES = {Ent.ROLE_MANAGER, Ent.ROLE_MEMBER, Ent.ROLE_OWNER}

def _getRoleVerification(memberRoles, fields):
  if memberRoles and memberRoles.find(Ent.ROLE_MEMBER) != -1:
    return (set(memberRoles.split(',')), None, fields if fields.find('role') != -1 else fields[:-1]+',role)')
  return (set(), memberRoles, fields)

def _getCIRoleVerification(memberRoles):
  if memberRoles:
    return set(memberRoles.split(','))
  return set()

def _checkMemberStatusIsSuspendedIsArchived(memberStatus, isSuspended, isArchived):
  if isSuspended is None and isArchived is None:
    return True
  if isSuspended is not None and isArchived is not None:
    if isSuspended == isArchived:
      if not isSuspended:
        return memberStatus not in {'SUSPENDED', 'ARCHIVED'}
      return memberStatus in {'SUSPENDED', 'ARCHIVED'}
    if isSuspended:
      return memberStatus == 'SUSPENDED'
    return memberStatus == 'ARCHIVED'
  if isSuspended is not None:
    if (not isSuspended and memberStatus != 'SUSPENDED') or (isSuspended and memberStatus == 'SUSPENDED'):
      return True
  if isArchived is not None:
    if (not isArchived and memberStatus != 'ARCHIVED') or (isArchived and memberStatus == 'ARCHIVED'):
      return True
  return False

def _checkMemberIsSuspendedIsArchived(member, isSuspended, isArchived):
  return _checkMemberStatusIsSuspendedIsArchived(member.get('status', 'UNKNOWN'), isSuspended, isArchived)

def _checkMemberRole(member, validRoles):
  return not validRoles or member.get('role', Ent.ROLE_MEMBER) in validRoles

def _checkMemberRoleIsSuspendedIsArchived(member, validRoles, isSuspended, isArchived):
  return _checkMemberRole(member, validRoles) and _checkMemberIsSuspendedIsArchived(member, isSuspended, isArchived)

def _checkMemberCategory(member, memberDisplayOptions):
  member_email = member.get('email', member.get('id', ''))
  if member_email.find('@') > 0:
    _, domain = member_email.lower().split('@', 1)
    category = 'internal' if domain in memberDisplayOptions['internalDomains'] else 'external'
  else:
    category = 'internal'
  if memberDisplayOptions[category]:
    member['category'] = category
    return True
  return False

def _checkCIMemberCategory(member, memberDisplayOptions):
  member_email = member.get('preferredMemberKey', {}).get('id', '')
  if member_email.find('@') > 0:
    _, domain = member_email.lower().split('@', 1)
    category = 'internal' if domain in memberDisplayOptions['internalDomains'] else 'external'
  else:
    category = 'internal'
  if memberDisplayOptions[category]:
    member['category'] = category
    return True
  return False

def getCIGroupMemberRoleFixType(member):
  ''' fixes missing type and returns the highest role of member '''
  if 'type' not in member:
    if member['preferredMemberKey']['id'] == GC.Values[GC.CUSTOMER_ID]:
      member['type'] = Ent.TYPE_CUSTOMER
    else:
      member['type'] = Ent.TYPE_OTHER
  roles = {}
  memberRoles = member.get('roles', [{'name': Ent.ROLE_MEMBER}])
  for role in memberRoles:
    roles[role['name']] = role
  for a_role in [Ent.ROLE_OWNER, Ent.ROLE_MANAGER, Ent.ROLE_MEMBER]:
    if a_role in roles:
      member['role'] = a_role
      if 'expiryDetail' in roles[a_role]:
        member['expireTime'] = roles[a_role]['expiryDetail']['expireTime']
      return
  member['role'] = memberRoles[0]['name']

def getCIGroupTransitiveMemberRoleFixType(groupName, tmember):
  ''' map transitive member to normal member '''
  tid = tmember['preferredMemberKey'][0].get('id', GC.Values[GC.CUSTOMER_ID]) if tmember['preferredMemberKey'] else ''
  ttype, tname = tmember['member'].split('/')
  member = {'name': f'{groupName}/membershipd/{tname}', 'preferredMemberKey': {'id': tid}}
  if 'type' not in tmember:
    if tid == GC.Values[GC.CUSTOMER_ID]:
      member['type'] = Ent.TYPE_CUSTOMER
    elif ttype == 'users':
      member['type'] = Ent.TYPE_USER if not tid.endswith('.iam.gserviceaccount.com') else Ent.TYPE_SERVICE_ACCOUNT
    elif ttype == 'groups':
      member['type'] = Ent.TYPE_GROUP
    elif tid.startswith('cbcm-browser.'):
      member['type'] = Ent.TYPE_CBCM_BROWSER
    else:
      member['type'] = Ent.TYPE_OTHER
  else:
    member['type'] = tmember['type']
  if 'roles' in tmember:
    memberRoles = []
    for trole in tmember['roles']:
      if 'role' in trole:
        trole['name'] = trole.pop('role')
      if trole['name'] == 'ADMIN':
        trole['name'] = Ent.ROLE_MANAGER
      memberRoles.append(trole)
  else:
    memberRoles = [{'name': Ent.ROLE_MEMBER}]
  roles = {}
  for role in memberRoles:
    roles[role['name']] = role
  for a_role in [Ent.ROLE_OWNER, Ent.ROLE_MANAGER, Ent.ROLE_MEMBER]:
    if a_role in roles:
      member['role'] = a_role
      if 'expiryDetail' in roles[a_role]:
        member['expireTime'] = roles[a_role]['expiryDetail']['expireTime']
      break
  else:
    member['role'] = memberRoles[0]['name']
  return member

def convertGroupCloudIDToEmail(ci, group, i=0, count=0):
  if not group.startswith('groups/'):
    group = normalizeEmailAddressOrUID(group, ciGroupsAPI=True)
    if not group.startswith('groups/'):
      return (ci, None, group)
  if not ci:
    ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
  try:
    ciGroup = callGAPI(ci.groups(), 'get',
                       throwReasons=GAPI.CIGROUP_GET_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                       name=group, fields='groupKey(id)')
    return (ci, None, ciGroup['groupKey']['id'])
  except (GAPI.notFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
          GAPI.forbidden, GAPI.badRequest, GAPI.invalid,
          GAPI.systemError, GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
    action = Act.Get()
    Act.Set(Act.LOOKUP)
    entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, group, Ent.GROUP, None], str(e), i, count)
    Act.Set(action)
    return (ci, None, None)

def convertGroupEmailToCloudID(ci, group, i=0, count=0):
  group = normalizeEmailAddressOrUID(group, ciGroupsAPI=True)
  if not group.startswith('groups/') and group.find('@') == -1:
    group = 'groups/'+group
  if group.startswith('groups/'):
    ci, _, groupEmail = convertGroupCloudIDToEmail(ci, group, i, count)
    return (ci, group, groupEmail)
  if not ci:
    ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
  try:
    ciGroup = callGAPI(ci.groups(), 'lookup',
                       throwReasons=GAPI.CIGROUP_GET_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                       groupKey_id=group, fields='name')
    return (ci, ciGroup['name'], group)
  except (GAPI.notFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
          GAPI.forbidden, GAPI.badRequest, GAPI.invalid,
          GAPI.systemError, GAPI.failedPrecondition, GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
    action = Act.Get()
    Act.Set(Act.LOOKUP)
    entityActionFailedWarning([Ent.GROUP, group, Ent.CLOUD_IDENTITY_GROUP, None], str(e), i, count)
    Act.Set(action)
    return (ci, None, None)

CIGROUP_DISCUSSION_FORUM_LABEL = 'cloudidentity.googleapis.com/groups.discussion_forum'
CIGROUP_DYNAMIC_LABEL = 'cloudidentity.googleapis.com/groups.dynamic'
CIGROUP_SECURITY_LABEL = 'cloudidentity.googleapis.com/groups.security'
CIGROUP_LOCKED_LABEL = 'cloudidentity.googleapis.com/groups.locked'

def getCIGroupMembershipGraph(ci, member):
  if not ci:
    ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
  parent = 'groups/-'
  try:
    result = callGAPI(ci.groups().memberships(), 'getMembershipGraph',
                      throwReasons=GAPI.CIGROUP_LIST_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                      parent=parent,
                      query=f"member_key_id == '{member}' && CIGROUP_DISCUSSION_FORUM_LABEL in labels")
    return (ci, result.get('response', {}))
  except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
          GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.invalidArgument,
          GAPI.systemError, GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
    action = Act.Get()
    Act.Set(Act.LOOKUP)
    entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, parent], str(e))
    Act.Set(action)
    return (ci, None)

def checkGroupExists(cd, ci, ciGroupsAPI, group, i=0, count=0):
  group = normalizeEmailAddressOrUID(group, ciGroupsAPI=ciGroupsAPI)
  if not ciGroupsAPI:
    if not group.startswith('groups/'):
      try:
        result = callGAPI(cd.groups(), 'get',
                          throwReasons=GAPI.GROUP_GET_THROW_REASONS, retryReasons=GAPI.GROUP_GET_RETRY_REASONS,
                          groupKey=group, fields='email')
        return (ci, None, result['email'])
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.systemError):
        entityUnknownWarning(Ent.GROUP, group, i, count)
        return (ci, None, None)
    else:
      ci, _, groupEmail = convertGroupCloudIDToEmail(ci, group, i, count)
      return (ci, None, groupEmail)
  else:
    if not group.startswith('groups/') and group.find('@') == -1:
      group = 'groups/'+group
    if group.startswith('groups/'):
      try:
        result = callGAPI(ci.groups(), 'get',
                          throwReasons=GAPI.CIGROUP_GET_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                          name=group, fields='name,groupKey(id)')
        return (ci, result['name'], result['groupKey']['id'])
      except (GAPI.notFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
              GAPI.forbidden, GAPI.badRequest, GAPI.invalid,
              GAPI.systemError, GAPI.permissionDenied, GAPI.serviceNotAvailable):
        entityUnknownWarning(Ent.CLOUD_IDENTITY_GROUP, group, i, count)
        return (ci, None, None)
    else:
      return convertGroupEmailToCloudID(ci, group, i, count)

# Turn the entity into a list of Users/CrOS devices
def getItemsToModify(entityType, entity, memberRoles=None, isSuspended=None, isArchived=None,
                     groupMemberType=Ent.TYPE_USER, noListConversion=False, recursive=False, noCLArgs=False):
  def _incrEntityDoesNotExist(entityType):
    entityError['entityType'] = entityType
    entityError[ENTITY_ERROR_DNE] += 1

  def _showInvalidEntity(entityType, entityName):
    entityError['entityType'] = entityType
    entityError[ENTITY_ERROR_INVALID] += 1
    printErrorMessage(INVALID_ENTITY_RC, formatKeyValueList('', [Ent.Singular(entityType), entityName, Msg.INVALID], ''))

  def _addGroupUsersToUsers(group, domains, recursive, includeDerivedMembership):
    printGettingAllEntityItemsForWhom(memberRoles if memberRoles else Ent.ROLE_MANAGER_MEMBER_OWNER, group, entityType=Ent.GROUP)
    validRoles, listRoles, listFields = _getRoleVerification(memberRoles, 'nextPageToken,members(email,type,status)')
    try:
      result = callGAPIpages(cd.members(), 'list', 'members',
                             pageMessage=getPageMessageForWhom(),
                             throwReasons=GAPI.MEMBERS_THROW_REASONS, retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                             includeDerivedMembership=includeDerivedMembership,
                             groupKey=group, roles=listRoles, fields=listFields, maxResults=GC.Values[GC.MEMBER_MAX_RESULTS])
    except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden, GAPI.serviceNotAvailable):
      entityUnknownWarning(Ent.GROUP, group)
      _incrEntityDoesNotExist(Ent.GROUP)
      return
    for member in result:
      if member['type'] == Ent.TYPE_USER:
        email = member['email'].lower()
        if email in entitySet:
          continue
        if _checkMemberRoleIsSuspendedIsArchived(member, validRoles, isSuspended, isArchived):
          if domains:
            _, domain = splitEmailAddress(email)
            if domain not in domains:
              continue
          entitySet.add(email)
          entityList.append(email)
      elif recursive and member['type'] == Ent.TYPE_GROUP:
        _addGroupUsersToUsers(member['email'], domains, recursive, includeDerivedMembership)

  def _addCIGroupUsersToUsers(groupName, groupEmail, recursive):
    printGettingAllEntityItemsForWhom(memberRoles if memberRoles else Ent.ROLE_MANAGER_MEMBER_OWNER, groupEmail, entityType=Ent.CLOUD_IDENTITY_GROUP)
    validRoles = _getCIRoleVerification(memberRoles)
    try:
      result = callGAPIpages(ci.groups().memberships(), 'list', 'memberships',
                             pageMessage=getPageMessageForWhom(),
                             throwReasons=GAPI.CIGROUP_LIST_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                             parent=groupName, view='FULL',
                             fields='nextPageToken,memberships(name,preferredMemberKey(id),roles(name),type)', pageSize=GC.Values[GC.MEMBER_MAX_RESULTS])
    except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
            GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.invalidArgument,
            GAPI.systemError, GAPI.permissionDenied, GAPI.serviceNotAvailable):
      entityUnknownWarning(Ent.CLOUD_IDENTITY_GROUP, groupEmail)
      _incrEntityDoesNotExist(Ent.CLOUD_IDENTITY_GROUP)
      return
    for member in result:
      getCIGroupMemberRoleFixType(member)
      if member['type'] == Ent.TYPE_USER:
        email = member.get('preferredMemberKey', {}).get('id', '')
        if (email and _checkMemberRole(member, validRoles) and email not in entitySet):
          entitySet.add(email)
          entityList.append(email)
      elif recursive and member['type'] == Ent.TYPE_GROUP and _checkMemberRole(member, validRoles):
        _, gname = member['name'].rsplit('/', 1)
        _addCIGroupUsersToUsers(f'groups/{gname}', f'groups/{gname}', recursive)

  GM.Globals[GM.CLASSROOM_SERVICE_NOT_AVAILABLE] = False
  ENTITY_ERROR_DNE = 'doesNotExist'
  ENTITY_ERROR_INVALID = 'invalid'
  entityError = {'entityType': None, ENTITY_ERROR_DNE: 0, ENTITY_ERROR_INVALID: 0}
  entityList = []
  entitySet = set()
  entityLocation = Cmd.Location()
  if entityType in {Cmd.ENTITY_USER, Cmd.ENTITY_USERS}:
    if not GC.Values[GC.USER_SERVICE_ACCOUNT_ACCESS_ONLY] and not GC.Values[GC.DOMAIN]:
      buildGAPIObject(API.DIRECTORY)
    result = convertEntityToList(entity, nonListEntityType=entityType == Cmd.ENTITY_USER)
    for user in result:
      if validateEmailAddressOrUID(user):
        if user not in entitySet:
          entitySet.add(user)
          entityList.append(user)
      else:
        _showInvalidEntity(Ent.USER, user)
    if GC.Values[GC.USER_SERVICE_ACCOUNT_ACCESS_ONLY]:
      return entityList
  elif entityType in {Cmd.ENTITY_ALL_USERS, Cmd.ENTITY_ALL_USERS_NS, Cmd.ENTITY_ALL_USERS_NS_SUSP, Cmd.ENTITY_ALL_USERS_SUSP}:
    cd = buildGAPIObject(API.DIRECTORY)
    if entityType == Cmd.ENTITY_ALL_USERS and isSuspended is not None:
      query = f'isSuspended={isSuspended}'
    else:
      query = Cmd.ALL_USERS_QUERY_MAP[entityType]
    printGettingAllAccountEntities(Ent.USER)
    try:
      result = callGAPIpages(cd.users(), 'list', 'users',
                             pageMessage=getPageMessage(),
                             throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                             customer=GC.Values[GC.CUSTOMER_ID],
                             query=query, orderBy='email', fields='nextPageToken,users(primaryEmail,archived)',
                             maxResults=GC.Values[GC.USER_MAX_RESULTS])
    except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
      accessErrorExit(cd)
    entityList = [user['primaryEmail'] for user in result if isArchived is None or isArchived == user['archived']]
    printGotAccountEntities(len(entityList))
  elif entityType in {Cmd.ENTITY_DOMAINS, Cmd.ENTITY_DOMAINS_NS, Cmd.ENTITY_DOMAINS_SUSP}:
    if entityType == Cmd.ENTITY_DOMAINS_NS:
      query = 'isSuspended=False'
    elif entityType == Cmd.ENTITY_DOMAINS_SUSP:
      query = 'isSuspended=True'
    elif isSuspended is not None:
      query = f'isSuspended={isSuspended}'
    else:
      query = None
    cd = buildGAPIObject(API.DIRECTORY)
    domains = convertEntityToList(entity)
    for domain in domains:
      printGettingAllEntityItemsForWhom(Ent.USER, domain, entityType=Ent.DOMAIN)
      try:
        result = callGAPIpages(cd.users(), 'list', 'users',
                               pageMessage=getPageMessageForWhom(),
                               throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.DOMAIN_NOT_FOUND, GAPI.FORBIDDEN],
                               domain=domain,
                               query=query, orderBy='email', fields='nextPageToken,users(primaryEmail,archived)',
                               maxResults=GC.Values[GC.USER_MAX_RESULTS])
      except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.forbidden):
        checkEntityDNEorAccessErrorExit(cd, Ent.DOMAIN, domain)
        _incrEntityDoesNotExist(Ent.DOMAIN)
        continue
      entityList = [user['primaryEmail'] for user in result if isArchived is None or isArchived == user['archived']]
      printGotAccountEntities(len(entityList))
  elif entityType in {Cmd.ENTITY_GROUP, Cmd.ENTITY_GROUPS,
                      Cmd.ENTITY_GROUP_NS, Cmd.ENTITY_GROUPS_NS,
                      Cmd.ENTITY_GROUP_SUSP, Cmd.ENTITY_GROUPS_SUSP,
                      Cmd.ENTITY_GROUP_INDE, Cmd.ENTITY_GROUPS_INDE}:
    if entityType in {Cmd.ENTITY_GROUP_NS, Cmd.ENTITY_GROUPS_NS}:
      isSuspended = False
    elif entityType in {Cmd.ENTITY_GROUP_SUSP, Cmd.ENTITY_GROUPS_SUSP}:
      isSuspended = True
    includeDerivedMembership = entityType in {Cmd.ENTITY_GROUP_INDE, Cmd.ENTITY_GROUPS_INDE}
    cd = buildGAPIObject(API.DIRECTORY)
    groups = convertEntityToList(entity, nonListEntityType=entityType in {Cmd.ENTITY_GROUP, Cmd.ENTITY_GROUP_NS, Cmd.ENTITY_GROUP_SUSP, Cmd.ENTITY_GROUP_INDE})
    for group in groups:
      if validateEmailAddressOrUID(group, checkPeople=False):
        group = normalizeEmailAddressOrUID(group)
        printGettingAllEntityItemsForWhom(memberRoles if memberRoles else Ent.ROLE_MANAGER_MEMBER_OWNER, group, entityType=Ent.GROUP)
        validRoles, listRoles, listFields = _getRoleVerification(memberRoles, 'nextPageToken,members(email,id,type,status)')
        try:
          result = callGAPIpages(cd.members(), 'list', 'members',
                                 pageMessage=getPageMessageForWhom(),
                                 throwReasons=GAPI.MEMBERS_THROW_REASONS, retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                                 includeDerivedMembership=includeDerivedMembership,
                                 groupKey=group, roles=listRoles, fields=listFields, maxResults=GC.Values[GC.MEMBER_MAX_RESULTS])
        except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden, GAPI.serviceNotAvailable):
          entityUnknownWarning(Ent.GROUP, group)
          _incrEntityDoesNotExist(Ent.GROUP)
          continue
        for member in result:
          email = member['email'].lower() if member['type'] != Ent.TYPE_CUSTOMER else member['id']
          if ((groupMemberType in ('ALL', member['type'])) and
              (not includeDerivedMembership or (member['type'] == Ent.TYPE_USER)) and
              _checkMemberRoleIsSuspendedIsArchived(member, validRoles, isSuspended, isArchived) and
              email not in entitySet):
            entitySet.add(email)
            entityList.append(email)
      else:
        _showInvalidEntity(Ent.GROUP, group)
  elif entityType in {Cmd.ENTITY_GROUP_USERS, Cmd.ENTITY_GROUP_USERS_NS, Cmd.ENTITY_GROUP_USERS_SUSP, Cmd.ENTITY_GROUP_USERS_SELECT}:
    if entityType == Cmd.ENTITY_GROUP_USERS_NS:
      isSuspended = False
    elif entityType == Cmd.ENTITY_GROUP_USERS_SUSP:
      isSuspended = True
    cd = buildGAPIObject(API.DIRECTORY)
    groups = convertEntityToList(entity)
    includeDerivedMembership = False
    domains = []
    rolesSet = set()
    if not noCLArgs:
      while Cmd.ArgumentsRemaining():
        myarg = getArgument()
        if myarg in GROUP_ROLES_MAP:
          rolesSet.add(GROUP_ROLES_MAP[myarg])
        elif myarg == 'primarydomain':
          domains.append(GC.Values[GC.DOMAIN])
        elif myarg == 'domains':
          domains.extend(getEntityList(Cmd.OB_DOMAIN_NAME_ENTITY))
        elif myarg == 'recursive':
          recursive = True
          includeDerivedMembership = False
        elif myarg == 'includederivedmembership':
          includeDerivedMembership = True
          recursive = False
        elif entityType == Cmd.ENTITY_GROUP_USERS_SELECT and myarg in SUSPENDED_ARGUMENTS:
          isSuspended = _getIsSuspended(myarg)
        elif entityType == Cmd.ENTITY_GROUP_USERS_SELECT and myarg in ARCHIVED_ARGUMENTS:
          isArchived = _getIsArchived(myarg)
        elif myarg == 'end':
          break
        else:
          Cmd.Backup()
          missingArgumentExit('end')
    if rolesSet:
      memberRoles = ','.join(sorted(rolesSet))
    for group in groups:
      if validateEmailAddressOrUID(group, checkPeople=False):
        _addGroupUsersToUsers(normalizeEmailAddressOrUID(group), domains, recursive, includeDerivedMembership)
      else:
        _showInvalidEntity(Ent.GROUP, group)
  elif entityType in {Cmd.ENTITY_CIGROUP, Cmd.ENTITY_CIGROUPS}:
    ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
    groups = convertEntityToList(entity, nonListEntityType=entityType in {Cmd.ENTITY_CIGROUP})
    for group in groups:
      if validateEmailAddressOrUID(group, checkPeople=False, ciGroupsAPI=True):
        _, name, groupEmail = convertGroupEmailToCloudID(ci, group)
        printGettingAllEntityItemsForWhom(memberRoles if memberRoles else Ent.ROLE_MANAGER_MEMBER_OWNER, groupEmail, entityType=Ent.CLOUD_IDENTITY_GROUP)
        validRoles = _getCIRoleVerification(memberRoles)
        try:
          result = callGAPIpages(ci.groups().memberships(), 'list', 'memberships',
                                 pageMessage=getPageMessageForWhom(),
                                 throwReasons=GAPI.CIGROUP_LIST_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                                 parent=name, view='FULL',
                                 fields='nextPageToken,memberships(preferredMemberKey(id),roles(name),type)',
                                 pageSize=GC.Values[GC.MEMBER_MAX_RESULTS])
        except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
                GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.invalidArgument,
                GAPI.systemError, GAPI.permissionDenied, GAPI.serviceNotAvailable):
          entityUnknownWarning(Ent.CLOUD_IDENTITY_GROUP, groupEmail)
          _incrEntityDoesNotExist(Ent.CLOUD_IDENTITY_GROUP)
          continue
        for member in result:
          getCIGroupMemberRoleFixType(member)
          email = member.get('preferredMemberKey', {}).get('id', '')
          if (email and (groupMemberType in ('ALL', member['type'])) and
              _checkMemberRole(member, validRoles) and email not in entitySet):
            entitySet.add(email)
            entityList.append(email)
      else:
        _showInvalidEntity(Ent.CLOUD_IDENTITY_GROUP, groupEmail)
  elif entityType in {Cmd.ENTITY_CIGROUP_USERS}:
    ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
    groups = convertEntityToList(entity)
    rolesSet = set()
    if not noCLArgs:
      while Cmd.ArgumentsRemaining():
        myarg = getArgument()
        if myarg in GROUP_ROLES_MAP:
          rolesSet.add(GROUP_ROLES_MAP[myarg])
        elif myarg == 'recursive':
          recursive = True
        elif myarg == 'end':
          break
        else:
          Cmd.Backup()
          missingArgumentExit('end')
    if rolesSet:
      memberRoles = ','.join(sorted(rolesSet))
    for group in groups:
      _, name, groupEmail = convertGroupEmailToCloudID(ci, group)
      if name:
        _addCIGroupUsersToUsers(name, groupEmail, recursive)
      else:
        _showInvalidEntity(Ent.GROUP, group)
  elif entityType in {Cmd.ENTITY_OU, Cmd.ENTITY_OUS, Cmd.ENTITY_OU_AND_CHILDREN, Cmd.ENTITY_OUS_AND_CHILDREN,
                      Cmd.ENTITY_OU_NS, Cmd.ENTITY_OUS_NS, Cmd.ENTITY_OU_AND_CHILDREN_NS, Cmd.ENTITY_OUS_AND_CHILDREN_NS,
                      Cmd.ENTITY_OU_SUSP, Cmd.ENTITY_OUS_SUSP, Cmd.ENTITY_OU_AND_CHILDREN_SUSP, Cmd.ENTITY_OUS_AND_CHILDREN_SUSP}:
    if entityType in {Cmd.ENTITY_OU_NS, Cmd.ENTITY_OUS_NS, Cmd.ENTITY_OU_AND_CHILDREN_NS, Cmd.ENTITY_OUS_AND_CHILDREN_NS}:
      isSuspended = False
    elif entityType in {Cmd.ENTITY_OU_SUSP, Cmd.ENTITY_OUS_SUSP, Cmd.ENTITY_OU_AND_CHILDREN_SUSP, Cmd.ENTITY_OUS_AND_CHILDREN_SUSP}:
      isSuspended = True
    cd = buildGAPIObject(API.DIRECTORY)
    ous = convertEntityToList(entity, shlexSplit=True, nonListEntityType=entityType in {Cmd.ENTITY_OU, Cmd.ENTITY_OU_AND_CHILDREN,
                                                                                        Cmd.ENTITY_OU_NS, Cmd.ENTITY_OU_AND_CHILDREN_NS,
                                                                                        Cmd.ENTITY_OU_SUSP, Cmd.ENTITY_OU_AND_CHILDREN_SUSP})
    directlyInOU = entityType in {Cmd.ENTITY_OU, Cmd.ENTITY_OUS, Cmd.ENTITY_OU_NS, Cmd.ENTITY_OUS_NS, Cmd.ENTITY_OU_SUSP, Cmd.ENTITY_OUS_SUSP}
    qualifier = Msg.DIRECTLY_IN_THE.format(Ent.Singular(Ent.ORGANIZATIONAL_UNIT)) if directlyInOU else Msg.IN_THE.format(Ent.Singular(Ent.ORGANIZATIONAL_UNIT))
    fields = 'nextPageToken,users(primaryEmail,orgUnitPath,archived)' if directlyInOU else 'nextPageToken,users(primaryEmail,archived)'
    prevLen = 0
    for ou in ous:
      ou = makeOrgUnitPathAbsolute(ou)
      if ou.startswith('id:'):
        try:
          ou = callGAPI(cd.orgunits(), 'get',
                        throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                        customerId=GC.Values[GC.CUSTOMER_ID],
                        orgUnitPath=ou, fields='orgUnitPath')['orgUnitPath']
        except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError, GAPI.badRequest,
                GAPI.invalidCustomerId, GAPI.loginRequired):
          checkEntityDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, ou)
          _incrEntityDoesNotExist(Ent.ORGANIZATIONAL_UNIT)
          continue
      ouLower = ou.lower()
      printGettingAllEntityItemsForWhom(Ent.USER, ou, qualifier=Msg.IN_THE.format(Ent.Singular(Ent.ORGANIZATIONAL_UNIT)),
                                        entityType=Ent.ORGANIZATIONAL_UNIT)
      pageMessage = getPageMessageForWhom()
      usersInOU = 0
      try:
        feed = yieldGAPIpages(cd.users(), 'list', 'users',
                              pageMessage=pageMessage, messageAttribute='primaryEmail',
                              throwReasons=[GAPI.INVALID_ORGUNIT, GAPI.ORGUNIT_NOT_FOUND,
                                            GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                              customer=GC.Values[GC.CUSTOMER_ID], query=orgUnitPathQuery(ou, isSuspended), orderBy='email',
                              fields=fields, maxResults=GC.Values[GC.USER_MAX_RESULTS])
        for users in feed:
          if directlyInOU:
            for user in users:
              if ouLower == user.get('orgUnitPath', '').lower() and (isArchived is None or isArchived == user['archived']):
                usersInOU += 1
                entityList.append(user['primaryEmail'])
          elif isArchived is None:
            entityList.extend([user['primaryEmail'] for user in users])
            usersInOU += len(users)
          else:
            for user in users:
              if isArchived == user['archived']:
                usersInOU += 1
                entityList.append(user['primaryEmail'])
        setGettingAllEntityItemsForWhom(Ent.USER, ou, qualifier=qualifier)
        printGotEntityItemsForWhom(usersInOU)
      except (GAPI.invalidInput, GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError, GAPI.badRequest,
              GAPI.invalidCustomerId, GAPI.loginRequired, GAPI.resourceNotFound, GAPI.forbidden):
        checkEntityDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, ou)
        _incrEntityDoesNotExist(Ent.ORGANIZATIONAL_UNIT)
  elif entityType in {Cmd.ENTITY_QUERY, Cmd.ENTITY_QUERIES}:
    cd = buildGAPIObject(API.DIRECTORY)
    queries = convertEntityToList(entity, shlexSplit=True, nonListEntityType=entityType == Cmd.ENTITY_QUERY)
    prevLen = 0
    for query in queries:
      printGettingAllAccountEntities(Ent.USER, query)
      try:
        result = callGAPIpages(cd.users(), 'list', 'users',
                               pageMessage=getPageMessage(),
                               throwReasons=[GAPI.INVALID_ORGUNIT, GAPI.ORGUNIT_NOT_FOUND,
                                             GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                               customer=GC.Values[GC.CUSTOMER_ID], query=query, orderBy='email',
                               fields='nextPageToken,users(primaryEmail,suspended,archived)',
                               maxResults=GC.Values[GC.USER_MAX_RESULTS])
      except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.invalidInput):
        Cmd.Backup()
        usageErrorExit(Msg.INVALID_QUERY)
      except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
        accessErrorExit(cd)
      for user in result:
        email = user['primaryEmail']
        if ((isSuspended is None or isSuspended == user['suspended']) and
            (isArchived is None or isArchived == user['archived']) and
            email not in entitySet):
          entitySet.add(email)
          entityList.append(email)
      totalLen = len(entityList)
      printGotAccountEntities(totalLen-prevLen)
      prevLen = totalLen
  elif entityType == Cmd.ENTITY_LICENSES:
    skusList = []
    for item in entity.split(','):
      productId, sku = SKU.getProductAndSKU(item)
      if not productId:
        _incrEntityDoesNotExist(Ent.SKU)
      elif (productId, sku) not in skusList:
        skusList.append((productId, sku))
    if skusList:
      entityList = doPrintLicenses(returnFields=['userId'], skus=skusList)
  elif entityType in {Cmd.ENTITY_COURSEPARTICIPANTS, Cmd.ENTITY_TEACHERS, Cmd.ENTITY_STUDENTS}:
    croom = buildGAPIObject(API.CLASSROOM)
    if not noListConversion:
      courseIdList = convertEntityToList(entity)
    else:
      courseIdList = [entity]
    _, _, coursesInfo = _getCoursesOwnerInfo(croom, courseIdList, GC.Values[GC.USE_COURSE_OWNER_ACCESS])
    for courseId, courseInfo in coursesInfo.items():
      try:
        if entityType in {Cmd.ENTITY_COURSEPARTICIPANTS, Cmd.ENTITY_TEACHERS}:
          printGettingAllEntityItemsForWhom(Ent.TEACHER, removeCourseIdScope(courseId), entityType=Ent.COURSE)
          result = callGAPIpages(courseInfo['croom'].courses().teachers(), 'list', 'teachers',
                                 pageMessage=getPageMessageForWhom(),
                                 throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.BAD_REQUEST, GAPI.SERVICE_NOT_AVAILABLE],
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 courseId=courseId, fields='nextPageToken,teachers/profile/emailAddress',
                                 pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS])
          for teacher in result:
            email = teacher['profile'].get('emailAddress', None)
            if email and (email not in entitySet):
              entitySet.add(email)
              entityList.append(email)
        if entityType in {Cmd.ENTITY_COURSEPARTICIPANTS, Cmd.ENTITY_STUDENTS}:
          printGettingAllEntityItemsForWhom(Ent.STUDENT, removeCourseIdScope(courseId), entityType=Ent.COURSE)
          result = callGAPIpages(courseInfo['croom'].courses().students(), 'list', 'students',
                                 pageMessage=getPageMessageForWhom(),
                                 throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.BAD_REQUEST, GAPI.SERVICE_NOT_AVAILABLE],
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 courseId=courseId, fields='nextPageToken,students/profile/emailAddress',
                                 pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS])
          for student in result:
            email = student['profile'].get('emailAddress', None)
            if email and (email not in entitySet):
              entitySet.add(email)
              entityList.append(email)
      except GAPI.notFound:
        entityDoesNotExistWarning(Ent.COURSE, removeCourseIdScope(courseId))
        _incrEntityDoesNotExist(Ent.COURSE)
      except GAPI.serviceNotAvailable as e:
        entityActionNotPerformedWarning([Ent.COURSE, removeCourseIdScope(courseId)], str(e))
        GM.Globals[GM.CLASSROOM_SERVICE_NOT_AVAILABLE] = True
        break
      except (GAPI.forbidden, GAPI.badRequest):
        ClientAPIAccessDeniedExit()
  elif entityType == Cmd.ENTITY_CROS:
    buildGAPIObject(API.DIRECTORY)
    result = convertEntityToList(entity)
    for deviceId in result:
      if deviceId not in entitySet:
        entitySet.add(deviceId)
        entityList.append(deviceId)
  elif entityType == Cmd.ENTITY_ALL_CROS:
    cd = buildGAPIObject(API.DIRECTORY)
    printGettingAllAccountEntities(Ent.CROS_DEVICE)
    try:
      result = callGAPIpages(cd.chromeosdevices(), 'list', 'chromeosdevices',
                             pageMessage=getPageMessage(),
                             throwReasons=[GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                             retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                             customerId=GC.Values[GC.CUSTOMER_ID],
                             fields='nextPageToken,chromeosdevices(deviceId)',
                             maxResults=GC.Values[GC.DEVICE_MAX_RESULTS])
    except (GAPI.invalidInput, GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
      accessErrorExit(cd)
    entityList = [device['deviceId'] for device in result]
  elif entityType in {Cmd.ENTITY_CROS_QUERY, Cmd.ENTITY_CROS_QUERIES, Cmd.ENTITY_CROS_SN}:
    cd = buildGAPIObject(API.DIRECTORY)
    queries = convertEntityToList(entity, shlexSplit=entityType == Cmd.ENTITY_CROS_QUERIES,
                                  nonListEntityType=entityType == Cmd.ENTITY_CROS_QUERY)
    if entityType == Cmd.ENTITY_CROS_SN:
      queries = [f'id:{query}' for query in queries]
    prevLen = 0
    for query in queries:
      printGettingAllAccountEntities(Ent.CROS_DEVICE, query)
      try:
        result = callGAPIpages(cd.chromeosdevices(), 'list', 'chromeosdevices',
                               pageMessage=getPageMessage(),
                               throwReasons=[GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                               customerId=GC.Values[GC.CUSTOMER_ID], query=query,
                               fields='nextPageToken,chromeosdevices(deviceId)',
                               maxResults=GC.Values[GC.DEVICE_MAX_RESULTS])
      except GAPI.invalidInput:
        Cmd.Backup()
        usageErrorExit(Msg.INVALID_QUERY)
      except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
        accessErrorExit(cd)
      for device in result:
        deviceId = device['deviceId']
        if deviceId not in entitySet:
          entitySet.add(deviceId)
          entityList.append(deviceId)
      totalLen = len(entityList)
      printGotAccountEntities(totalLen-prevLen)
      prevLen = totalLen
  elif entityType in {Cmd.ENTITY_CROS_OU, Cmd.ENTITY_CROS_OU_AND_CHILDREN, Cmd.ENTITY_CROS_OUS, Cmd.ENTITY_CROS_OUS_AND_CHILDREN,
                      Cmd.ENTITY_CROS_OU_QUERY, Cmd.ENTITY_CROS_OU_AND_CHILDREN_QUERY, Cmd.ENTITY_CROS_OUS_QUERY, Cmd.ENTITY_CROS_OUS_AND_CHILDREN_QUERY,
                      Cmd.ENTITY_CROS_OU_QUERIES, Cmd.ENTITY_CROS_OU_AND_CHILDREN_QUERIES, Cmd.ENTITY_CROS_OUS_QUERIES, Cmd.ENTITY_CROS_OUS_AND_CHILDREN_QUERIES}:
    cd = buildGAPIObject(API.DIRECTORY)
    ous = convertEntityToList(entity, shlexSplit=True,
                              nonListEntityType=entityType in {Cmd.ENTITY_CROS_OU, Cmd.ENTITY_CROS_OU_AND_CHILDREN,
                                                               Cmd.ENTITY_CROS_OU_QUERY, Cmd.ENTITY_CROS_OU_AND_CHILDREN_QUERY,
                                                               Cmd.ENTITY_CROS_OU_QUERIES, Cmd.ENTITY_CROS_OU_AND_CHILDREN_QUERIES})
    numOus = len(ous)
    includeChildOrgunits = entityType in {Cmd.ENTITY_CROS_OU_AND_CHILDREN, Cmd.ENTITY_CROS_OUS_AND_CHILDREN,
                                          Cmd.ENTITY_CROS_OU_AND_CHILDREN_QUERY, Cmd.ENTITY_CROS_OUS_AND_CHILDREN_QUERY,
                                          Cmd.ENTITY_CROS_OU_AND_CHILDREN_QUERIES, Cmd.ENTITY_CROS_OUS_AND_CHILDREN_QUERIES}
    allQualifier = Msg.DIRECTLY_IN_THE.format(Ent.Choose(Ent.ORGANIZATIONAL_UNIT, numOus)) if not includeChildOrgunits else Msg.IN_THE.format(Ent.Choose(Ent.ORGANIZATIONAL_UNIT, numOus))
    if entityType in {Cmd.ENTITY_CROS_OU_QUERY, Cmd.ENTITY_CROS_OU_AND_CHILDREN_QUERY, Cmd.ENTITY_CROS_OUS_QUERY, Cmd.ENTITY_CROS_OUS_AND_CHILDREN_QUERY}:
      queries = getQueries('query')
    elif entityType in {Cmd.ENTITY_CROS_OU_QUERIES, Cmd.ENTITY_CROS_OU_AND_CHILDREN_QUERIES, Cmd.ENTITY_CROS_OUS_QUERIES, Cmd.ENTITY_CROS_OUS_AND_CHILDREN_QUERIES}:
      queries = getQueries('queries')
    else:
      queries = [None]
    for ou in ous:
      ou = makeOrgUnitPathAbsolute(ou)
      oneQualifier = Msg.DIRECTLY_IN_THE.format(Ent.Singular(Ent.ORGANIZATIONAL_UNIT)) if not includeChildOrgunits else Msg.IN_THE.format(Ent.Singular(Ent.ORGANIZATIONAL_UNIT))
      for query in queries:
        printGettingAllEntityItemsForWhom(Ent.CROS_DEVICE, ou,
                                          query=query, qualifier=oneQualifier, entityType=Ent.ORGANIZATIONAL_UNIT)
        try:
          result = callGAPIpages(cd.chromeosdevices(), 'list', 'chromeosdevices',
                                 pageMessage=getPageMessageForWhom(),
                                 throwReasons=[GAPI.INVALID_INPUT, GAPI.INVALID_ORGUNIT, GAPI.ORGUNIT_NOT_FOUND,
                                               GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 customerId=GC.Values[GC.CUSTOMER_ID], query=query,
                                 orgUnitPath=ou, includeChildOrgunits=includeChildOrgunits,
                                 fields='nextPageToken,chromeosdevices(deviceId)', maxResults=GC.Values[GC.DEVICE_MAX_RESULTS])
        except GAPI.invalidInput:
          Cmd.Backup()
          usageErrorExit(Msg.INVALID_QUERY)
        except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
          checkEntityDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, ou)
          _incrEntityDoesNotExist(Ent.ORGANIZATIONAL_UNIT)
          continue
        if query is None:
          entityList.extend([device['deviceId'] for device in result])
        else:
          for device in result:
            deviceId = device['deviceId']
            if deviceId not in entitySet:
              entitySet.add(deviceId)
              entityList.append(deviceId)
    Ent.SetGettingQualifier(Ent.CROS_DEVICE, allQualifier)
    Ent.SetGettingForWhom(','.join(ous))
    printGotEntityItemsForWhom(len(entityList))
  elif entityType == Cmd.ENTITY_BROWSER:
    result = convertEntityToList(entity)
    for deviceId in result:
      if deviceId not in entitySet:
        entitySet.add(deviceId)
        entityList.append(deviceId)
  elif entityType in {Cmd.ENTITY_BROWSER_OU, Cmd.ENTITY_BROWSER_OUS}:
    cbcm = buildGAPIObject(API.CBCM)
    customerId = _getCustomerIdNoC()
    ous = convertEntityToList(entity, shlexSplit=True, nonListEntityType=entityType == Cmd.ENTITY_BROWSER_OU)
    numOus = len(ous)
    allQualifier = Msg.DIRECTLY_IN_THE.format(Ent.Choose(Ent.ORGANIZATIONAL_UNIT, numOus))
    oneQualifier = Msg.DIRECTLY_IN_THE.format(Ent.Singular(Ent.ORGANIZATIONAL_UNIT))
    for ou in ous:
      ou = makeOrgUnitPathAbsolute(ou)
      printGettingAllEntityItemsForWhom(Ent.CHROME_BROWSER, ou, qualifier=oneQualifier, entityType=Ent.ORGANIZATIONAL_UNIT)
      try:
        result = callGAPIpages(cbcm.chromebrowsers(), 'list', 'browsers',
                               pageMessage=getPageMessageForWhom(),
                               throwReasons=[GAPI.BAD_REQUEST, GAPI.INVALID_ORGUNIT, GAPI.FORBIDDEN],
                               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                               customer=customerId, orgUnitPath=ou, projection='BASIC',
                               orderBy='id', sortOrder='ASCENDING', fields='nextPageToken,browsers(deviceId)')
      except (GAPI.badRequest, GAPI.invalidOrgunit, GAPI.forbidden):
        checkEntityDNEorAccessErrorExit(None, Ent.ORGANIZATIONAL_UNIT, ou)
        _incrEntityDoesNotExist(Ent.ORGANIZATIONAL_UNIT)
        continue
      entityList.extend([browser['deviceId'] for browser in result])
    Ent.SetGettingQualifier(Ent.CHROME_BROWSER, allQualifier)
    Ent.SetGettingForWhom(','.join(ous))
    printGotEntityItemsForWhom(len(entityList))
  elif entityType in {Cmd.ENTITY_BROWSER_QUERY, Cmd.ENTITY_BROWSER_QUERIES}:
    cbcm = buildGAPIObject(API.CBCM)
    customerId = _getCustomerIdNoC()
    queries = convertEntityToList(entity, shlexSplit=entityType == Cmd.ENTITY_BROWSER_QUERIES,
                                  nonListEntityType=entityType == Cmd.ENTITY_BROWSER_QUERY)
    prevLen = 0
    for query in queries:
      printGettingAllAccountEntities(Ent.CHROME_BROWSER, query)
      try:
        result = callGAPIpages(cbcm.chromebrowsers(), 'list', 'browsers',
                               pageMessage=getPageMessage(),
                               throwReasons=[GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                               customer=customerId, query=query, projection='BASIC',
                               orderBy='id', sortOrder='ASCENDING', fields='nextPageToken,browsers(deviceId)')
      except GAPI.invalidInput:
        Cmd.Backup()
        usageErrorExit(Msg.INVALID_QUERY)
      except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden) as e:
        accessErrorExitNonDirectory(API.CBCM, str(e))
      for device in result:
        deviceId = device['deviceId']
        if deviceId not in entitySet:
          entitySet.add(deviceId)
          entityList.append(deviceId)
      totalLen = len(entityList)
      printGotAccountEntities(totalLen-prevLen)
      prevLen = totalLen
  else:
    systemErrorExit(UNKNOWN_ERROR_RC, 'getItemsToModify coding error')
  for errorType in [ENTITY_ERROR_DNE, ENTITY_ERROR_INVALID]:
    if entityError[errorType] > 0:
      Cmd.SetLocation(entityLocation-1)
      writeStderr(Cmd.CommandLineWithBadArgumentMarked(False))
      count = entityError[errorType]
      if errorType == ENTITY_ERROR_DNE:
        stderrErrorMsg(Msg.BAD_ENTITIES_IN_SOURCE.format(count, Ent.Choose(entityError['entityType'], count),
                                                         Msg.DO_NOT_EXIST if count != 1 else Msg.DOES_NOT_EXIST))
        sys.exit(ENTITY_DOES_NOT_EXIST_RC)
      else:
        stderrErrorMsg(Msg.BAD_ENTITIES_IN_SOURCE.format(count, Msg.INVALID, Ent.Choose(entityError['entityType'], count)))
        sys.exit(INVALID_ENTITY_RC)
  return entityList

def splitEntityList(entity, dataDelimiter):
  if not entity:
    return []
  if not dataDelimiter:
    return [entity]
  return entity.split(dataDelimiter)

def splitEntityListShlex(entity, dataDelimiter):
  if not entity:
    return (True, [])
  if not dataDelimiter:
    return (True, [entity])
  return shlexSplitListStatus(entity, dataDelimiter)

def fileDataErrorExit(filename, row, itemName, value, errMessage):
  if itemName:
    systemErrorExit(DATA_ERROR_RC,
                    formatKeyValueList('',
                                       [Ent.Singular(Ent.FILE), filename,
                                        Ent.Singular(Ent.ROW), row,
                                        Ent.Singular(Ent.ITEM), itemName,
                                        Ent.Singular(Ent.VALUE), value,
                                        errMessage],
                                       ''))
  else:
    systemErrorExit(DATA_ERROR_RC,
                    formatKeyValueList('',
                                       [Ent.Singular(Ent.FILE), filename,
                                        Ent.Singular(Ent.ROW), row,
                                        Ent.Singular(Ent.VALUE), value,
                                        errMessage],
                                       ''))

# <FileSelector>
def getEntitiesFromFile(shlexSplit, returnSet=False):
  filename = getString(Cmd.OB_FILE_NAME)
  filenameLower = filename.lower()
  if filenameLower not in {'gcsv', 'gdoc', 'gcscsv', 'gcsdoc'}:
    encoding = getCharSet()
    f = openFile(filename, encoding=encoding, stripUTFBOM=True)
  elif filenameLower in {'gcsv', 'gdoc'}:
    f = getGDocData(filenameLower)
    getCharSet()
  else: #filenameLower in {'gcscsv', 'gcsdoc'}:
    f = getStorageFileData(filenameLower)
    getCharSet()
  dataDelimiter = getDelimiter()
  entitySet = set()
  entityList = []
  i = 0
  for row in f:
    i += 1
    if shlexSplit:
      splitStatus, itemList = splitEntityListShlex(row.strip(), dataDelimiter)
      if not splitStatus:
        fileDataErrorExit(filename, i, None, row.strip(), f'{Msg.INVALID_LIST}: {itemList}')
    else:
      itemList = splitEntityList(row.strip(), dataDelimiter)
    for item in itemList:
      item = item.strip()
      if item and (item not in entitySet):
        entitySet.add(item)
        entityList.append(item)
  closeFile(f)
  return entityList if not returnSet else entitySet

# <CSVFileSelector>
def getEntitiesFromCSVFile(shlexSplit, returnSet=False):
  fileFieldName = getString(Cmd.OB_FILE_NAME_FIELD_NAME)
  if platform.system() == 'Windows' and not fileFieldName.startswith('-:'):
    drive, fileFieldName = os.path.splitdrive(fileFieldName)
  else:
    drive = ''
  if fileFieldName.find(':') == -1:
    Cmd.Backup()
    invalidArgumentExit(Cmd.OB_FILE_NAME_FIELD_NAME)
  fileFieldNameList = fileFieldName.split(':')
  filename = drive+fileFieldNameList[0]
  f, csvFile, fieldnames = openCSVFileReader(filename)
  for fieldName in fileFieldNameList[1:]:
    if fieldName not in fieldnames:
      csvFieldErrorExit(fieldName, fieldnames, backupArg=True, checkForCharset=True)
  matchFields, skipFields = getMatchSkipFields(fieldnames)
  dataDelimiter = getDelimiter()
  entitySet = set()
  entityList = []
  i = 1
  for row in csvFile:
    i += 1
    if checkMatchSkipFields(row, None, matchFields, skipFields):
      for fieldName in fileFieldNameList[1:]:
        if shlexSplit:
          splitStatus, itemList = splitEntityListShlex(row[fieldName].strip(), dataDelimiter)
          if not splitStatus:
            fileDataErrorExit(filename, i, fieldName, row[fieldName].strip(), f'{Msg.INVALID_LIST}: {itemList}')
        else:
          itemList = splitEntityList(row[fieldName].strip(), dataDelimiter)
        for item in itemList:
          item = item.strip()
          if item and (item not in entitySet):
            entitySet.add(item)
            entityList.append(item)
  closeFile(f)
  return entityList if not returnSet else entitySet

# <CSVFileSelector>
#	keyfield <FieldName> [keypattern <RESearchPattern>] [keyvalue <RESubstitution>] [delimiter <Character>]
#	subkeyfield <FieldName> [keypattern <RESearchPattern>] [keyvalue <RESubstitution>] [delimiter <Character>]
#	(matchfield|skipfield <FieldName> <RESearchPattern>)*
#	[datafield <FieldName>(:<FieldName>)* [delimiter <Character>]]
def getEntitiesFromCSVbyField():

  def getKeyFieldInfo(keyword, required, globalKeyField):
    if not checkArgumentPresent(keyword, required=required):
      GM.Globals[globalKeyField] = None
      return (None, None, None, None)
    keyField = GM.Globals[globalKeyField] = getString(Cmd.OB_FIELD_NAME)
    if keyField not in fieldnames:
      csvFieldErrorExit(keyField, fieldnames, backupArg=True)
    if checkArgumentPresent('keypattern'):
      keyPattern = getREPattern()
    else:
      keyPattern = None
    if checkArgumentPresent('keyvalue'):
      keyValue = getString(Cmd.OB_STRING)
    else:
      keyValue = keyField
    keyDelimiter = getDelimiter()
    return (keyField, keyPattern, keyValue, keyDelimiter)

  def getKeyList(row, keyField, keyPattern, keyValue, keyDelimiter, matchFields, skipFields):
    item = row[keyField].strip()
    if not item:
      return []
    if not checkMatchSkipFields(row, None, matchFields, skipFields):
      return []
    if keyPattern:
      keyList = [keyPattern.sub(keyValue, keyItem.strip()) for keyItem in splitEntityList(item, keyDelimiter)]
    else:
      keyList = [re.sub(keyField, keyItem.strip(), keyValue) for keyItem in splitEntityList(item, keyDelimiter)]
    return [key for key in keyList if key]

  filename = getString(Cmd.OB_FILE_NAME)
  f, csvFile, fieldnames = openCSVFileReader(filename)
  mainKeyField, mainKeyPattern, mainKeyValue, mainKeyDelimiter = getKeyFieldInfo('keyfield', True, GM.CSV_KEY_FIELD)
  subKeyField, subKeyPattern, subKeyValue, subKeyDelimiter = getKeyFieldInfo('subkeyfield', False, GM.CSV_SUBKEY_FIELD)
  matchFields, skipFields = getMatchSkipFields(fieldnames)
  if checkArgumentPresent('datafield'):
    if GM.Globals[GM.CSV_DATA_DICT]:
      csvDataAlreadySavedErrorExit()
    GM.Globals[GM.CSV_DATA_FIELD] = getString(Cmd.OB_FIELD_NAME, checkBlank=True)
    dataFields = GM.Globals[GM.CSV_DATA_FIELD].split(':')
    for dataField in dataFields:
      if dataField not in fieldnames:
        csvFieldErrorExit(dataField, fieldnames, backupArg=True)
    dataDelimiter = getDelimiter()
  else:
    GM.Globals[GM.CSV_DATA_FIELD] = None
    dataFields = []
    dataDelimiter = None
  entitySet = set()
  entityList = []
  csvDataKeys = {}
  GM.Globals[GM.CSV_DATA_DICT] = {}
  if not subKeyField:
    for row in csvFile:
      mainKeyList = getKeyList(row, mainKeyField, mainKeyPattern, mainKeyValue, mainKeyDelimiter, matchFields, skipFields)
      if not mainKeyList:
        continue
      for mainKey in mainKeyList:
        if mainKey not in entitySet:
          entitySet.add(mainKey)
          entityList.append(mainKey)
          if GM.Globals[GM.CSV_DATA_FIELD]:
            csvDataKeys[mainKey] = set()
            GM.Globals[GM.CSV_DATA_DICT][mainKey] = []
      for dataField in dataFields:
        if dataField in row:
          dataList = splitEntityList(row[dataField].strip(), dataDelimiter)
          for dataValue in dataList:
            dataValue = dataValue.strip()
            if not dataValue:
              continue
            for mainKey in mainKeyList:
              if dataValue not in csvDataKeys[mainKey]:
                csvDataKeys[mainKey].add(dataValue)
                GM.Globals[GM.CSV_DATA_DICT][mainKey].append(dataValue)
  else:
    csvSubKeys = {}
    for row in csvFile:
      mainKeyList = getKeyList(row, mainKeyField, mainKeyPattern, mainKeyValue, mainKeyDelimiter, matchFields, skipFields)
      if not mainKeyList:
        continue
      for mainKey in mainKeyList:
        if mainKey not in entitySet:
          entitySet.add(mainKey)
          entityList.append(mainKey)
          csvSubKeys[mainKey] = set()
          csvDataKeys[mainKey] = {}
          GM.Globals[GM.CSV_DATA_DICT][mainKey] = {}
      subKeyList = getKeyList(row, subKeyField, subKeyPattern, subKeyValue, subKeyDelimiter, {}, {})
      if not subKeyList:
        continue
      for mainKey in mainKeyList:
        for subKey in subKeyList:
          if subKey not in csvSubKeys[mainKey]:
            csvSubKeys[mainKey].add(subKey)
            if GM.Globals[GM.CSV_DATA_FIELD]:
              csvDataKeys[mainKey][subKey] = set()
              GM.Globals[GM.CSV_DATA_DICT][mainKey][subKey] = []
      for dataField in dataFields:
        if dataField in row:
          dataList = splitEntityList(row[dataField].strip(), dataDelimiter)
          for dataValue in dataList:
            dataValue = dataValue.strip()
            if not dataValue:
              continue
            for mainKey in mainKeyList:
              for subKey in subKeyList:
                if dataValue not in csvDataKeys[mainKey][subKey]:
                  csvDataKeys[mainKey][subKey].add(dataValue)
                  GM.Globals[GM.CSV_DATA_DICT][mainKey][subKey].append(dataValue)
  closeFile(f)
  return entityList

# Typically used to map courseparticipants to students or teachers
def mapEntityType(entityType, typeMap):
  if (typeMap is not None) and (entityType in typeMap):
    return typeMap[entityType]
  return entityType

def getEntityArgument(entityList):
  if entityList is None:
    return (0, 0, entityList)
  if isinstance(entityList, dict):
    clLoc = Cmd.Location()
    Cmd.SetLocation(GM.Globals[GM.ENTITY_CL_DELAY_START])
    entityList = getItemsToModify(**entityList)
    Cmd.SetLocation(clLoc)
  return (0, len(entityList), entityList)

def getEntityToModify(defaultEntityType=None, browserAllowed=False, crosAllowed=False, userAllowed=True,
                      typeMap=None, isSuspended=None, isArchived=None, groupMemberType=Ent.TYPE_USER, delayGet=False):
  if GC.Values[GC.USER_SERVICE_ACCOUNT_ACCESS_ONLY]:
    crosAllowed = False
    selectorChoices = Cmd.SERVICE_ACCOUNT_ONLY_ENTITY_SELECTORS[:]
  else:
    selectorChoices = Cmd.BASE_ENTITY_SELECTORS[:]
  if userAllowed:
    selectorChoices += Cmd.USER_ENTITY_SELECTORS+Cmd.USER_CSVDATA_ENTITY_SELECTORS
  if crosAllowed:
    selectorChoices += Cmd.CROS_ENTITY_SELECTORS+Cmd.CROS_CSVDATA_ENTITY_SELECTORS
  if browserAllowed:
    selectorChoices = Cmd.BROWSER_ENTITY_SELECTORS
  entitySelector = getChoice(selectorChoices, defaultChoice=None)
  if entitySelector:
    choices = []
    if entitySelector == Cmd.ENTITY_SELECTOR_ALL:
      if userAllowed:
        choices += Cmd.USER_ENTITY_SELECTOR_ALL_SUBTYPES
      if crosAllowed:
        choices += Cmd.CROS_ENTITY_SELECTOR_ALL_SUBTYPES
      entityType = Cmd.ENTITY_SELECTOR_ALL_SUBTYPES_MAP[getChoice(choices)]
      if not delayGet:
        return (Cmd.ENTITY_USERS if entityType != Cmd.ENTITY_ALL_CROS else Cmd.ENTITY_CROS,
                getItemsToModify(entityType, None))
      GM.Globals[GM.ENTITY_CL_DELAY_START] = Cmd.Location()
      buildGAPIObject(API.DIRECTORY)
      return (Cmd.ENTITY_USERS if entityType != Cmd.ENTITY_ALL_CROS else Cmd.ENTITY_CROS,
              {'entityType': entityType, 'entity': None})
    if userAllowed:
      if entitySelector == Cmd.ENTITY_SELECTOR_FILE:
        return (Cmd.ENTITY_USERS, getItemsToModify(Cmd.ENTITY_USERS, getEntitiesFromFile(False)))
      if entitySelector in [Cmd.ENTITY_SELECTOR_CSV, Cmd.ENTITY_SELECTOR_CSVFILE]:
        return (Cmd.ENTITY_USERS, getItemsToModify(Cmd.ENTITY_USERS, getEntitiesFromCSVFile(False)))
    if crosAllowed:
      if entitySelector == Cmd.ENTITY_SELECTOR_CROSFILE:
        return (Cmd.ENTITY_CROS, getEntitiesFromFile(False))
      if entitySelector in [Cmd.ENTITY_SELECTOR_CROSCSV, Cmd.ENTITY_SELECTOR_CROSCSVFILE]:
        return (Cmd.ENTITY_CROS, getEntitiesFromCSVFile(False))
      if entitySelector == Cmd.ENTITY_SELECTOR_CROSFILE_SN:
        return (Cmd.ENTITY_CROS, getItemsToModify(Cmd.ENTITY_CROS_SN, getEntitiesFromFile(False)))
      if entitySelector in [Cmd.ENTITY_SELECTOR_CROSCSV_SN, Cmd.ENTITY_SELECTOR_CROSCSVFILE_SN]:
        return (Cmd.ENTITY_CROS, getItemsToModify(Cmd.ENTITY_CROS_SN, getEntitiesFromCSVFile(False)))
    if browserAllowed:
      if entitySelector == Cmd.ENTITY_SELECTOR_FILE:
        return (Cmd.ENTITY_BROWSER, getEntitiesFromFile(False))
      if entitySelector in [Cmd.ENTITY_SELECTOR_CSV, Cmd.ENTITY_SELECTOR_CSVFILE]:
        return (Cmd.ENTITY_BROWSER, getEntitiesFromCSVFile(False))
    if entitySelector == Cmd.ENTITY_SELECTOR_DATAFILE:
      if userAllowed:
        choices += Cmd.USER_ENTITY_SELECTOR_DATAFILE_CSVKMD_SUBTYPES if not GC.Values[GC.USER_SERVICE_ACCOUNT_ACCESS_ONLY] else [Cmd.ENTITY_USERS]
      if crosAllowed:
        choices += Cmd.CROS_ENTITY_SELECTOR_DATAFILE_CSVKMD_SUBTYPES
      entityType = mapEntityType(getChoice(choices), typeMap)
      return (Cmd.ENTITY_USERS if entityType not in Cmd.CROS_ENTITY_SELECTOR_DATAFILE_CSVKMD_SUBTYPES else Cmd.ENTITY_CROS,
              getItemsToModify(entityType, getEntitiesFromFile(shlexSplit=entityType in [Cmd.ENTITY_OUS, Cmd.ENTITY_OUS_AND_CHILDREN,
                                                                                         Cmd.ENTITY_OUS_NS, Cmd.ENTITY_OUS_AND_CHILDREN_NS,
                                                                                         Cmd.ENTITY_OUS_SUSP, Cmd.ENTITY_OUS_AND_CHILDREN_SUSP,
                                                                                         Cmd.ENTITY_CROS_OUS, Cmd.ENTITY_CROS_OUS_AND_CHILDREN])))
    if entitySelector == Cmd.ENTITY_SELECTOR_CSVDATAFILE:
      if userAllowed:
        choices += Cmd.USER_ENTITY_SELECTOR_DATAFILE_CSVKMD_SUBTYPES if not GC.Values[GC.USER_SERVICE_ACCOUNT_ACCESS_ONLY] else [Cmd.ENTITY_USERS]
      if crosAllowed:
        choices += Cmd.CROS_ENTITY_SELECTOR_DATAFILE_CSVKMD_SUBTYPES
      entityType = mapEntityType(getChoice(choices), typeMap)
      return (Cmd.ENTITY_USERS if entityType not in Cmd.CROS_ENTITY_SELECTOR_DATAFILE_CSVKMD_SUBTYPES else Cmd.ENTITY_CROS,
              getItemsToModify(entityType, getEntitiesFromCSVFile(shlexSplit=entityType in [Cmd.ENTITY_OUS, Cmd.ENTITY_OUS_AND_CHILDREN,
                                                                                            Cmd.ENTITY_OUS_NS, Cmd.ENTITY_OUS_AND_CHILDREN_NS,
                                                                                            Cmd.ENTITY_OUS_SUSP, Cmd.ENTITY_OUS_AND_CHILDREN_SUSP,
                                                                                            Cmd.ENTITY_CROS_OUS, Cmd.ENTITY_CROS_OUS_AND_CHILDREN])))
    if entitySelector == Cmd.ENTITY_SELECTOR_CSVKMD:
      if userAllowed:
        choices += Cmd.USER_ENTITY_SELECTOR_DATAFILE_CSVKMD_SUBTYPES if not GC.Values[GC.USER_SERVICE_ACCOUNT_ACCESS_ONLY] else [Cmd.ENTITY_USERS]
      if crosAllowed:
        choices += Cmd.CROS_ENTITY_SELECTOR_DATAFILE_CSVKMD_SUBTYPES
      entityType = mapEntityType(getChoice(choices, choiceAliases=Cmd.ENTITY_ALIAS_MAP), typeMap)
      return (Cmd.ENTITY_USERS if entityType not in Cmd.CROS_ENTITY_SELECTOR_DATAFILE_CSVKMD_SUBTYPES else Cmd.ENTITY_CROS,
              getItemsToModify(entityType, getEntitiesFromCSVbyField()))
    if entitySelector in [Cmd.ENTITY_SELECTOR_CSVDATA, Cmd.ENTITY_SELECTOR_CROSCSVDATA]:
      checkDataField()
      return (Cmd.ENTITY_USERS if entitySelector == Cmd.ENTITY_SELECTOR_CSVDATA else Cmd.ENTITY_CROS,
              GM.Globals[GM.CSV_DATA_DICT])
  entityChoices = []
  if userAllowed:
    entityChoices += Cmd.USER_ENTITIES if not GC.Values[GC.USER_SERVICE_ACCOUNT_ACCESS_ONLY] else [Cmd.ENTITY_USER, Cmd.ENTITY_USERS]
  if crosAllowed:
    entityChoices += Cmd.CROS_ENTITIES
  if browserAllowed:
    entityChoices += Cmd.BROWSER_ENTITIES
  entityType = mapEntityType(getChoice(entityChoices, choiceAliases=Cmd.ENTITY_ALIAS_MAP, defaultChoice=defaultEntityType), typeMap)
  if not entityType:
    invalidChoiceExit(Cmd.Current(), selectorChoices+entityChoices, False)
  if entityType not in Cmd.CROS_ENTITIES+Cmd.BROWSER_ENTITIES:
    entityClass = Cmd.ENTITY_USERS
    if entityType == Cmd.ENTITY_OAUTHUSER:
      return (entityClass, [_getAdminEmail()])
    entityItem = getString(Cmd.OB_USER_ENTITY, minLen=0)
  elif entityType in Cmd.CROS_ENTITIES:
    entityClass = Cmd.ENTITY_CROS
    entityItem = getString(Cmd.OB_CROS_ENTITY, minLen=0)
  else:
    entityClass = Cmd.ENTITY_BROWSER
    entityItem = getString(Cmd.OB_BROWSER_ENTITY, minLen=0)
  if not delayGet:
    if entityClass == Cmd.ENTITY_USERS:
      return (entityClass, getItemsToModify(entityType, entityItem,
                                            isSuspended=isSuspended, isArchived=isArchived, groupMemberType=groupMemberType))
    return (entityClass, getItemsToModify(entityType, entityItem))
  GM.Globals[GM.ENTITY_CL_DELAY_START] = Cmd.Location()
  if not GC.Values[GC.USER_SERVICE_ACCOUNT_ACCESS_ONLY]:
    buildGAPIObject(API.DIRECTORY)
  if entityClass == Cmd.ENTITY_USERS:
    if entityType in [Cmd.ENTITY_GROUP_USERS,
                      Cmd.ENTITY_GROUP_USERS_NS, Cmd.ENTITY_GROUP_USERS_SUSP,
                      Cmd.ENTITY_GROUP_USERS_SELECT,
                      Cmd.ENTITY_CIGROUP_USERS]:
      # Skip over sub-arguments
      while Cmd.ArgumentsRemaining():
        myarg = getArgument()
        if myarg in GROUP_ROLES_MAP or myarg in {'primarydomain', 'recursive', 'includederivedmembership'}:
          pass
        elif myarg == 'domains':
          Cmd.Advance()
        elif ((entityType == Cmd.ENTITY_GROUP_USERS_SELECT) and
              (myarg in SUSPENDED_ARGUMENTS) or (myarg in ARCHIVED_ARGUMENTS)):
          if myarg in {'issuspended', 'isarchived'}:
            if Cmd.PeekArgumentPresent(TRUE_VALUES) or Cmd.PeekArgumentPresent(FALSE_VALUES):
              Cmd.Advance()
        elif myarg == 'end':
          break
        else:
          Cmd.Backup()
          missingArgumentExit('end')
    return (entityClass,
            {'entityType': entityType, 'entity': entityItem, 'isSuspended': isSuspended, 'isArchived': isArchived,
             'groupMemberType': groupMemberType})
  if entityClass == Cmd.ENTITY_CROS:
    if entityType in {Cmd.ENTITY_CROS_OU_QUERY, Cmd.ENTITY_CROS_OU_AND_CHILDREN_QUERY, Cmd.ENTITY_CROS_OUS_QUERY, Cmd.ENTITY_CROS_OUS_AND_CHILDREN_QUERY,
                      Cmd.ENTITY_CROS_OU_QUERIES, Cmd.ENTITY_CROS_OU_AND_CHILDREN_QUERIES, Cmd.ENTITY_CROS_OUS_QUERIES, Cmd.ENTITY_CROS_OUS_AND_CHILDREN_QUERIES}:
      Cmd.Advance()
  return (entityClass,
          {'entityType': entityType, 'entity': entityItem})

def getEntitySelector():
  return getChoice(Cmd.ENTITY_LIST_SELECTORS, defaultChoice=None)

def getEntitySelection(entitySelector, shlexSplit):
  if entitySelector in [Cmd.ENTITY_SELECTOR_FILE]:
    return getEntitiesFromFile(shlexSplit)
  if entitySelector in [Cmd.ENTITY_SELECTOR_CSV, Cmd.ENTITY_SELECTOR_CSVFILE]:
    return getEntitiesFromCSVFile(shlexSplit)
  if entitySelector == Cmd.ENTITY_SELECTOR_CSVKMD:
    return getEntitiesFromCSVbyField()
  if entitySelector in [Cmd.ENTITY_SELECTOR_CSVSUBKEY]:
    checkSubkeyField()
    return GM.Globals[GM.CSV_DATA_DICT]
  if entitySelector in [Cmd.ENTITY_SELECTOR_CSVDATA]:
    checkDataField()
    return GM.Globals[GM.CSV_DATA_DICT]
  return []

def getEntityList(item, shlexSplit=False):
  entitySelector = getEntitySelector()
  if entitySelector:
    return getEntitySelection(entitySelector, shlexSplit)
  return convertEntityToList(getString(item, minLen=0), shlexSplit=shlexSplit)

def getNormalizedEmailAddressEntity(shlexSplit=False, noUid=True, noLower=False):
  return [normalizeEmailAddressOrUID(emailAddress, noUid=noUid, noLower=noLower) for emailAddress in getEntityList(Cmd.OB_EMAIL_ADDRESS_ENTITY, shlexSplit)]

def getUserObjectEntity(clObject, itemType, shlexSplit=False):
  entity = {'item': itemType, 'list': getEntityList(clObject, shlexSplit), 'dict': None}
  if isinstance(entity['list'], dict):
    entity['dict'] = entity['list']
  return entity

def _validateUserGetObjectList(user, i, count, entity, api=API.GMAIL, showAction=True):
  if entity['dict']:
    entityList = entity['dict'][user]
  else:
    entityList = entity['list']
  user, svc = buildGAPIServiceObject(api, user, i, count)
  if not svc:
    return (user, None, [], 0)
  jcount = len(entityList)
  if showAction:
    entityPerformActionNumItems([Ent.USER, user], jcount, entity['item'], i, count)
  if jcount == 0:
    setSysExitRC(NO_ENTITIES_FOUND_RC)
  return (user, svc, entityList, jcount)

def _validateUserGetMessageIds(user, i, count, entity):
  if entity:
    if entity['dict']:
      entityList = entity['dict'][user]
    else:
      entityList = entity['list']
  else:
    entityList = []
  user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
  if not gmail:
    return (user, None, None)
  return (user, gmail, entityList)

def checkUserExists(cd, user, entityType=Ent.USER, i=0, count=0):
  user = normalizeEmailAddressOrUID(user)
  try:
    return callGAPI(cd.users(), 'get',
                    throwReasons=GAPI.USER_GET_THROW_REASONS,
                    userKey=user, fields='primaryEmail')['primaryEmail']
  except (GAPI.userNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.backendError, GAPI.systemError):
    entityUnknownWarning(entityType, user, i, count)
    return None

def checkUserSuspended(cd, user, entityType=Ent.USER, i=0, count=0):
  user = normalizeEmailAddressOrUID(user)
  try:
    return callGAPI(cd.users(), 'get',
                    throwReasons=GAPI.USER_GET_THROW_REASONS,
                    userKey=user, fields='suspended')['suspended']
  except (GAPI.userNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.backendError, GAPI.systemError):
    entityUnknownWarning(entityType, user, i, count)
    return None

# Add attachements to an email message
def _addAttachmentsToMessage(message, attachments):
  for attachment in attachments:
    try:
      attachFilename = attachment[0]
      attachContentType, attachEncoding = mimetypes.guess_type(attachFilename)
      if attachContentType is None or attachEncoding is not None:
        attachContentType = 'application/octet-stream'
      main_type, sub_type = attachContentType.split('/', 1)
      if main_type == 'text':
        msg = MIMEText(readFile(attachFilename, 'r', attachment[1]), _subtype=sub_type, _charset=UTF8)
      elif main_type == 'image':
        msg = MIMEImage(readFile(attachFilename, 'rb'), _subtype=sub_type)
      elif main_type == 'audio':
        msg = MIMEAudio(readFile(attachFilename, 'rb'), _subtype=sub_type)
      elif main_type == 'application':
        msg = MIMEApplication(readFile(attachFilename, 'rb'), _subtype=sub_type)
      else:
        msg = MIMEBase(main_type, sub_type)
        msg.set_payload(readFile(attachFilename, 'rb'))
      msg.add_header('Content-Disposition', 'attachment', filename=os.path.basename(attachFilename))
      message.attach(msg)
    except (IOError, UnicodeDecodeError) as e:
      usageErrorExit(f'{attachFilename}: {str(e)}')

# Add embedded images to an email message
def _addEmbeddedImagesToMessage(message, embeddedImages):
  for embeddedImage in embeddedImages:
    try:
      imageFilename = embeddedImage[0]
      imageContentType, imageEncoding = mimetypes.guess_type(imageFilename)
      if imageContentType is None or imageEncoding is not None:
        imageContentType = 'application/octet-stream'
      main_type, sub_type = imageContentType.split('/', 1)
      if main_type == 'image':
        msg = MIMEImage(readFile(imageFilename, 'rb'), _subtype=sub_type)
      else:
        msg = MIMEBase(main_type, sub_type)
        msg.set_payload(readFile(imageFilename, 'rb'))
      msg.add_header('Content-Disposition', 'attachment', filename=os.path.basename(imageFilename))
      msg.add_header('Content-ID', f'<{embeddedImage[1]}>')
      message.attach(msg)
    except (IOError, UnicodeDecodeError) as e:
      usageErrorExit(f'{imageFilename}: {str(e)}')

NAME_EMAIL_ADDRESS_PATTERN = re.compile(r'^.*<(.+)>$')

# Send an email
def send_email(msgSubject, msgBody, msgTo, i=0, count=0, clientAccess=False, msgFrom=None, msgReplyTo=None,
               html=False, charset=UTF8, attachments=None, embeddedImages=None,
               msgHeaders=None, ccRecipients=None, bccRecipients=None, mailBox=None):
  def checkResult(entityType, recipients):
    if not recipients:
      return
    toSent = set(recipients.split(','))
    toFailed = {}
    for addr, err in iter(result.items()):
      if addr in toSent:
        toSent.remove(addr)
        toFailed[addr] = f'{err[0]}: {err[1]}'
    if toSent:
      entityActionPerformed([entityType, ','.join(toSent), Ent.MESSAGE, msgSubject], i, count)
    for addr, errMsg in iter(toFailed.items()):
      entityActionFailedWarning([entityType, addr, Ent.MESSAGE, msgSubject], errMsg, i, count)

  def cleanAddr(emailAddr):
    match = NAME_EMAIL_ADDRESS_PATTERN.match(emailAddr)
    if match:
      return match.group(1)
    return emailAddr

  if msgFrom is None:
    msgFrom = _getAdminEmail()
  # Force ASCII for RFC compliance
  # xmlcharref seems to work to display at least
  # some unicode in HTML body and is ignored in
  # plain text body.
#  msgBody = msgBody.encode('ascii', 'xmlcharrefreplace').decode(UTF8)
  if not attachments and not embeddedImages:
    message = MIMEText(msgBody, ['plain', 'html'][html], charset)
  else:
    message = MIMEMultipart()
    msg = MIMEText(msgBody, ['plain', 'html'][html], charset)
    message.attach(msg)
    if attachments:
      _addAttachmentsToMessage(message, attachments)
    if embeddedImages:
      _addEmbeddedImagesToMessage(message, embeddedImages)
  message['Subject'] = msgSubject
  message['From'] = msgFrom
  if msgReplyTo is not None:
    message['Reply-To'] = msgReplyTo
  if ccRecipients:
    message['Cc'] = ccRecipients.lower()
  if bccRecipients:
    message['Bcc'] = bccRecipients.lower()
  if msgHeaders:
    for header, value in iter(msgHeaders.items()):
      if header not in {'Subject', 'From', 'To', 'Reply-To', 'Cc', 'Bcc'}:
        message[header] = value
  if mailBox is None:
    mailBox = msgFrom
  mailBoxAddr = normalizeEmailAddressOrUID(cleanAddr(mailBox), noUid=True, noLower=True)
  action = Act.Get()
  Act.Set(Act.SENDEMAIL)
  if not GC.Values[GC.SMTP_HOST]:
    if not clientAccess:
      userId, gmail = buildGAPIServiceObject(API.GMAIL, mailBoxAddr)
      if not gmail:
        return
    else:
      userId = mailBoxAddr
      gmail = buildGAPIObject(API.GMAIL)
    message['To'] = msgTo if msgTo else userId
    try:
      result = callGAPI(gmail.users().messages(), 'send',
                        throwReasons=[GAPI.SERVICE_NOT_AVAILABLE, GAPI.AUTH_ERROR, GAPI.DOMAIN_POLICY,
                                      GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED],
                        userId=userId, body={'raw': base64.urlsafe_b64encode(message.as_bytes()).decode()}, fields='id')
      entityActionPerformedMessage([Ent.RECIPIENT, msgTo, Ent.MESSAGE, msgSubject], f"{result['id']}", i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy,
            GAPI.invalid, GAPI.invalidArgument, GAPI.forbidden, GAPI.permissionDenied) as e:
      entityActionFailedWarning([Ent.RECIPIENT, msgTo, Ent.MESSAGE, msgSubject], str(e), i, count)
  else:
    message['To'] = msgTo if msgTo else mailBoxAddr
    server = None
    try:
      server = smtplib.SMTP(GC.Values[GC.SMTP_HOST], 587, GC.Values[GC.SMTP_FQDN])
      if GC.Values[GC.DEBUG_LEVEL] > 0:
        server.set_debuglevel(1)
      server.starttls(context=ssl.create_default_context(cafile=GC.Values[GC.CACERTS_PEM]))
      if GC.Values[GC.SMTP_USERNAME] and GC.Values[GC.SMTP_PASSWORD]:
        if isinstance(GC.Values[GC.SMTP_PASSWORD], bytes):
          server.login(GC.Values[GC.SMTP_USERNAME], base64.b64decode(GC.Values[GC.SMTP_PASSWORD]).decode(UTF8))
        else:
          server.login(GC.Values[GC.SMTP_USERNAME], GC.Values[GC.SMTP_PASSWORD])
      result = server.send_message(message)
      checkResult(Ent.RECIPIENT, message['To'])
      checkResult(Ent.RECIPIENT_CC, ccRecipients)
      checkResult(Ent.RECIPIENT_BCC, bccRecipients)
    except smtplib.SMTPException as e:
      entityActionFailedWarning([Ent.RECIPIENT, msgTo, Ent.MESSAGE, msgSubject], str(e), i, count)
    if server:
      try:
        server.quit()
      except Exception:
        pass
  Act.Set(action)

def addFieldToFieldsList(fieldName, fieldsChoiceMap, fieldsList):
  fields = fieldsChoiceMap[fieldName.lower()]
  if isinstance(fields, list):
    fieldsList.extend(fields)
  else:
    fieldsList.append(fields)

def _getFieldsList():
  return getString(Cmd.OB_FIELD_NAME_LIST).lower().replace('_', '').replace(',', ' ').split()

def _getRawFields(requiredField=None):
  rawFields = getString(Cmd.OB_FIELDS)
  if requiredField is None or requiredField in rawFields:
    return rawFields
  return f'{requiredField},{rawFields}'

def CheckInputRowFilterHeaders(titlesList, rowFilter, rowDropFilter):
  status = True
  for filterVal in rowFilter:
    columns = [t for t in titlesList if filterVal[0].match(t)]
    if not columns:
      stderrErrorMsg(Msg.COLUMN_DOES_NOT_MATCH_ANY_INPUT_COLUMNS.format(GC.CSV_INPUT_ROW_FILTER, filterVal[0].pattern))
      status = False
  for filterVal in rowDropFilter:
    columns = [t for t in titlesList if filterVal[0].match(t)]
    if not columns:
      stderrErrorMsg(Msg.COLUMN_DOES_NOT_MATCH_ANY_INPUT_COLUMNS.format(GC.CSV_INPUT_ROW_DROP_FILTER, filterVal[0].pattern))
      status = False
  if not status:
    sys.exit(USAGE_ERROR_RC)

def RowFilterMatch(row, titlesList, rowFilter, rowFilterModeAll, rowDropFilter, rowDropFilterModeAll):
  def rowRegexFilterMatch(filterPattern):
    if anyMatch:
      for column in columns:
        if filterPattern.search(str(row.get(column, ''))):
          return True
      return False
    for column in columns:
      if not filterPattern.search(str(row.get(column, ''))):
        return False
    return True

  def rowNotRegexFilterMatch(filterPattern):
    if anyMatch:
      for column in columns:
        if filterPattern.search(str(row.get(column, ''))):
          return False
      return True
    for column in columns:
      if not filterPattern.search(str(row.get(column, ''))):
        return True

    return False

  def stripTimeFromDateTime(rowDate):
    if YYYYMMDD_PATTERN.match(rowDate):
      try:
        rowTime = datetime.datetime.strptime(rowDate, YYYYMMDD_FORMAT)
      except ValueError:
        return None
    else:
      try:
        rowTime, _ = iso8601.parse_date(rowDate)
      except (iso8601.ParseError, OverflowError):
        return None
    return ISOformatTimeStamp(datetime.datetime(rowTime.year, rowTime.month, rowTime.day, tzinfo=iso8601.UTC))

  def rowDateTimeFilterMatch(dateMode, op, filterDate):
    def checkMatch(rowDate):
      if not rowDate or not isinstance(rowDate, str):
        return False
      if rowDate == GC.Values[GC.NEVER_TIME]:
        rowDate = NEVER_TIME
      if dateMode:
        rowDate = stripTimeFromDateTime(rowDate)
        if not rowDate:
          return False
      if op == '<':
        return rowDate < filterDate
      if op == '<=':
        return rowDate <= filterDate
      if op == '>':
        return rowDate > filterDate
      if op == '>=':
        return rowDate >= filterDate
      if op == '!=':
        return rowDate != filterDate
      return rowDate == filterDate

    if anyMatch:
      for column in columns:
        if checkMatch(row.get(column, '')):
          return True
      return False
    for column in columns:
      if not checkMatch(row.get(column, '')):
        return False
    return True

  def rowDateTimeRangeFilterMatch(dateMode, op, filterDateL, filterDateR):
    def checkMatch(rowDate):
      if not rowDate or not isinstance(rowDate, str):
        return False
      if rowDate == GC.Values[GC.NEVER_TIME]:
        rowDate = NEVER_TIME
      if dateMode:
        rowDate = stripTimeFromDateTime(rowDate)
        if not rowDate:
          return False
      if op == '!=':
        return not filterDateL <= rowDate <= filterDateR
      return filterDateL <= rowDate <= filterDateR

    if anyMatch:
      for column in columns:
        if checkMatch(row.get(column, '')):
          return True
      return False
    for column in columns:
      if not checkMatch(row.get(column, '')):
        return False
    return True

  def getHourMinuteFromDateTime(rowDate):
    if YYYYMMDD_PATTERN.match(rowDate):
      return None
    try:
      rowTime, _ = iso8601.parse_date(rowDate)
    except (iso8601.ParseError, OverflowError):
      return None
    return f'{rowTime.hour:02d}:{rowTime.minute:02d}'

  def rowTimeOfDayRangeFilterMatch(op, startHourMinute, endHourMinute):
    def checkMatch(rowDate):
      if not rowDate or not isinstance(rowDate, str) or rowDate == GC.Values[GC.NEVER_TIME]:
        return False
      rowHourMinute = getHourMinuteFromDateTime(rowDate)
      if not rowHourMinute:
        return False
      if op == '!=':
        return not startHourMinute <= rowHourMinute <= endHourMinute
      return startHourMinute <= rowHourMinute <= endHourMinute

    if anyMatch:
      for column in columns:
        if checkMatch(row.get(column, '')):
          return True
      return False
    for column in columns:
      if not checkMatch(row.get(column, '')):
        return False
    return True

  def rowCountFilterMatch(op, filterCount):
    def checkMatch(rowCount):
      if isinstance(rowCount, str):
##### Blank = 0
        if not rowCount:
          rowCount = '0'
        elif not rowCount.isdigit():
          return False
        rowCount = int(rowCount)
      elif not isinstance(rowCount, int):
        return False
      if op == '<':
        return rowCount < filterCount
      if op == '<=':
        return rowCount <= filterCount
      if op == '>':
        return rowCount > filterCount
      if op == '>=':
        return rowCount >= filterCount
      if op == '!=':
        return rowCount != filterCount
      return rowCount == filterCount

    if anyMatch:
      for column in columns:
        if checkMatch(row.get(column, 0)):
          return True
      return False
    for column in columns:
      if not checkMatch(row.get(column, 0)):
        return False
    return True

  def rowCountRangeFilterMatch(op, filterCountL, filterCountR):
    def checkMatch(rowCount):
      if isinstance(rowCount, str):
        if not rowCount.isdigit():
          return False
        rowCount = int(rowCount)
      elif not isinstance(rowCount, int):
        return False
      if op == '!=':
        return not filterCountL <= rowCount <= filterCountR
      return filterCountL <= rowCount <= filterCountR

    if anyMatch:
      for column in columns:
        if checkMatch(row.get(column, 0)):
          return True
      return False
    for column in columns:
      if not checkMatch(row.get(column, 0)):
        return False
    return True

  def rowLengthFilterMatch(op, filterLength):
    def checkMatch(rowString):
      if not isinstance(rowString, str):
        return False
      rowLength = len(rowString)
      if op == '<':
        return rowLength < filterLength
      if op == '<=':
        return rowLength <= filterLength
      if op == '>':
        return rowLength > filterLength
      if op == '>=':
        return rowLength >= filterLength
      if op == '!=':
        return rowLength != filterLength
      return rowLength == filterLength

    if anyMatch:
      for column in columns:
        if checkMatch(row.get(column, '')):
          return True
      return False
    for column in columns:
      if not checkMatch(row.get(column, '')):
        return False
    return True

  def rowLengthRangeFilterMatch(op, filterLengthL, filterLengthR):
    def checkMatch(rowString):
      if not isinstance(rowString, str):
        return False
      rowLength = len(rowString)
      if op == '!=':
        return not filterLengthL <= rowLength <= filterLengthR
      return filterLengthL <= rowLength <= filterLengthR

    if anyMatch:
      for column in columns:
        if checkMatch(row.get(column, '')):
          return True
      return False
    for column in columns:
      if not checkMatch(row.get(column, '')):
        return False
    return True

  def rowBooleanFilterMatch(filterBoolean):
    def checkMatch(rowBoolean):
      if isinstance(rowBoolean, bool):
        return rowBoolean == filterBoolean
      if isinstance(rowBoolean, str):
        if rowBoolean.lower() in TRUE_FALSE:
          return rowBoolean.capitalize() == str(filterBoolean)
##### Blank = False
        if not rowBoolean:
          return not filterBoolean
      return False

    if anyMatch:
      for column in columns:
        if checkMatch(row.get(column, False)):
          return True
      return False
    for column in columns:
      if not checkMatch(row.get(column, False)):
        return False
    return True

  def rowDataFilterMatch(filterData):
    if anyMatch:
      for column in columns:
        if str(row.get(column, '')) in filterData:
          return True
      return False
    for column in columns:
      if not str(row.get(column, '')) in filterData:
        return False
    return True

  def rowNotDataFilterMatch(filterData):
    if anyMatch:
      for column in columns:
        if str(row.get(column, '')) in filterData:
          return False
      return True
    for column in columns:
      if not str(row.get(column, '')) in filterData:
        return True
    return False

  def rowTextFilterMatch(op, filterText):
    def checkMatch(rowText):
      if not isinstance(rowText, str):
        rowText = str(rowText)
      if op == '<':
        return rowText < filterText
      if op == '<=':
        return rowText <= filterText
      if op == '>':
        return rowText > filterText
      if op == '>=':
        return rowText >= filterText
      if op == '!=':
        return rowText != filterText
      return rowText == filterText

    if anyMatch:
      for column in columns:
        if checkMatch(row.get(column, '')):
          return True
      return False
    for column in columns:
      if not checkMatch(row.get(column, '')):
        return False
    return True

  def rowTextRangeFilterMatch(op, filterTextL, filterTextR):
    def checkMatch(rowText):
      if not isinstance(rowText, str):
        rowText = str(rowText)
      if op == '!=':
        return not filterTextL <= rowText <= filterTextR
      return filterTextL <= rowText <= filterTextR

    if anyMatch:
      for column in columns:
        if checkMatch(row.get(column, '')):
          return True
      return False
    for column in columns:
      if not checkMatch(row.get(column, '')):
        return False
    return True

  def filterMatch(filterVal):
    if filterVal[2] == 'regex':
      if rowRegexFilterMatch(filterVal[3]):
        return True
    elif filterVal[2] == 'notregex':
      if rowNotRegexFilterMatch(filterVal[3]):
        return True
    elif filterVal[2] in {'date', 'time'}:
      if rowDateTimeFilterMatch(filterVal[2] == 'date', filterVal[3], filterVal[4]):
        return True
    elif filterVal[2] in {'daterange', 'timerange'}:
      if rowDateTimeRangeFilterMatch(filterVal[2] == 'date', filterVal[3], filterVal[4], filterVal[5]):
        return True
    elif filterVal[2] == 'timeofdayrange':
      if rowTimeOfDayRangeFilterMatch(filterVal[3], filterVal[4], filterVal[5]):
        return True
    elif filterVal[2] == 'count':
      if rowCountFilterMatch(filterVal[3], filterVal[4]):
        return True
    elif filterVal[2] == 'countrange':
      if rowCountRangeFilterMatch(filterVal[3], filterVal[4], filterVal[5]):
        return True
    elif filterVal[2] == 'length':
      if rowLengthFilterMatch(filterVal[3], filterVal[4]):
        return True
    elif filterVal[2] == 'lengthrange':
      if rowLengthRangeFilterMatch(filterVal[3], filterVal[4], filterVal[5]):
        return True
    elif filterVal[2] == 'boolean':
      if rowBooleanFilterMatch(filterVal[3]):
        return True
    elif filterVal[2] == 'data':
      if rowDataFilterMatch(filterVal[3]):
        return True
    elif filterVal[2] == 'notdata':
      if rowNotDataFilterMatch(filterVal[3]):
        return True
    elif filterVal[2] == 'text':
      if rowTextFilterMatch(filterVal[3], filterVal[4]):
        return True
    elif filterVal[2] == 'textrange':
      if rowTextRangeFilterMatch(filterVal[3], filterVal[4], filterVal[5]):
        return True
    return False

  if rowFilter:
    anyMatches = False
    for filterVal in rowFilter:
      columns = [t for t in titlesList if filterVal[0].match(t)]
      if not columns:
        columns = [None]
      anyMatch = filterVal[1]
      if filterMatch(filterVal):
        if not rowFilterModeAll: # Any - any match selects
          anyMatches = True
          break
      else:
        if rowFilterModeAll: # All - any match failure doesn't select
          return False
    if not rowFilterModeAll and not anyMatches: # Any - no matches doesn't select
      return False
  if rowDropFilter:
    allMatches = True
    for filterVal in rowDropFilter:
      columns = [t for t in titlesList if filterVal[0].match(t)]
      if not columns:
        columns = [None]
      anyMatch = filterVal[1]
      if filterMatch(filterVal):
        if not rowDropFilterModeAll: # Any - any match drops
          return False
      else:
        if rowDropFilterModeAll: # All - any match failure doesn't drop
          allMatches = False
          break
    if rowDropFilterModeAll and allMatches: # All - all matches drops
      return False
  return True

# myarg is command line argument
# fieldChoiceMap maps myarg to API field names
#FIELD_CHOICE_MAP = {
#  'foo': 'foo',
#  'foobar': 'fooBar',
#  }
# fieldsList is the list of API fields
def getFieldsList(myarg, fieldsChoiceMap, fieldsList, initialField=None, fieldsArg='fields', onlyFieldsArg=False):
  def addInitialField():
    if isinstance(initialField, list):
      fieldsList.extend(initialField)
    else:
      fieldsList.append(initialField)

  def addMappedFields(mappedFields):
    if isinstance(mappedFields, list):
      fieldsList.extend(mappedFields)
    else:
      fieldsList.append(mappedFields)

  if not onlyFieldsArg and myarg in fieldsChoiceMap:
    if not fieldsList and initialField is not None:
      addInitialField()
    addMappedFields(fieldsChoiceMap[myarg])
  elif myarg == fieldsArg:
    if not fieldsList and initialField is not None:
      addInitialField()
    for field in _getFieldsList():
      if field in fieldsChoiceMap:
        addMappedFields(fieldsChoiceMap[field])
      else:
        invalidChoiceExit(field, fieldsChoiceMap, True)
  else:
    return False
  return True

def getFieldsFromFieldsList(fieldsList):
  if fieldsList:
    return ','.join(set(fieldsList)).replace('.', '/')
  return None

def getItemFieldsFromFieldsList(item, fieldsList, returnItemIfNoneList=False):
  if fieldsList:
    return f'nextPageToken,{item}({",".join(set(fieldsList))})'.replace('.', '/')
  if not returnItemIfNoneList:
    return None
  return f'nextPageToken,{item}'

class CSVPrintFile():

  def __init__(self, titles=None, sortTitles=None, indexedTitles=None):
    self.rows = []
    self.rowCount = 0
    self.outputTranspose = GM.Globals[GM.CSV_OUTPUT_TRANSPOSE]
    self.todrive = GM.Globals[GM.CSV_TODRIVE]
    self.titlesSet = set()
    self.titlesList = []
    self.JSONtitlesSet = set()
    self.JSONtitlesList = []
    self.sortHeaders = []
    self.SetHeaderForce(GC.Values[GC.CSV_OUTPUT_HEADER_FORCE])
    if not self.headerForce and titles is not None:
      self.SetTitles(titles)
      self.SetJSONTitles(titles)
    self.SetHeaderOrder(GC.Values[GC.CSV_OUTPUT_HEADER_ORDER])
    if GM.Globals.get(GM.CSV_OUTPUT_COLUMN_DELIMITER) is None:
      GM.Globals[GM.CSV_OUTPUT_COLUMN_DELIMITER] = GC.Values.get(GC.CSV_OUTPUT_COLUMN_DELIMITER, ',')
    self.SetColumnDelimiter(GM.Globals[GM.CSV_OUTPUT_COLUMN_DELIMITER])
    if GM.Globals.get(GM.CSV_OUTPUT_QUOTE_CHAR) is None:
      GM.Globals[GM.CSV_OUTPUT_QUOTE_CHAR] = GC.Values.get(GC.CSV_OUTPUT_QUOTE_CHAR, '"')
    if GM.Globals.get(GM.CSV_OUTPUT_NO_ESCAPE_CHAR) is None:
      GM.Globals[GM.CSV_OUTPUT_NO_ESCAPE_CHAR] = GC.Values.get(GC.CSV_OUTPUT_NO_ESCAPE_CHAR, False)
    self.SetNoEscapeChar(GM.Globals[GM.CSV_OUTPUT_NO_ESCAPE_CHAR])
    self.SetQuoteChar(GM.Globals[GM.CSV_OUTPUT_QUOTE_CHAR])
#    if GM.Globals.get(GM.CSV_OUTPUT_SORT_HEADERS) is None:
    if not GM.Globals.get(GM.CSV_OUTPUT_SORT_HEADERS):
      GM.Globals[GM.CSV_OUTPUT_SORT_HEADERS] = GC.Values.get(GC.CSV_OUTPUT_SORT_HEADERS, [])
    self.SetSortHeaders(GM.Globals[GM.CSV_OUTPUT_SORT_HEADERS])
#    if GM.Globals.get(GM.CSV_OUTPUT_TIMESTAMP_COLUMN) is None:
    if not GM.Globals.get(GM.CSV_OUTPUT_TIMESTAMP_COLUMN):
      GM.Globals[GM.CSV_OUTPUT_TIMESTAMP_COLUMN] = GC.Values.get(GC.CSV_OUTPUT_TIMESTAMP_COLUMN, '')
    self.SetTimestampColumn(GM.Globals[GM.CSV_OUTPUT_TIMESTAMP_COLUMN])
    self.SetFormatJSON(False)
    self.SetMapDrive3Titles(False)
    self.SetNodataFields(False, None, None, None, False)
    self.SetFixPaths(False)
    self.SetShowPermissionsLast(False)
    self.sortTitlesSet = set()
    self.sortTitlesList = []
    if sortTitles is not None:
      if not isinstance(sortTitles, str) or sortTitles != 'sortall':
        self.SetSortTitles(sortTitles)
      else:
        self.SetSortAllTitles()
    self.SetIndexedTitles(indexedTitles if indexedTitles is not None else [])
    self.SetHeaderFilter(GC.Values[GC.CSV_OUTPUT_HEADER_FILTER])
    self.SetHeaderDropFilter(GC.Values[GC.CSV_OUTPUT_HEADER_DROP_FILTER])
    self.SetRowFilter(GC.Values[GC.CSV_OUTPUT_ROW_FILTER], GC.Values[GC.CSV_OUTPUT_ROW_FILTER_MODE])
    self.SetRowDropFilter(GC.Values[GC.CSV_OUTPUT_ROW_DROP_FILTER], GC.Values[GC.CSV_OUTPUT_ROW_DROP_FILTER_MODE])
    self.SetRowLimit(GC.Values[GC.CSV_OUTPUT_ROW_LIMIT])
    self.SetZeroBlankMimeTypeCounts(False)

  def AddTitle(self, title):
    self.titlesSet.add(title)
    self.titlesList.append(title)

  def AddTitles(self, titles):
    for title in titles if isinstance(titles, list) else [titles]:
      if title not in self.titlesSet:
        self.AddTitle(title)

  def SetTitles(self, titles):
    self.titlesSet = set()
    self.titlesList = []
    self.AddTitles(titles)

  def RemoveTitles(self, titles):
    for title in titles if isinstance(titles, list) else [titles]:
      if title in self.titlesSet:
        self.titlesSet.remove(title)
        self.titlesList.remove(title)

  def MoveTitlesToEnd(self, titles):
    self.RemoveTitles(titles)
    self.AddTitles(titles)

  def MapTitles(self, ov, nv):
    if ov in self.titlesSet:
      self.titlesSet.remove(ov)
      self.titlesSet.add(nv)
      for i, v in enumerate(self.titlesList):
        if v == ov:
          self.titlesList[i] = nv
          break

  def AddSortTitle(self, title):
    self.sortTitlesSet.add(title)
    self.sortTitlesList.append(title)

  def AddSortTitles(self, titles):
    for title in titles if isinstance(titles, list) else [titles]:
      if title not in self.sortTitlesSet:
        self.AddSortTitle(title)

  def SetSortTitles(self, titles):
    self.sortTitlesSet = set()
    self.sortTitlesList = []
    self.AddSortTitles(titles)

  def SetSortAllTitles(self):
    self.sortTitlesList = self.titlesList[:]
    self.sortTitlesSet = set(self.sortTitlesList)

  def SetMapDrive3Titles(self, mapDrive3Titles):
    self.mapDrive3Titles = mapDrive3Titles

  def MapDrive3TitlesToDrive2(self):
    _mapDrive3TitlesToDrive2(self.titlesList, API.DRIVE3_TO_DRIVE2_FILES_FIELDS_MAP)
    self.titlesSet = set(self.titlesList)

  def AddJSONTitle(self, title):
    self.JSONtitlesSet.add(title)
    self.JSONtitlesList.append(title)

  def AddJSONTitles(self, titles):
    for title in titles if isinstance(titles, list) else [titles]:
      if title not in self.JSONtitlesSet:
        self.AddJSONTitle(title)

  def RemoveJSONTitles(self, titles):
    for title in titles if isinstance(titles, list) else [titles]:
      if title in self.JSONtitlesSet:
        self.JSONtitlesSet.remove(title)
        self.JSONtitlesList.remove(title)

  def MoveJSONTitlesToEnd(self, titles):
    for title in titles if isinstance(titles, list) else [titles]:
      if title in self.JSONtitlesList:
        self.JSONtitlesList.remove(title)
      self.JSONtitlesList.append(title)

  def SetJSONTitles(self, titles):
    self.JSONtitlesSet = set()
    self.JSONtitlesList = []
    self.AddJSONTitles(titles)

# fieldName is command line argument
# fieldNameMap maps fieldName to API field names; CSV file header will be API field name
#ARGUMENT_TO_PROPERTY_MAP = {
#  'admincreated': 'adminCreated',
#  'aliases': ['aliases', 'nonEditableAliases'],
#  }
# fieldsList is the list of API fields
  def AddField(self, fieldName, fieldNameMap, fieldsList):
    fields = fieldNameMap[fieldName.lower()]
    if isinstance(fields, list):
      for field in fields:
        if field not in fieldsList:
          fieldsList.append(field)
          self.AddTitles(field.replace('.', GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]))
    elif fields not in fieldsList:
      fieldsList.append(fields)
      self.AddTitles(fields.replace('.', GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]))

  def addInitialField(self, initialField, fieldsChoiceMap, fieldsList):
    if isinstance(initialField, list):
      for field in initialField:
        self.AddField(field, fieldsChoiceMap, fieldsList)
    else:
      self.AddField(initialField, fieldsChoiceMap, fieldsList)

  def GetFieldsListTitles(self, fieldName, fieldsChoiceMap, fieldsList, initialField=None):
    if fieldName in fieldsChoiceMap:
      if not fieldsList and initialField is not None:
        self.addInitialField(initialField, fieldsChoiceMap, fieldsList)
      self.AddField(fieldName, fieldsChoiceMap, fieldsList)
    elif fieldName == 'fields':
      if not fieldsList and initialField is not None:
        self.addInitialField(initialField, fieldsChoiceMap, fieldsList)
      for field in _getFieldsList():
        if field in fieldsChoiceMap:
          self.AddField(field, fieldsChoiceMap, fieldsList)
        else:
          invalidChoiceExit(field, fieldsChoiceMap, True)
    else:
      return False
    return True

  TDSHEET_ENTITY_MAP = {'tdsheet': 'sheetEntity', 'tdbackupsheet': 'backupSheetEntity', 'tdcopysheet': 'copySheetEntity'}
  TDSHARE_ACL_ROLES_MAP = {
    'commenter': 'commenter',
    'contributor': 'writer',
    'editor': 'writer',
    'read': 'reader',
    'reader': 'reader',
    'viewer': 'reader',
    'writer': 'writer',
    }


  def GetTodriveParameters(self):
    def invalidTodriveFileIdExit(entityValueList, message, location):
      Cmd.SetLocation(location-1)
      usageErrorExit(formatKeyValueList('', Ent.FormatEntityValueList([Ent.DRIVE_FILE_ID, self.todrive['fileId']]+entityValueList)+[message], ''))

    def invalidTodriveParentExit(entityType, message):
      Cmd.SetLocation(tdparentLocation-1)
      if not localParent:
        usageErrorExit(Msg.INVALID_ENTITY.format(Ent.Singular(entityType),
                                                 formatKeyValueList('',
                                                                    [Ent.Singular(Ent.CONFIG_FILE), GM.Globals[GM.GAM_CFG_FILE],
                                                                     Ent.Singular(Ent.ITEM), GC.TODRIVE_PARENT,
                                                                     Ent.Singular(Ent.VALUE), self.todrive['parent'],
                                                                     message],
                                                                    '')))
      else:
        usageErrorExit(Msg.INVALID_ENTITY.format(Ent.Singular(entityType), message))

    def invalidTodriveUserExit(entityType, message):
      Cmd.SetLocation(tduserLocation-1)
      if not localUser:
        usageErrorExit(Msg.INVALID_ENTITY.format(Ent.Singular(entityType),
                                                 formatKeyValueList('',
                                                                    [Ent.Singular(Ent.CONFIG_FILE), GM.Globals[GM.GAM_CFG_FILE],
                                                                     Ent.Singular(Ent.ITEM), GC.TODRIVE_USER,
                                                                     Ent.Singular(Ent.VALUE), self.todrive['user'],
                                                                     message],
                                                                    '')))
      else:
        usageErrorExit(Msg.INVALID_ENTITY.format(Ent.Singular(entityType), message))

    def getDriveObject():
      if not GC.Values[GC.TODRIVE_CLIENTACCESS]:
        _, drive = buildGAPIServiceObject(API.DRIVETD, self.todrive['user'])
        if not drive:
          invalidTodriveUserExit(Ent.USER, Msg.NOT_FOUND)
      else:
        drive = buildGAPIObject(API.DRIVE3)
      return drive

    CELL_WRAP_MAP = {'clip': 'CLIP', 'overflow': 'OVERFLOW_CELL', 'overflowcell': 'OVERFLOW_CELL', 'wrap': 'WRAP'}
    CELL_NUMBER_FORMAT_MAP = {'text': 'TEXT', 'number': 'NUMBER'}

    localUser = localParent = False
    tdfileidLocation = tdparentLocation = tdaddsheetLocation = tdupdatesheetLocation = tduserLocation = Cmd.Location()
    tdsheetLocation = {}
    for sheetEntity in iter(self.TDSHEET_ENTITY_MAP.values()):
      tdsheetLocation[sheetEntity] = Cmd.Location()
    self.todrive = {'user': GC.Values[GC.TODRIVE_USER], 'title': None, 'description': None,
                    'sheetEntity': None, 'addsheet': False, 'updatesheet': False, 'sheettitle': None,
                    'cellwrap': None, 'cellnumberformat': None, 'clearfilter': GC.Values[GC.TODRIVE_CLEARFILTER],
                    'backupSheetEntity': None, 'copySheetEntity': None,
                    'locale': GC.Values[GC.TODRIVE_LOCALE], 'timeZone': GC.Values[GC.TODRIVE_TIMEZONE],
                    'timestamp': GC.Values[GC.TODRIVE_TIMESTAMP], 'timeformat': GC.Values[GC.TODRIVE_TIMEFORMAT],
                    'noescapechar': GC.Values[GC.TODRIVE_NO_ESCAPE_CHAR],
                    'daysoffset': None, 'hoursoffset': None,
                    'sheettimestamp': GC.Values[GC.TODRIVE_SHEET_TIMESTAMP], 'sheettimeformat': GC.Values[GC.TODRIVE_SHEET_TIMEFORMAT],
                    'sheetdaysoffset': None, 'sheethoursoffset': None,
                    'fileId': None, 'parentId': None, 'parent': GC.Values[GC.TODRIVE_PARENT], 'retaintitle': False,
                    'localcopy': GC.Values[GC.TODRIVE_LOCALCOPY], 'uploadnodata': GC.Values[GC.TODRIVE_UPLOAD_NODATA],
                    'nobrowser': GC.Values[GC.TODRIVE_NOBROWSER], 'noemail': GC.Values[GC.TODRIVE_NOEMAIL], 'returnidonly': False,
                    'alert': [], 'share': [], 'notify': False, 'subject': None, 'from': None}
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg == 'tduser':
        self.todrive['user'] = getString(Cmd.OB_EMAIL_ADDRESS)
        tduserLocation = Cmd.Location()
        localUser = True
      elif myarg == 'tdtitle':
        self.todrive['title'] = getString(Cmd.OB_STRING, minLen=0)
      elif myarg == 'tddescription':
        self.todrive['description'] = getString(Cmd.OB_STRING)
      elif myarg in self.TDSHEET_ENTITY_MAP:
        sheetEntity = self.TDSHEET_ENTITY_MAP[myarg]
        tdsheetLocation[sheetEntity] = Cmd.Location()
        self.todrive[sheetEntity] = getSheetEntity(True)
      elif myarg == 'tdaddsheet':
        tdaddsheetLocation = Cmd.Location()
        self.todrive['addsheet'] = getBoolean()
        if self.todrive['addsheet']:
          self.todrive['updatesheet'] = False
      elif myarg == 'tdupdatesheet':
        tdupdatesheetLocation = Cmd.Location()
        self.todrive['updatesheet'] = getBoolean()
        if self.todrive['updatesheet']:
          self.todrive['addsheet'] = False
      elif myarg == 'tdcellwrap':
        self.todrive['cellwrap'] = getChoice(CELL_WRAP_MAP, mapChoice=True)
      elif myarg == 'tdcellnumberformat':
        self.todrive['cellnumberformat'] = getChoice(CELL_NUMBER_FORMAT_MAP, mapChoice=True)
      elif myarg == 'tdclearfilter':
        self.todrive['clearfilter'] = getBoolean()
      elif myarg == 'tdlocale':
        self.todrive['locale'] = getLanguageCode(LOCALE_CODES_MAP)
      elif myarg == 'tdtimezone':
        self.todrive['timeZone'] = getString(Cmd.OB_STRING, minLen=0)
      elif myarg == 'tdtimestamp':
        self.todrive['timestamp'] = getBoolean()
      elif myarg == 'tdtimeformat':
        self.todrive['timeformat'] = getString(Cmd.OB_STRING, minLen=0)
      elif myarg == 'tdsheettitle':
        self.todrive['sheettitle'] = getString(Cmd.OB_STRING, minLen=0)
      elif myarg == 'tdsheettimestamp':
        self.todrive['sheettimestamp'] = getBoolean()
      elif myarg == 'tdsheettimeformat':
        self.todrive['sheettimeformat'] = getString(Cmd.OB_STRING, minLen=0)
      elif myarg == 'tddaysoffset':
        self.todrive['daysoffset'] = getInteger(minVal=0)
      elif myarg == 'tdhoursoffset':
        self.todrive['hoursoffset'] = getInteger(minVal=0)
      elif myarg == 'tdsheetdaysoffset':
        self.todrive['sheetdaysoffset'] = getInteger(minVal=0)
      elif myarg == 'tdsheethoursoffset':
        self.todrive['sheethoursoffset'] = getInteger(minVal=0)
      elif myarg == 'tdfileid':
        self.todrive['fileId'] = getString(Cmd.OB_DRIVE_FILE_ID)
        tdfileidLocation = Cmd.Location()
      elif myarg == 'tdretaintitle':
        self.todrive['retaintitle'] = getBoolean()
      elif myarg == 'tdparent':
        self.todrive['parent'] = escapeDriveFileName(getString(Cmd.OB_DRIVE_FOLDER_NAME, minLen=0))
        tdparentLocation = Cmd.Location()
        localParent = True
      elif myarg == 'tdlocalcopy':
        self.todrive['localcopy'] = getBoolean()
      elif myarg == 'tduploadnodata':
        self.todrive['uploadnodata'] = getBoolean()
      elif myarg == 'tdnobrowser':
        self.todrive['nobrowser'] = getBoolean()
      elif myarg == 'tdnoemail':
        self.todrive['noemail'] = getBoolean()
      elif myarg == 'tdreturnidonly':
        self.todrive['returnidonly'] = getBoolean()
      elif myarg == 'tdnoescapechar':
        self.todrive['noescapechar'] = getBoolean()
      elif myarg == 'tdalert':
        self.todrive['alert'].append({'emailAddress': normalizeEmailAddressOrUID(getString(Cmd.OB_EMAIL_ADDRESS))})
      elif myarg == 'tdshare':
        self.todrive['share'].append({'emailAddress': normalizeEmailAddressOrUID(getString(Cmd.OB_EMAIL_ADDRESS)),
                                      'type': 'user',
                                      'role': getChoice(self.TDSHARE_ACL_ROLES_MAP, mapChoice=True)})
      elif myarg == 'tdnotify':
        self.todrive['notify'] = getBoolean()
      elif myarg == 'tdsubject':
        self.todrive['subject'] = getString(Cmd.OB_STRING, minLen=0)
      elif myarg == 'tdfrom':
        self.todrive['from'] = getString(Cmd.OB_EMAIL_ADDRESS)
      else:
        Cmd.Backup()
        break
    if self.todrive['addsheet']:
      if not self.todrive['fileId']:
        Cmd.SetLocation(tdaddsheetLocation-1)
        missingArgumentExit('tdfileid')
      if self.todrive['sheetEntity'] and self.todrive['sheetEntity']['sheetId']:
        Cmd.SetLocation(tdsheetLocation[sheetEntity]-1)
        invalidArgumentExit('tdsheet <String>')
    if self.todrive['updatesheet'] and (not self.todrive['fileId'] or not self.todrive['sheetEntity']):
      Cmd.SetLocation(tdupdatesheetLocation-1)
      missingArgumentExit('tdfileid and tdsheet')
    if self.todrive['sheetEntity'] and self.todrive['sheetEntity']['sheetId'] and (not self.todrive['fileId'] or not self.todrive['updatesheet']):
      Cmd.SetLocation(tdsheetLocation['sheetEntity']-1)
      missingArgumentExit('tdfileid and tdupdatesheet')
    if not self.todrive['user'] or GC.Values[GC.TODRIVE_CLIENTACCESS]:
      self.todrive['user'] = _getAdminEmail()
    if not GC.Values[GC.USER_SERVICE_ACCOUNT_ACCESS_ONLY] and not GC.Values[GC.TODRIVE_CLIENTACCESS]:
      user = checkUserExists(buildGAPIObject(API.DIRECTORY), self.todrive['user'])
      if not user:
        invalidTodriveUserExit(Ent.USER, Msg.NOT_FOUND)
      self.todrive['user'] = user
    else:
      self.todrive['user'] = normalizeEmailAddressOrUID(self.todrive['user'])
    if self.todrive['fileId']:
      drive = getDriveObject()
      try:
        result = callGAPI(drive.files(), 'get',
                          throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                          fileId=self.todrive['fileId'], fields='id,mimeType,capabilities(canEdit)', supportsAllDrives=True)
        if result['mimeType'] == MIMETYPE_GA_FOLDER:
          invalidTodriveFileIdExit([], Msg.NOT_AN_ENTITY.format(Ent.Singular(Ent.DRIVE_FILE)), tdfileidLocation)
        if not result['capabilities']['canEdit']:
          invalidTodriveFileIdExit([], Msg.NOT_WRITABLE, tdfileidLocation)
        if self.todrive['sheetEntity']:
          if result['mimeType'] != MIMETYPE_GA_SPREADSHEET:
            invalidTodriveFileIdExit([], f'{Msg.NOT_A} {Ent.Singular(Ent.SPREADSHEET)}', tdfileidLocation)
          if not GC.Values[GC.TODRIVE_CLIENTACCESS]:
            _, sheet = buildGAPIServiceObject(API.SHEETSTD, self.todrive['user'])
            if sheet is None:
              invalidTodriveUserExit(Ent.USER, Msg.NOT_FOUND)
          else:
            sheet = buildGAPIObject(API.SHEETS)
          try:
            spreadsheet = callGAPI(sheet.spreadsheets(), 'get',
                                   throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                                   spreadsheetId=self.todrive['fileId'],
                                   fields='spreadsheetUrl,sheets(properties(sheetId,title),protectedRanges(range(sheetId),requestingUserCanEdit))')
            for sheetEntity in iter(self.TDSHEET_ENTITY_MAP.values()):
              if self.todrive[sheetEntity]:
                sheetId = getSheetIdFromSheetEntity(spreadsheet, self.todrive[sheetEntity])
                if sheetId is None:
                  if not self.todrive['addsheet'] and ((sheetEntity != 'sheetEntity') or (self.todrive[sheetEntity]['sheetType'] == Ent.SHEET_ID)):
                    invalidTodriveFileIdExit([self.todrive[sheetEntity]['sheetType'], self.todrive[sheetEntity]['sheetValue']], Msg.NOT_FOUND, tdsheetLocation[sheetEntity])
                else:
                  if self.todrive['addsheet']:
                    invalidTodriveFileIdExit([self.todrive[sheetEntity]['sheetType'], self.todrive[sheetEntity]['sheetValue']], Msg.ALREADY_EXISTS, tdsheetLocation[sheetEntity])
                  if protectedSheetId(spreadsheet, sheetId):
                    invalidTodriveFileIdExit([self.todrive[sheetEntity]['sheetType'], self.todrive[sheetEntity]['sheetValue']], Msg.NOT_WRITABLE, tdsheetLocation[sheetEntity])
                self.todrive[sheetEntity]['sheetId'] = sheetId
          except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
                  GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
                  GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
            invalidTodriveFileIdExit([], str(e), tdfileidLocation)
      except GAPI.fileNotFound:
        invalidTodriveFileIdExit([], Msg.NOT_FOUND, tdfileidLocation)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        invalidTodriveUserExit(Ent.USER, str(e))
    elif not self.todrive['parent'] or self.todrive['parent'] == ROOT:
      self.todrive['parentId'] = ROOT
    else:
      drive = getDriveObject()
      if self.todrive['parent'].startswith('id:'):
        try:
          result = callGAPI(drive.files(), 'get',
                            throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FILE_NOT_FOUND, GAPI.INVALID],
                            fileId=self.todrive['parent'][3:], fields='id,mimeType,capabilities(canEdit)', supportsAllDrives=True)
        except GAPI.fileNotFound:
          invalidTodriveParentExit(Ent.DRIVE_FOLDER_ID, Msg.NOT_FOUND)
        except GAPI.invalid as e:
          invalidTodriveParentExit(Ent.DRIVE_FOLDER_ID, str(e))
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          invalidTodriveUserExit(Ent.USER, str(e))
        if result['mimeType'] != MIMETYPE_GA_FOLDER:
          invalidTodriveParentExit(Ent.DRIVE_FOLDER_ID, Msg.NOT_AN_ENTITY.format(Ent.Singular(Ent.DRIVE_FOLDER)))
        if not result['capabilities']['canEdit']:
          invalidTodriveParentExit(Ent.DRIVE_FOLDER_ID, Msg.NOT_WRITABLE)
        self.todrive['parentId'] = result['id']
      else:
        try:
          results = callGAPIpages(drive.files(), 'list', 'files',
                                  throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY],
                                  retryReasons=[GAPI.UNKNOWN_ERROR],
                                  q=f"name = '{self.todrive['parent']}'",
                                  fields='nextPageToken,files(id,mimeType,capabilities(canEdit))',
                                  pageSize=1, supportsAllDrives=True)
        except GAPI.invalidQuery:
          invalidTodriveParentExit(Ent.DRIVE_FOLDER_NAME, Msg.NOT_FOUND)
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          invalidTodriveUserExit(Ent.USER, str(e))
        if not results:
          invalidTodriveParentExit(Ent.DRIVE_FOLDER_NAME, Msg.NOT_FOUND)
        if results[0]['mimeType'] != MIMETYPE_GA_FOLDER:
          invalidTodriveParentExit(Ent.DRIVE_FOLDER_NAME, Msg.NOT_AN_ENTITY.format(Ent.Singular(Ent.DRIVE_FOLDER)))
        if not results[0]['capabilities']['canEdit']:
          invalidTodriveParentExit(Ent.DRIVE_FOLDER_NAME, Msg.NOT_WRITABLE)
        self.todrive['parentId'] = results[0]['id']

  def SortTitles(self):
    if not self.sortTitlesList:
      return
    restoreTitles = []
    for title in self.sortTitlesList:
      if title in self.titlesSet:
        self.titlesList.remove(title)
        restoreTitles.append(title)
    self.titlesList.sort()
    for title in restoreTitles[::-1]:
      self.titlesList.insert(0, title)

  def RemoveIndexedTitles(self, titles):
    for title in titles if isinstance(titles, list) else [titles]:
      if title in self.indexedTitles:
        self.indexedTitles.remove(title)

  def SetIndexedTitles(self, indexedTitles):
    self.indexedTitles = indexedTitles

  def SortIndexedTitles(self, titlesList):
    for field in self.indexedTitles:
      fieldDotN = re.compile(fr'({field}){GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}(\d+)(.*)')
      indexes = []
      subtitles = []
      for i, v in enumerate(titlesList):
        mg = fieldDotN.match(v)
        if mg:
          indexes.append(i)
          subtitles.append(mg.groups(''))
      for i, ii in enumerate(indexes):
        titlesList[ii] = [f'{subtitle[0]}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{subtitle[1]}{subtitle[2]}' for subtitle in sorted(subtitles, key=lambda k: (int(k[1]), k[2]))][i]

  @staticmethod
  def FixPathsTitles(titlesList):
# Put paths before path.0
    try:
      index = titlesList.index(f'path{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}0')
      titlesList.remove('paths')
      titlesList.insert(index, 'paths')
    except ValueError:
      pass

  def FixNodataTitles(self):
    if self.mapNodataFields:
      titles = []
      addPermissionsTitle = not self.oneItemPerRow
      for field in self.nodataFields:
        if field.find('(') != -1:
          field, subFields = field.split('(', 1)
          if field in self.driveListFields:
            if field != 'permissions':
              titles.append(field)
            elif addPermissionsTitle:
              titles.append(field)
              addPermissionsTitle = False
            titles.extend([f'{field}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}0{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{subField}' for subField in subFields[:-1].split(',') if subField])
          else:
            titles.extend([f'{field}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{subField}' for subField in subFields[:-1].split(',') if subField])
        elif field.find('.') != -1:
          field, subField = field.split('.', 1)
          if field in self.driveListFields:
            if field != 'permissions':
              titles.append(field)
            elif addPermissionsTitle:
              titles.append(field)
              addPermissionsTitle = False
            titles.append(f'{field}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}0{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{subField}')
          else:
            titles.append(f'{field}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{subField}')
        elif field.lower() in self.driveSubfieldsChoiceMap:
          if field in self.driveListFields:
            if field != 'permissions':
              titles.append(field)
            elif addPermissionsTitle:
              titles.append(field)
              addPermissionsTitle = False
            for subField in iter(self.driveSubfieldsChoiceMap[field.lower()].values()):
              if not isinstance(subField, list):
                titles.append(f'{field}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}0{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{subField}')
              else:
                titles.extend([f'{field}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}0{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{subSubField}' for subSubField in subField])
          else:
            for subField in iter(self.driveSubfieldsChoiceMap[field.lower()].values()):
              if not isinstance(subField, list):
                titles.append(f'{field}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{subField}')
              else:
                titles.extend([f'{field}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{subSubField}' for subSubField in subField])
        else:
          titles.append(field)
        if self.oneItemPerRow:
          for i, title in enumerate(titles):
            if title.startswith('permissions.0'):
              titles[i] = title.replace('permissions.0', 'permission')
      if not self.formatJSON:
        self.SetTitles(titles)
        self.SetSortTitles(['Owner', 'id', 'name', 'title'])
        self.SortTitles()
      else:
        self.SetJSONTitles(titles)
    else:
      self.SetTitles(self.nodataFields)
      self.SetJSONTitles(self.nodataFields)

  def MovePermsToEnd(self):
# Put permissions at end of titles
    try:
      last = len(self.titlesList)
      start = end = self.titlesList.index('permissions')
      while end < last and self.titlesList[end].startswith('permissions'):
        end += 1
      self.titlesList = self.titlesList[:start]+self.titlesList[end:]+self.titlesList[start:end]
    except ValueError:
      pass

  def SetColumnDelimiter(self, columnDelimiter):
    self.columnDelimiter = columnDelimiter

  def SetNoEscapeChar(self, noEscapeChar):
    self.noEscapeChar = noEscapeChar

  def SetQuoteChar(self, quoteChar):
    self.quoteChar = quoteChar

  def SetTimestampColumn(self, timestampColumn):
    self.timestampColumn = timestampColumn
    if not GC.Values[GC.OUTPUT_TIMEFORMAT]:
      self.todaysTime = ISOformatTimeStamp(todaysTime())
    else:
      self.todaysTime = todaysTime().strftime(GC.Values[GC.OUTPUT_TIMEFORMAT])

  def SetSortHeaders(self, sortHeaders):
    self.sortHeaders = sortHeaders

  def SetFormatJSON(self, formatJSON):
    self.formatJSON = formatJSON

  def SetNodataFields(self, mapNodataFields, nodataFields, driveListFields, driveSubfieldsChoiceMap, oneItemPerRow):
    self.mapNodataFields = mapNodataFields
    self.nodataFields = nodataFields
    self.driveListFields = driveListFields
    self.driveSubfieldsChoiceMap = driveSubfieldsChoiceMap
    self.oneItemPerRow = oneItemPerRow

  def SetFixPaths(self, fixPaths):
    self.fixPaths = fixPaths

  def SetShowPermissionsLast(self, showPermissionsLast):
    self.showPermissionsLast = showPermissionsLast

  def FixCourseAliasesTitles(self):
# Put Aliases.* after Aliases
    try:
      aliasesIndex = self.sortTitlesList.index('Aliases')
      index = self.titlesList.index('Aliases.0')
      tempSortTitlesList = self.sortTitlesList[:]
      self.SetSortTitles(tempSortTitlesList[:aliasesIndex+1])
      while self.titlesList[index].startswith('Aliases.'):
        self.AddSortTitle(self.titlesList[index])
        index += 1
      self.AddSortTitles(tempSortTitlesList[aliasesIndex+1:])
    except ValueError:
      pass

  def RearrangeCourseTitles(self, ttitles, stitles):
# Put teachers and students after courseMaterialSets if present, otherwise at end
    for title in ttitles['list']:
      if title in self.titlesList:
        self.titlesList.remove(title)
    for title in stitles['list']:
      if title in self.titlesList:
        self.titlesList.remove(title)
    try:
      cmsIndex = self.titlesList.index('courseMaterialSets')
      self.titlesList = self.titlesList[:cmsIndex]+ttitles['list']+stitles['list']+self.titlesList[cmsIndex:]
    except ValueError:
      self.titlesList.extend(ttitles['list'])
      self.titlesList.extend(stitles['list'])

  def SortRows(self, title, reverse):
    if title in self.titlesSet:
      self.rows.sort(key=lambda k: k[title], reverse=reverse)

  def SortRowsTwoTitles(self, title1, title2, reverse):
    if title1 in self.titlesSet and title2 in self.titlesSet:
      self.rows.sort(key=lambda k: (k[title1], k[title2]), reverse=reverse)

  def SetRowFilter(self, rowFilter, rowFilterMode):
    self.rowFilter = rowFilter
    self.rowFilterMode = rowFilterMode

  def SetRowDropFilter(self, rowDropFilter, rowDropFilterMode):
    self.rowDropFilter = rowDropFilter
    self.rowDropFilterMode = rowDropFilterMode

  def SetRowLimit(self, rowLimit):
    self.rowLimit = rowLimit

  def AppendRow(self, row):
    if self.timestampColumn:
      row[self.timestampColumn] = self.todaysTime
    if not self.rowLimit or self.rowCount < self.rowLimit:
      self.rowCount +=1
      self.rows.append(row)

  def WriteRowNoFilter(self, row):
    self.AppendRow(row)

  def WriteRow(self, row):
    if RowFilterMatch(row, self.titlesList, self.rowFilter, self.rowFilterMode, self.rowDropFilter, self.rowDropFilterMode):
      self.AppendRow(row)

  def WriteRowTitles(self, row):
    for title in row:
      if title not in self.titlesSet:
        self.AddTitle(title)
    if RowFilterMatch(row, self.titlesList, self.rowFilter, self.rowFilterMode, self.rowDropFilter, self.rowDropFilterMode):
      self.AppendRow(row)

  def WriteRowTitlesNoFilter(self, row):
    for title in row:
      if title not in self.titlesSet:
        self.AddTitle(title)
    self.AppendRow(row)

  def WriteRowTitlesJSONNoFilter(self, row):
    for title in row:
      if title not in self.JSONtitlesSet:
        self.AddJSONTitle(title)
    self.AppendRow(row)

  def CheckRowTitles(self, row):
    if not self.rowFilter and not self.rowDropFilter:
      return True
    for title in row:
      if title not in self.titlesSet:
        self.AddTitle(title)
    return RowFilterMatch(row, self.titlesList, self.rowFilter, self.rowFilterMode, self.rowDropFilter, self.rowDropFilterMode)

  def UpdateMimeTypeCounts(self, row, mimeTypeInfo, sizeField):
    saveList = self.titlesList[:]
    saveSet = set(self.titlesSet)
    for title in row:
      if title not in self.titlesSet:
        self.AddTitle(title)
    if RowFilterMatch(row, self.titlesList, self.rowFilter, self.rowFilterMode, self.rowDropFilter, self.rowDropFilterMode):
      mimeTypeInfo.setdefault(row['mimeType'], {'count': 0, 'size': 0})
      mimeTypeInfo[row['mimeType']]['count'] += 1
      mimeTypeInfo[row['mimeType']]['size'] += int(row.get(sizeField, '0'))
    self.titlesList = saveList[:]
    self.titlesSet = set(saveSet)

  def SetZeroBlankMimeTypeCounts(self, zeroBlankMimeTypeCounts):
    self.zeroBlankMimeTypeCounts = zeroBlankMimeTypeCounts

  def ZeroBlankMimeTypeCounts(self):
    for row in self.rows:
      for title in self.titlesList:
        if title not in self.sortTitlesSet and title not in row:
          row[title] = 0

  def CheckOutputRowFilterHeaders(self):
    for filterVal in self.rowFilter:
      columns = [t for t in self.titlesList if filterVal[0].match(t)]
      if not columns:
        stderrWarningMsg(Msg.COLUMN_DOES_NOT_MATCH_ANY_OUTPUT_COLUMNS.format(GC.CSV_OUTPUT_ROW_FILTER, filterVal[0].pattern))
    for filterVal in self.rowDropFilter:
      columns = [t for t in self.titlesList if filterVal[0].match(t)]
      if not columns:
        stderrWarningMsg(Msg.COLUMN_DOES_NOT_MATCH_ANY_OUTPUT_COLUMNS.format(GC.CSV_OUTPUT_ROW_DROP_FILTER, filterVal[0].pattern))

  def SetHeaderFilter(self, headerFilter):
    self.headerFilter = headerFilter

  def SetHeaderDropFilter(self, headerDropFilter):
    self.headerDropFilter = headerDropFilter

  def SetHeaderForce(self, headerForce):
    self.headerForce = headerForce
    self.SetTitles(headerForce)
    self.SetJSONTitles(headerForce)

  def SetHeaderOrder(self, headerOrder):
    self.headerOrder = headerOrder

  def orderHeaders(self, titlesList):
    for title in self.headerOrder:
      if title in titlesList:
        titlesList.remove(title)
    return self.headerOrder+titlesList

  @staticmethod
  def HeaderFilterMatch(filters, title):
    for filterStr in filters:
      if filterStr.match(title):
        return True
    return False

  def FilterHeaders(self):
    if self.headerDropFilter:
      self.titlesList = [t for t in self.titlesList if not self.HeaderFilterMatch(self.headerDropFilter, t)]
    if self.headerFilter:
      self.titlesList = [t for t in self.titlesList if self.HeaderFilterMatch(self.headerFilter, t)]
    self.titlesSet = set(self.titlesList)
    if not self.titlesSet:
      systemErrorExit(USAGE_ERROR_RC, Msg.NO_COLUMNS_SELECTED_WITH_CSV_OUTPUT_HEADER_FILTER.format(GC.CSV_OUTPUT_HEADER_FILTER, GC.CSV_OUTPUT_HEADER_DROP_FILTER))

  def FilterJSONHeaders(self):
    if self.headerDropFilter:
      self.JSONtitlesList = [t for t in self.JSONtitlesList if not self.HeaderFilterMatch(self.headerDropFilter, t)]
    if self.headerFilter:
      self.JSONtitlesList = [t for t in self.JSONtitlesList if self.HeaderFilterMatch(self.headerFilter, t)]
    self.JSONtitlesSet = set(self.JSONtitlesList)
    if not self.JSONtitlesSet:
      systemErrorExit(USAGE_ERROR_RC, Msg.NO_COLUMNS_SELECTED_WITH_CSV_OUTPUT_HEADER_FILTER.format(GC.CSV_OUTPUT_HEADER_FILTER, GC.CSV_OUTPUT_HEADER_DROP_FILTER))

  def writeCSVfile(self, list_type, clearRowFilters=False):

    def todriveCSVErrorExit(entityValueList, errMsg):
      systemErrorExit(ACTION_FAILED_RC, formatKeyValueList(Ind.Spaces(),
                                                           Ent.FormatEntityValueList(entityValueList)+[Act.NotPerformed(), errMsg],
                                                           currentCountNL(0, 0)))

    @staticmethod
    def itemgetter(*items):
      if len(items) == 1:
        item = items[0]
        def g(obj):
          return obj.get(item, '')
      else:
        def g(obj):
          return tuple(obj.get(item, '') for item in items)
      return g

    def writeCSVData(writer):
      try:
        if not self.outputTranspose:
          if GM.Globals[GM.CSVFILE][GM.REDIRECT_WRITE_HEADER]:
            writer.writerow(dict((item, item) for item in writer.fieldnames))
          if not self.sortHeaders:
            writer.writerows(self.rows)
          else:
            for row in sorted(self.rows, key=itemgetter(*self.sortHeaders)):
              writer.writerow(row)
        else:
          writer.writerows(self.rows)
        return True
      except IOError as e:
        stderrErrorMsg(e)
        return False

    def setDialect(lineterminator, noEscapeChar):
      writerDialect = {
        'delimiter': self.columnDelimiter,
        'doublequote': True,
        'escapechar': '\\' if not noEscapeChar else None,
        'lineterminator': lineterminator,
        'quotechar': self.quoteChar,
        'quoting': csv.QUOTE_MINIMAL,
        'skipinitialspace': False,
        'strict': False}
      return writerDialect

    def normalizeSortHeaders():
      if self.sortHeaders:
        writerKeyMap = {}
        for k in titlesList:
          writerKeyMap[k.lower()] = k
        self.sortHeaders = [writerKeyMap[k.lower()] for k in self.sortHeaders if k.lower() in writerKeyMap]

    def writeCSVToStdout():
      csvFile = StringIOobject()
      writerDialect = setDialect('\n', self.noEscapeChar)
      writer = csv.DictWriter(csvFile, titlesList, extrasaction=extrasaction, **writerDialect)
      if writeCSVData(writer):
        try:
          GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD].write(csvFile.getvalue())
        except IOError as e:
          stderrErrorMsg(fdErrorMessage(GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD], 'stdout', e))
          setSysExitRC(FILE_ERROR_RC)
      closeFile(csvFile)

    def writeCSVToFile():
      csvFile = openFile(GM.Globals[GM.CSVFILE][GM.REDIRECT_NAME], GM.Globals[GM.CSVFILE][GM.REDIRECT_MODE], newline='',
                         encoding=GM.Globals[GM.CSVFILE][GM.REDIRECT_ENCODING], errors='backslashreplace',
                         continueOnError=True)
      if csvFile:
        writerDialect = setDialect(str(GC.Values[GC.CSV_OUTPUT_LINE_TERMINATOR]), self.noEscapeChar)
        writer = csv.DictWriter(csvFile, titlesList, extrasaction=extrasaction, **writerDialect)
        writeCSVData(writer)
        closeFile(csvFile)

    def writeCSVToDrive():
      numRows = len(self.rows)
      numColumns = len(titlesList)
      if numRows == 0 and not self.todrive['uploadnodata']:
        printKeyValueList([Msg.NO_CSV_DATA_TO_UPLOAD])
        setSysExitRC(NO_CSV_DATA_TO_UPLOAD_RC)
        return
      if self.todrive['addsheet'] or self.todrive['updatesheet']:
        csvFile = TemporaryFile(mode='w+', encoding=UTF8)
      else:
        csvFile = StringIOobject()
      writerDialect = setDialect('\n', self.todrive['noescapechar'])
      writer = csv.DictWriter(csvFile, titlesList, extrasaction=extrasaction, **writerDialect)
      if writeCSVData(writer):
        if ((self.todrive['title'] is None) or
             (not self.todrive['title'] and not self.todrive['timestamp'])):
          title = f'{GC.Values[GC.DOMAIN]} - {list_type}'
        else:
          title = self.todrive['title']
        if ((self.todrive['sheettitle'] is None) or
            (not self.todrive['sheettitle'] and not self.todrive['sheettimestamp'])):
          if ((self.todrive['sheetEntity'] is None) or
              (not self.todrive['sheetEntity']['sheetTitle'])):
            sheetTitle = title
          else:
            sheetTitle = self.todrive['sheetEntity']['sheetTitle']
        else:
          sheetTitle = self.todrive['sheettitle']
        tdbasetime = tdtime = datetime.datetime.now(GC.Values[GC.TIMEZONE])
        if self.todrive['daysoffset'] is not None or self.todrive['hoursoffset'] is not None:
          tdtime = tdbasetime+relativedelta(days=-self.todrive['daysoffset'] if self.todrive['daysoffset'] is not None else 0,
                                            hours=-self.todrive['hoursoffset'] if self.todrive['hoursoffset'] is not None else 0)
        if self.todrive['timestamp']:
          if title:
            title += ' - '
          if not self.todrive['timeformat']:
            title += ISOformatTimeStamp(tdtime)
          else:
            title += tdtime.strftime(self.todrive['timeformat'])
        if self.todrive['sheettimestamp']:
          if self.todrive['sheetdaysoffset'] is not None or self.todrive['sheethoursoffset'] is not None:
            tdtime = tdbasetime+relativedelta(days=-self.todrive['sheetdaysoffset'] if self.todrive['sheetdaysoffset'] is not None else 0,
                                              hours=-self.todrive['sheethoursoffset'] if self.todrive['sheethoursoffset'] is not None else 0)
          if sheetTitle:
            sheetTitle += ' - '
          if not self.todrive['sheettimeformat']:
            sheetTitle += ISOformatTimeStamp(tdtime)
          else:
            sheetTitle += tdtime.strftime(self.todrive['sheettimeformat'])
        action = Act.Get()
        if not GC.Values[GC.TODRIVE_CLIENTACCESS]:
          user, drive = buildGAPIServiceObject(API.DRIVETD, self.todrive['user'])
          if not drive:
            closeFile(csvFile)
            return
        else:
          user = self.todrive['user']
          drive = buildGAPIObject(API.DRIVE3)
        importSize = csvFile.tell()
# Add/Update sheet
        try:
          if self.todrive['addsheet'] or self.todrive['updatesheet']:
            Act.Set(Act.CREATE if self.todrive['addsheet'] else Act.UPDATE)
            result = callGAPI(drive.about(), 'get',
                              throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                              fields='maxImportSizes')
            if numRows*numColumns > MAX_GOOGLE_SHEET_CELLS or importSize > int(result['maxImportSizes'][MIMETYPE_GA_SPREADSHEET]):
              todriveCSVErrorExit([Ent.USER, user], Msg.RESULTS_TOO_LARGE_FOR_GOOGLE_SPREADSHEET)
            fields = ','.join(['id', 'mimeType', 'webViewLink', 'name', 'capabilities(canEdit)'])
            body = {'description': self.todrive['description']}
            if body['description'] is None:
              body['description'] = Cmd.QuotedArgumentList(Cmd.AllArguments())
            if not self.todrive['retaintitle']:
              body['name'] = title
            result = callGAPI(drive.files(), 'update',
                              throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                          GAPI.FILE_NOT_FOUND, GAPI.UNKNOWN_ERROR],
                              fileId=self.todrive['fileId'], body=body, fields=fields, supportsAllDrives=True)
            entityValueList = [Ent.USER, user, Ent.DRIVE_FILE_ID, self.todrive['fileId']]
            if not result['capabilities']['canEdit']:
              todriveCSVErrorExit(entityValueList, Msg.NOT_WRITABLE)
            if result['mimeType'] != MIMETYPE_GA_SPREADSHEET:
              todriveCSVErrorExit(entityValueList, f'{Msg.NOT_A} {Ent.Singular(Ent.SPREADSHEET)}')
            if not GC.Values[GC.TODRIVE_CLIENTACCESS]:
              _, sheet = buildGAPIServiceObject(API.SHEETSTD, user)
              if sheet is None:
                return
            else:
              sheet = buildGAPIObject(API.SHEETS)
            csvFile.seek(0)
            spreadsheet = None
            if self.todrive['updatesheet']:
              for sheetEntity in iter(self.TDSHEET_ENTITY_MAP.values()):
                if self.todrive[sheetEntity]:
                  entityValueList = [Ent.USER, user, Ent.SPREADSHEET, title, self.todrive[sheetEntity]['sheetType'], self.todrive[sheetEntity]['sheetValue']]
                  if spreadsheet is None:
                    spreadsheet = callGAPI(sheet.spreadsheets(), 'get',
                                           throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                                           spreadsheetId=self.todrive['fileId'],
                                           fields='spreadsheetUrl,sheets(properties(sheetId,title),protectedRanges(range(sheetId),requestingUserCanEdit))')
                  sheetId = getSheetIdFromSheetEntity(spreadsheet, self.todrive[sheetEntity])
                  if sheetId is None:
                    if ((sheetEntity != 'sheetEntity') or (self.todrive[sheetEntity]['sheetType'] == Ent.SHEET_ID)):
                      todriveCSVErrorExit(entityValueList, Msg.NOT_FOUND)
                    self.todrive['addsheet'] = True
                  else:
                    if protectedSheetId(spreadsheet, sheetId):
                      todriveCSVErrorExit(entityValueList, Msg.NOT_WRITABLE)
                    self.todrive[sheetEntity]['sheetId'] = sheetId
            if self.todrive['addsheet']:
              body = {'requests': [{'addSheet': {'properties': {'title': sheetTitle, 'sheetType': 'GRID'}}}]}
              try:
                addresult = callGAPI(sheet.spreadsheets(), 'batchUpdate',
                                     throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                                     spreadsheetId=self.todrive['fileId'], body=body)
                self.todrive['sheetEntity'] = {'sheetId': addresult['replies'][0]['addSheet']['properties']['sheetId']}
              except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
                      GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions, GAPI.badRequest,
                      GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
                todriveCSVErrorExit(entityValueList, str(e))
            body = {'requests': []}
            if not self.todrive['addsheet']:
              if self.todrive['backupSheetEntity']:
                body['requests'].append({'copyPaste': {'source': {'sheetId': self.todrive['sheetEntity']['sheetId']},
                                                       'destination': {'sheetId': self.todrive['backupSheetEntity']['sheetId']}, 'pasteType': 'PASTE_NORMAL'}})
              if self.todrive['clearfilter']:
                body['requests'].append({'clearBasicFilter': {'sheetId': self.todrive['sheetEntity']['sheetId']}})
              if self.todrive['sheettitle']:
                body['requests'].append({'updateSheetProperties':
                                           {'properties': {'sheetId': self.todrive['sheetEntity']['sheetId'], 'title': sheetTitle}, 'fields': 'title'}})
            body['requests'].append({'updateCells': {'range': {'sheetId': self.todrive['sheetEntity']['sheetId']}, 'fields': '*'}})
            if self.todrive['cellwrap']:
              body['requests'].append({'repeatCell': {'range': {'sheetId': self.todrive['sheetEntity']['sheetId']},
                                                      'fields': 'userEnteredFormat.wrapStrategy',
                                                      'cell': {'userEnteredFormat': {'wrapStrategy': self.todrive['cellwrap']}}}})
            if self.todrive['cellnumberformat']:
              body['requests'].append({'repeatCell': {'range': {'sheetId': self.todrive['sheetEntity']['sheetId']},
                                                      'fields': 'userEnteredFormat.numberFormat',
                                                      'cell': {'userEnteredFormat': {'numberFormat': {'type': self.todrive['cellnumberformat']}}}}})
            body['requests'].append({'pasteData': {'coordinate': {'sheetId': self.todrive['sheetEntity']['sheetId'], 'rowIndex': '0', 'columnIndex': '0'},
                                                   'data': csvFile.read(), 'type': 'PASTE_NORMAL', 'delimiter': self.columnDelimiter}})
            if self.todrive['copySheetEntity']:
              body['requests'].append({'copyPaste': {'source': {'sheetId': self.todrive['sheetEntity']['sheetId']},
                                                     'destination': {'sheetId': self.todrive['copySheetEntity']['sheetId']}, 'pasteType': 'PASTE_NORMAL'}})
            try:
              callGAPI(sheet.spreadsheets(), 'batchUpdate',
                       throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                       spreadsheetId=self.todrive['fileId'], body=body)
            except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
                    GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
                    GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
              todriveCSVErrorExit(entityValueList, str(e))
            closeFile(csvFile)
# Create/update file
          else:
            if GC.Values[GC.TODRIVE_CONVERSION]:
              result = callGAPI(drive.about(), 'get',
                                throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                                fields='maxImportSizes')
              if numRows*len(titlesList) > MAX_GOOGLE_SHEET_CELLS or importSize > int(result['maxImportSizes'][MIMETYPE_GA_SPREADSHEET]):
                printKeyValueList([WARNING, Msg.RESULTS_TOO_LARGE_FOR_GOOGLE_SPREADSHEET])
                mimeType = 'text/csv'
              else:
                mimeType = MIMETYPE_GA_SPREADSHEET
            else:
              mimeType = 'text/csv'
            fields = ','.join(['id', 'mimeType', 'webViewLink'])
            body = {'description': self.todrive['description'], 'mimeType': mimeType}
            if body['description'] is None:
              body['description'] = Cmd.QuotedArgumentList(Cmd.AllArguments())
            if not self.todrive['fileId'] or not self.todrive['retaintitle']:
              body['name'] = title
            try:
              if not self.todrive['fileId']:
                Act.Set(Act.CREATE)
                body['parents'] = [self.todrive['parentId']]
                result = callGAPI(drive.files(), 'create',
                                  bailOnInternalError=True,
                                  throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                              GAPI.FILE_NOT_FOUND, GAPI.UNKNOWN_ERROR, GAPI.INTERNAL_ERROR, GAPI.STORAGE_QUOTA_EXCEEDED,
                                                                              GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP],
                                  body=body,
                                  media_body=googleapiclient.http.MediaIoBaseUpload(io.BytesIO(csvFile.getvalue().encode()), mimetype='text/csv', resumable=True),
                                  fields=fields, supportsAllDrives=True)
              else:
                Act.Set(Act.UPDATE)
                result = callGAPI(drive.files(), 'update',
                                  bailOnInternalError=True,
                                  throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                              GAPI.FILE_NOT_FOUND, GAPI.UNKNOWN_ERROR, GAPI.INTERNAL_ERROR],
                                  fileId=self.todrive['fileId'],
                                  body=body,
                                  media_body=googleapiclient.http.MediaIoBaseUpload(io.BytesIO(csvFile.getvalue().encode()), mimetype='text/csv', resumable=True),
                                  fields=fields, supportsAllDrives=True)
              spreadsheetId = result['id']
            except GAPI.internalError as e:
              entityActionFailedWarning([Ent.DRIVE_FILE, body['name']], Msg.UPLOAD_CSV_FILE_INTERNAL_ERROR.format(str(e), str(numRows)))
              closeFile(csvFile)
              return
            closeFile(csvFile)
            if not self.todrive['fileId'] and self.todrive['share']:
              Act.Set(Act.SHARE)
              for share in self.todrive['share']:
                if share['emailAddress'] != user:
                  try:
                    callGAPI(drive.permissions(), 'create',
                             bailOnInternalError=True,
                             throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+GAPI.DRIVE3_CREATE_ACL_THROW_REASONS,
                             fileId=spreadsheetId, sendNotificationEmail=False, body=share, fields='', supportsAllDrives=True)
                    entityActionPerformed([Ent.USER, user, Ent.SPREADSHEET, title,
                                           Ent.TARGET_USER, share['emailAddress'], Ent.ROLE, share['role']])
                  except (GAPI.badRequest, GAPI.invalid, GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError,
                          GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions, GAPI.unknownError, GAPI.ownershipChangeAcrossDomainNotPermitted,
                          GAPI.teamDriveDomainUsersOnlyRestriction, GAPI.teamDriveTeamMembersOnlyRestriction,
                          GAPI.targetUserRoleLimitedByLicenseRestriction, GAPI.insufficientAdministratorPrivileges, GAPI.sharingRateLimitExceeded,
                          GAPI.publishOutNotPermitted, GAPI.shareInNotPermitted, GAPI.shareOutNotPermitted, GAPI.shareOutNotPermittedToUser,
                          GAPI.cannotShareTeamDriveTopFolderWithAnyoneOrDomains, GAPI.cannotShareTeamDriveWithNonGoogleAccounts,
                          GAPI.ownerOnTeamDriveItemNotSupported,
                          GAPI.organizerOnNonTeamDriveNotSupported, GAPI.organizerOnNonTeamDriveItemNotSupported,
                          GAPI.fileOrganizerNotYetEnabledForThisTeamDrive,
                          GAPI.fileOrganizerOnFoldersInSharedDriveOnly,
                          GAPI.fileOrganizerOnNonTeamDriveNotSupported,
                          GAPI.teamDrivesFolderSharingNotSupported, GAPI.invalidLinkVisibility,
                          GAPI.invalidSharingRequest, GAPI.fileNeverWritable, GAPI.abusiveContentRestriction) as e:
                    entityActionFailedWarning([Ent.USER, user, Ent.SPREADSHEET, title,
                                               Ent.TARGET_USER, share['emailAddress'], Ent.ROLE, share['role']],
                                              str(e))
            if ((result['mimeType'] == MIMETYPE_GA_SPREADSHEET) and
                (self.todrive['sheetEntity'] or self.todrive['locale'] or self.todrive['timeZone'] or
                 self.todrive['sheettitle'] or self.todrive['cellwrap'] or self.todrive['cellnumberformat'])):
              if not GC.Values[GC.TODRIVE_CLIENTACCESS]:
                _, sheet = buildGAPIServiceObject(API.SHEETSTD, user)
                if sheet is None:
                  return
              else:
                sheet = buildGAPIObject(API.SHEETS)
              try:
                body = {'requests': []}
                if self.todrive['sheetEntity'] or self.todrive['sheettitle'] or self.todrive['cellwrap']:
                  spreadsheet = callGAPI(sheet.spreadsheets(), 'get',
                                         throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                                         spreadsheetId=spreadsheetId, fields='sheets/properties')
                  spreadsheet['sheets'][0]['properties']['title'] = sheetTitle
                  body['requests'].append({'updateSheetProperties':
                                           {'properties': spreadsheet['sheets'][0]['properties'], 'fields': 'title'}})
                  if self.todrive['cellwrap']:
                    body['requests'].append({'repeatCell': {'range': {'sheetId': spreadsheet['sheets'][0]['properties']['sheetId']},
                                                            'fields': 'userEnteredFormat.wrapStrategy',
                                                            'cell': {'userEnteredFormat': {'wrapStrategy': self.todrive['cellwrap']}}}})
                if self.todrive['locale']:
                  body['requests'].append({'updateSpreadsheetProperties':
                                             {'properties': {'locale': self.todrive['locale']}, 'fields': 'locale'}})
                if self.todrive['timeZone']:
                  body['requests'].append({'updateSpreadsheetProperties':
                                             {'properties': {'timeZone': self.todrive['timeZone']}, 'fields': 'timeZone'}})
                if body['requests']:
                  callGAPI(sheet.spreadsheets(), 'batchUpdate',
                           throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                           spreadsheetId=spreadsheetId, body=body)
              except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
                      GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
                      GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition,
                      GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep) as e:
                todriveCSVErrorExit([Ent.USER, user, Ent.SPREADSHEET, title], str(e))
          Act.Set(action)
          file_url = result['webViewLink']
          msg_txt = f'{Msg.DATA_UPLOADED_TO_DRIVE_FILE}:\n{file_url}'
          if not self.todrive['returnidonly']:
            printKeyValueList([msg_txt])
          else:
            if self.todrive['fileId']:
              writeStdout(f'{self.todrive["fileId"]}\n')
            else:
              writeStdout(f'{spreadsheetId}\n')
          if not self.todrive['subject']:
            subject = title
          else:
            subject = self.todrive['subject'].replace('#file#', title).replace('#sheet#', sheetTitle)
          if not self.todrive['noemail']:
            send_email(subject, msg_txt, user, clientAccess=GC.Values[GC.TODRIVE_CLIENTACCESS], msgFrom=self.todrive['from'])
          if self.todrive['notify']:
            for recipient in self.todrive['share']+self.todrive['alert']:
              if recipient['emailAddress'] != user:
                send_email(subject, msg_txt, recipient['emailAddress'], clientAccess=GC.Values[GC.TODRIVE_CLIENTACCESS], msgFrom=self.todrive['from'])
          if not self.todrive['nobrowser']:
            webbrowser.open(file_url)
        except (GAPI.forbidden, GAPI.insufficientPermissions):
          printWarningMessage(INSUFFICIENT_PERMISSIONS_RC, Msg.INSUFFICIENT_PERMISSIONS_TO_PERFORM_TASK)
        except (GAPI.fileNotFound, GAPI.unknownError, GAPI.internalError, GAPI.storageQuotaExceeded) as e:
          if not self.todrive['fileId']:
            entityActionFailedWarning([Ent.DRIVE_FOLDER, self.todrive['parentId']], str(e))
          else:
            entityActionFailedWarning([Ent.DRIVE_FILE, self.todrive['fileId']], str(e))
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          userDriveServiceNotEnabledWarning(user, str(e), 0, 0)
      else:
        closeFile(csvFile)

    if GM.Globals[GM.CSVFILE][GM.REDIRECT_QUEUE] is not None:
      GM.Globals[GM.CSVFILE][GM.REDIRECT_QUEUE].put((GM.REDIRECT_QUEUE_NAME, list_type))
      GM.Globals[GM.CSVFILE][GM.REDIRECT_QUEUE].put((GM.REDIRECT_QUEUE_TODRIVE, self.todrive))
      GM.Globals[GM.CSVFILE][GM.REDIRECT_QUEUE].put((GM.REDIRECT_QUEUE_CSVPF,
                                                     (self.titlesList, self.sortTitlesList, self.indexedTitles,
                                                      self.formatJSON, self.JSONtitlesList,
                                                      self.columnDelimiter, self.noEscapeChar, self.quoteChar,
                                                      self.sortHeaders, self.timestampColumn,
                                                      self.mapDrive3Titles,
                                                      self.fixPaths,
                                                      self.mapNodataFields,
                                                      self.nodataFields,
                                                      self.driveListFields,
                                                      self.driveSubfieldsChoiceMap,
                                                      self.oneItemPerRow,
                                                      self.showPermissionsLast,
                                                      self.zeroBlankMimeTypeCounts)))
      if clearRowFilters:
        GM.Globals[GM.CSVFILE][GM.REDIRECT_QUEUE].put((GM.REDIRECT_QUEUE_CLEAR_ROW_FILTERS, clearRowFilters))
      GM.Globals[GM.CSVFILE][GM.REDIRECT_QUEUE].put((GM.REDIRECT_QUEUE_DATA, self.rows))
      return
    if self.zeroBlankMimeTypeCounts:
      self.ZeroBlankMimeTypeCounts()
    if not clearRowFilters and (self.rowFilter or self.rowDropFilter):
      self.CheckOutputRowFilterHeaders()
    if self.headerFilter or self.headerDropFilter:
      if not self.formatJSON:
        self.FilterHeaders()
      else:
        self.FilterJSONHeaders()
      extrasaction = 'ignore'
    else:
      extrasaction = 'raise'
    if not self.formatJSON:
      if not self.headerForce:
        self.SortTitles()
        self.SortIndexedTitles(self.titlesList)
        if self.fixPaths:
          self.FixPathsTitles(self.titlesList)
        if self.showPermissionsLast:
          self.MovePermsToEnd()
        if not self.rows and self.nodataFields is not None:
          self.FixNodataTitles()
        if self.mapDrive3Titles:
          self. MapDrive3TitlesToDrive2()
      else:
        self.titlesList = self.headerForce
      if self.timestampColumn:
        self.AddTitle(self.timestampColumn)
      if self.headerOrder:
        self.titlesList = self.orderHeaders(self.titlesList)
      titlesList = self.titlesList
    else:
      if not self.headerForce:
        if self.fixPaths:
          self.FixPathsTitles(self.JSONtitlesList)
        if not self.rows and self.nodataFields is not None:
          self.FixNodataTitles()
      else:
        self.JSONtitlesList = self.headerForce
      if self.timestampColumn:
        for i, v in enumerate(self.JSONtitlesList):
          if v.startswith('JSON'):
            self.JSONtitlesList.insert(i, self.timestampColumn)
            self.JSONtitlesSet.add(self.timestampColumn)
            break
        else:
          self.AddJSONTitle(self.timestampColumn)
      if self.headerOrder:
        self.JSONtitlesList = self.orderHeaders(self.JSONtitlesList)
      titlesList = self.JSONtitlesList
    normalizeSortHeaders()
    if self.outputTranspose:
      newRows = []
      newTitlesList = [i for i in range(len(self.rows)+1)]
      for title in titlesList:
        i = 0
        newRow = {i: title}
        for row in self.rows:
          i += 1
          newRow[i] = row.get(title)
        newRows.append(newRow)
      titlesList = newTitlesList
      self.rows = newRows
    if (not self.todrive) or self.todrive['localcopy']:
      if GM.Globals[GM.CSVFILE][GM.REDIRECT_NAME] == '-':
        if GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD]:
          writeCSVToStdout()
        else:
          GM.Globals[GM.CSVFILE][GM.REDIRECT_NAME] = GM.Globals[GM.STDOUT][GM.REDIRECT_NAME]
          writeCSVToFile()
      else:
        writeCSVToFile()
    if self.todrive:
      writeCSVToDrive()
    if GM.Globals[GM.CSVFILE][GM.REDIRECT_MODE] == DEFAULT_FILE_APPEND_MODE:
      GM.Globals[GM.CSVFILE][GM.REDIRECT_WRITE_HEADER] = False

def writeEntityNoHeaderCSVFile(entityType, entityList):
  csvPF = CSVPrintFile(entityType)
  _, _, entityList = getEntityArgument(entityList)
  if entityType == Ent.USER:
    for entity in entityList:
      csvPF.WriteRowNoFilter({entityType: normalizeEmailAddressOrUID(entity)})
  else:
    for entity in entityList:
      csvPF.WriteRowNoFilter({entityType: entity})
  GM.Globals[GM.CSVFILE][GM.REDIRECT_WRITE_HEADER] = False
  csvPF.writeCSVfile(Ent.Plural(entityType))

def getTodriveOnly(csvPF):
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    else:
      unknownArgumentExit()

DEFAULT_SKIP_OBJECTS = {'kind', 'etag', 'etags', '@type'}

# Clean a JSON object
def cleanJSON(topStructure, listLimit=None, skipObjects=None, timeObjects=None):
  def _clean(structure, key, subSkipObjects):
    if not isinstance(structure, (dict, list)):
      if key not in timeObjects:
        if isinstance(structure, str) and GC.Values[GC.CSV_OUTPUT_CONVERT_CR_NL]:
          return escapeCRsNLs(structure)
        return structure
      if isinstance(structure, str) and not structure.isdigit():
        return formatLocalTime(structure)
      return formatLocalTimestamp(structure)
    if isinstance(structure, list):
      listLen = len(structure)
      listLen = min(listLen, listLimit or listLen)
      return [_clean(v, '', DEFAULT_SKIP_OBJECTS) for v in structure[0:listLen]]
    return {k: _clean(v, k, DEFAULT_SKIP_OBJECTS) for k, v in sorted(iter(structure.items())) if k not in subSkipObjects}

  timeObjects = timeObjects or set()
  return _clean(topStructure, '', DEFAULT_SKIP_OBJECTS.union(skipObjects or set()))

# Flatten a JSON object
def flattenJSON(topStructure, flattened=None,
                listLimit=None, skipObjects=None, timeObjects=None, noLenObjects=None, simpleLists=None, delimiter=None):
  def _flatten(structure, key, path):
    if not isinstance(structure, (dict, list)):
      if key not in timeObjects:
        if isinstance(structure, str):
          if GC.Values[GC.CSV_OUTPUT_CONVERT_CR_NL] and (structure.find('\n') >= 0 or structure.find('\r') >= 0):
            flattened[path] = escapeCRsNLs(structure)
          else:
            flattened[path] = structure
        else:
          flattened[path] = structure
      else:
        if isinstance(structure, str) and not structure.isdigit():
          flattened[path] = formatLocalTime(structure)
        else:
          flattened[path] = formatLocalTimestamp(structure)
    elif isinstance(structure, list):
      listLen = len(structure)
      listLen = min(listLen, listLimit or listLen)
      if key in simpleLists:
        flattened[path] = delimiter.join(structure[:listLen])
      else:
        if key not in noLenObjects:
          flattened[path] = listLen
        for i in range(listLen):
          _flatten(structure[i], '', f'{path}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{i}')
    else:
      if structure:
        for k, v in sorted(iter(structure.items())):
          if k not in DEFAULT_SKIP_OBJECTS:
            _flatten(v, k, f'{path}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{k}')
      else:
        flattened[path] = ''

  flattened = flattened or {}
  allSkipObjects = DEFAULT_SKIP_OBJECTS.union(skipObjects or set())
  timeObjects = timeObjects or set()
  noLenObjects = noLenObjects or set()
  simpleLists = simpleLists or set()
  for k, v in sorted(iter(topStructure.items())):
    if k not in allSkipObjects:
      _flatten(v, k, k)
  return flattened

# Show a json object
def showJSON(showName, showValue, skipObjects=None, timeObjects=None,
             simpleLists=None, dictObjectsKey=None, sortDictKeys=True):
  def _show(objectName, objectValue, subObjectKey, level, subSkipObjects):
    if objectName in subSkipObjects:
      return
    if objectName is not None:
      printJSONKey(objectName)
      subObjectKey = dictObjectsKey.get(objectName)
    if isinstance(objectValue, list):
      if objectName in simpleLists:
        printJSONValue(' '.join(objectValue))
        return
      if len(objectValue) == 1 and isinstance(objectValue[0], (str, bool, float, int)):
        if objectName is not None:
          printJSONValue(objectValue[0])
        else:
          printKeyValueList([objectValue[0]])
        return
      if objectName is not None:
        printBlankLine()
        Ind.Increment()
      for subValue in objectValue:
        if isinstance(subValue, (str, bool, float, int)):
          printKeyValueList([subValue])
        else:
          _show(None, subValue, subObjectKey, level+1, DEFAULT_SKIP_OBJECTS)
      if objectName is not None:
        Ind.Decrement()
    elif isinstance(objectValue, dict):
      indentAfterFirst = unindentAfterLast = False
      if objectName is not None:
        printBlankLine()
        Ind.Increment()
      elif level > 0:
        indentAfterFirst = unindentAfterLast = True
      subObjects = sorted(objectValue) if sortDictKeys else objectValue.keys()
      if subObjectKey and (subObjectKey in subObjects):
        subObjects.remove(subObjectKey)
        subObjects.insert(0, subObjectKey)
        subObjectKey = None
      for subObject in subObjects:
        if subObject not in subSkipObjects:
          _show(subObject, objectValue[subObject], subObjectKey, level+1, DEFAULT_SKIP_OBJECTS)
          if indentAfterFirst:
            Ind.Increment()
            indentAfterFirst = False
      if objectName is not None or ((not indentAfterFirst) and unindentAfterLast):
        Ind.Decrement()
    else:
      if objectName not in timeObjects:
        if isinstance(objectValue, str) and (objectValue.find('\n') >= 0 or objectValue.find('\r') >= 0):
          if GC.Values[GC.SHOW_CONVERT_CR_NL]:
            printJSONValue(escapeCRsNLs(objectValue))
          else:
            printBlankLine()
            Ind.Increment()
            printKeyValueList([Ind.MultiLineText(objectValue)])
            Ind.Decrement()
        else:
          printJSONValue(objectValue if objectValue is not None else '')
      else:
        if isinstance(objectValue, str) and not objectValue.isdigit():
          printJSONValue(formatLocalTime(objectValue))
        else:
          printJSONValue(formatLocalTimestamp(objectValue))

  timeObjects = timeObjects or set()
  simpleLists = simpleLists or set()
  dictObjectsKey = dictObjectsKey or {}
  _show(showName, showValue, None, 0, DEFAULT_SKIP_OBJECTS.union(skipObjects or set()))

class FormatJSONQuoteChar():

  def __init__(self, csvPF=None, formatJSONOnly=False):
    self.SetCsvPF(csvPF)
    self.SetFormatJSON(False)
    self.SetQuoteChar(GM.Globals.get(GM.CSV_OUTPUT_QUOTE_CHAR, GC.Values.get(GC.CSV_OUTPUT_QUOTE_CHAR, '"')))
    if not formatJSONOnly:
      return
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg == 'formatjson':
        self.SetFormatJSON(True)
        return
      unknownArgumentExit()

  def SetCsvPF(self, csvPF):
    self.csvPF = csvPF

  def SetFormatJSON(self, formatJSON):
    self.formatJSON = formatJSON
    if self.csvPF:
      self.csvPF.SetFormatJSON(formatJSON)

  def GetFormatJSON(self, myarg):
    if myarg == 'formatjson':
      self.SetFormatJSON(True)
      return
    unknownArgumentExit()

  def SetQuoteChar(self, quoteChar):
    self.quoteChar = quoteChar
    if self.csvPF:
      self.csvPF.SetQuoteChar(quoteChar)

  def GetQuoteChar(self, myarg):
    if self.csvPF and myarg == 'quotechar':
      self.SetQuoteChar(getCharacter())
      return
    unknownArgumentExit()

  def GetFormatJSONQuoteChar(self, myarg, addTitle=False, noExit=False):
    if myarg == 'formatjson':
      self.SetFormatJSON(True)
      if self.csvPF and addTitle:
        self.csvPF.AddJSONTitles('JSON')
      return True
    if self.csvPF and myarg == 'quotechar':
      self.SetQuoteChar(getCharacter())
      return True
    if noExit:
      return False
    unknownArgumentExit()

# Batch processing request_id fields
RI_ENTITY = 0
RI_I = 1
RI_COUNT = 2
RI_J = 3
RI_JCOUNT = 4
RI_ITEM = 5
RI_ROLE = 6
RI_OPTION = 7

def batchRequestID(entityName, i, count, j, jcount, item, role=None, option=None):
  if role is None and option is None:
    return f'{entityName}\n{i}\n{count}\n{j}\n{jcount}\n{item}'
  return f'{entityName}\n{i}\n{count}\n{j}\n{jcount}\n{item}\n{role}\n{option}'

TIME_OFFSET_UNITS = [('day', SECONDS_PER_DAY), ('hour', SECONDS_PER_HOUR), ('minute', SECONDS_PER_MINUTE), ('second', 1)]

def getLocalGoogleTimeOffset(testLocation=GOOGLE_TIMECHECK_LOCATION):
  # If local time is well off, it breaks https because the server certificate will be seen as too old or new and thus invalid; http doesn't have that issue.
  # Try with http first, if time is close (<MAX_LOCAL_GOOGLE_TIME_OFFSET seconds), retry with https as it should be OK
  httpObj = getHttpObj()
  for prot in ['http', 'https']:
    try:
      headerData = httpObj.request(f'{prot}://'+testLocation, 'HEAD')
      googleUTC = datetime.datetime.strptime(headerData[0]['date'], '%a, %d %b %Y %H:%M:%S %Z').replace(tzinfo=iso8601.UTC)
    except (httplib2.HttpLib2Error, RuntimeError) as e:
      handleServerError(e)
    except httplib2.socks.HTTPError as e:
      # If user has specified an HTTPS proxy, the http request will probably fail as httplib2
      # turns a GET into a CONNECT which is not valid for an http address
      if prot == 'http':
        continue
      handleServerError(e)
    except (ValueError, KeyError):
      if prot == 'http':
        continue
      systemErrorExit(NETWORK_ERROR_RC, Msg.INVALID_HTTP_HEADER.format(str(headerData)))
    offset = remainder = int(abs((datetime.datetime.now(iso8601.UTC)-googleUTC).total_seconds()))
    if offset < MAX_LOCAL_GOOGLE_TIME_OFFSET and prot == 'http':
      continue
    timeoff = []
    for tou in TIME_OFFSET_UNITS:
      uval, remainder = divmod(remainder, tou[1])
      if uval:
        timeoff.append(f'{uval} {tou[0]}{"s" if uval != 1 else ""}')
    if not timeoff:
      timeoff.append(Msg.LESS_THAN_1_SECOND)
    nicetime = ', '.join(timeoff)
    return (offset, nicetime)

def _getServerTLSUsed(location):
  url = 'https://'+location
  _, netloc, _, _, _, _ = urlparse(url)
  conn = 'https:'+netloc
  httpObj = getHttpObj()
  triesLimit = 5
  for n in range(1, triesLimit+1):
    try:
      httpObj.request(url, headers={'user-agent': GAM_USER_AGENT})
      cipher_name, tls_ver, _ = httpObj.connections[conn].sock.cipher()
      return tls_ver, cipher_name
    except (httplib2.HttpLib2Error, RuntimeError) as e:
      if n != triesLimit:
        httpObj.connections = {}
        waitOnFailure(n, triesLimit, NETWORK_ERROR_RC, str(e))
        continue
      handleServerError(e)

MACOS_CODENAMES = {
  10: {
    6:  'Snow Leopard',
    7:  'Lion',
    8:  'Mountain Lion',
    9:  'Mavericks',
    10: 'Yosemite',
    11: 'El Capitan',
    12: 'Sierra',
    13: 'High Sierra',
    14: 'Mojave',
    15: 'Catalina',
    16: 'Big Sur'
    },
  11: 'Big Sur',
  12: 'Monterey',
  13: 'Ventura',
  14: 'Sonoma',
  15: 'Sequoia',
  }

def getOSPlatform():
  myos = platform.system()
  if myos == 'Linux':
    pltfrm = ' '.join(distro.linux_distribution(full_distribution_name=False)).title()
  elif myos == 'Windows':
    pltfrm = ' '.join(platform.win32_ver())
  elif myos == 'Darwin':
    myos = 'MacOS'
    mac_ver = platform.mac_ver()[0]
    major_ver = int(mac_ver.split('.')[0]) # macver 10.14.6 == major_ver 10
    minor_ver = int(mac_ver.split('.')[1]) # macver 10.14.6 == minor_ver 14
    if major_ver == 10:
      codename = MACOS_CODENAMES[major_ver].get(minor_ver, '')
    else:
      codename = MACOS_CODENAMES.get(major_ver, '')
    pltfrm = ' '.join([codename, mac_ver])
  else:
    pltfrm = platform.platform()
  return f'{myos} {pltfrm}'

# gam checkconnection
def doCheckConnection():

  def check_host(host):
    nonlocal try_count, okay, not_okay, success_count
    try_count += 1
    dns_err = None
    ip = 'unknown'
    try:
      ip = socket.getaddrinfo(host, None)[0][-1][0] # works with ipv6
    except socket.gaierror as e:
      dns_err = f'{not_okay}\n   DNS failure: {str(e)}\n'
    except Exception as e:
      dns_err = f'{not_okay}\n   Unknown DNS failure: {str(e)}\n'
    check_line = f'Checking {host} ({ip}) ({try_count})...'
    writeStdout(f'{check_line:<100}')
    flushStdout()
    if dns_err:
      writeStdout(dns_err)
      return
    gen_firewall = 'You probably have security software or a firewall on your machine or network that is preventing GAM from making Internet connections. Check your network configuration or try running GAM on a hotspot or home network to see if the problem exists only on your organization\'s network.'
    try:
      if host.startswith('http'):
        url = host
      else:
        url = f'https://{host}:443/'
      httpObj.request(url, 'HEAD', headers=headers)
      success_count += 1
      writeStdout(f'{okay}\n')
    except ConnectionRefusedError:
      writeStdout(f'{not_okay}\n    Connection refused. {gen_firewall}\n')
    except ConnectionResetError:
      writeStdout(f'{not_okay}\n    Connection reset by peer. {gen_firewall}\n')
    except httplib2.error.ServerNotFoundError:
      writeStdout(f'{not_okay}\n    Failed to find server. Your DNS is probably misconfigured.\n')
    except ssl.SSLError as e:
      if e.reason == 'SSLV3_ALERT_HANDSHAKE_FAILURE':
        writeStdout(f'{not_okay}\n    GAM expects to connect with TLS 1.3 or newer and that failed. If your firewall / proxy server is not compatible with TLS 1.3 then you can tell GAM to allow TLS 1.2 by setting tls_min_version = TLSv1.2 in gam.cfg.\n')
      elif e.reason == 'CERTIFICATE_VERIFY_FAILED':
        writeStdout(f'{not_okay}\n    Certificate verification failed. If you are behind a firewall / proxy server that does TLS / SSL inspection you may need to point GAM at your certificate authority file by setting cacerts_pem = /path/to/your/certauth.pem in gam.cfg.\n')
      elif e.strerror and e.strerror.startswith('TLS/SSL connection has been closed\n'):
        writeStdout(f'{not_okay}\n    TLS connection was closed. {gen_firewall}\n')
      else:
        writeStdout(f'{not_okay}\n    {str(e)}\n')
    except TimeoutError:
      writeStdout(f'{not_okay}\n    Timed out trying to connect to host\n')
    except Exception as e:
      writeStdout(f'{not_okay}\n    {str(e)}\n')

  try_count = 0
  httpObj = getHttpObj(timeout=30)
  httpObj.follow_redirects = False
  headers = {'user-agent': GAM_USER_AGENT}
  okay = createGreenText('OK')
  not_okay = createRedText('ERROR')
  success_count = 0
  initial_hosts = ['api.github.com',
           'raw.githubusercontent.com',
           'accounts.google.com',
           'oauth2.googleapis.com',
           'www.googleapis.com']
  for host in initial_hosts:
    check_host(host)
  api_hosts = ['apps-apis.google.com',
               'www.google.com']
  for host in api_hosts:
    check_host(host)
  # For v2 discovery APIs, GAM gets discovery file from <api>.googleapis.com so
  # add those domains.
  disc_hosts = []
  for api, config in API._INFO.items():
    if config.get('v2discovery') and not config.get('localdiscovery'):
      if mapped_api := config.get('mappedAPI'):
        api = mapped_api
      host = f'{api}.googleapis.com'
      if host not in disc_hosts:
        disc_hosts.append(host)
  for host in disc_hosts:
    check_host(host)
  checked_hosts = initial_hosts + api_hosts + disc_hosts
  # now we need to "build" each API and check it's base URL host
  # if we haven't already. This may not be any hosts at all but
  # to ensure we are checking all hosts GAM may use we should
  # keep this.
  for api in API._INFO:
    if api in [API.CONTACTS, API.EMAIL_AUDIT]:
      continue
    svc = getService(api, httpObj)
    base_url = svc._rootDesc.get('baseUrl')
    parsed_base_url = urlparse(base_url)
    base_host = parsed_base_url.netloc
    if base_host not in checked_hosts:
      writeStdout(f'Checking {base_host} for {api}\n')
      check_host(base_host)
      checked_hosts.append(base_host)
  if success_count == try_count:
    writeStdout(createGreenText('All hosts passed!\n'))
  else:
    systemErrorExit(3, createYellowText('Some hosts failed to connect! Please follow the recommendations for those hosts to correct any issues and try again.'))

# gam comment
def doComment():
  writeStdout(Cmd.QuotedArgumentList(Cmd.Remaining())+'\n')

# gam version [check|checkrc|simple|extended] [timeoffset] [nooffseterror] [location <HostName>]
def doVersion(checkForArgs=True):
  forceCheck = 0
  extended = noOffsetError = timeOffset = simple = False
  testLocation = GOOGLE_TIMECHECK_LOCATION
  if checkForArgs:
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg == 'check':
        forceCheck = 1
      elif myarg == 'checkrc':
        forceCheck = -1
      elif myarg == 'simple':
        simple = True
      elif myarg == 'extended':
        extended = timeOffset = True
      elif myarg == 'timeoffset':
        timeOffset = True
      elif myarg == 'nooffseterror':
        noOffsetError = True
      elif myarg == 'location':
        testLocation = getString(Cmd.OB_HOST_NAME)
      else:
        unknownArgumentExit()
  if simple:
    writeStdout(__version__)
    return
  writeStdout((f'{GAM} {__version__} - {GAM_URL} - {GM.Globals[GM.GAM_TYPE]}\n'
               f'{__author__}\n'
               f'Python {sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]} {struct.calcsize("P")*8}-bit {sys.version_info[3]}\n'
               f'{getOSPlatform()} {platform.machine()}\n'
               f'Path: {GM.Globals[GM.GAM_PATH]}\n'
               f'{Ent.Singular(Ent.CONFIG_FILE)}: {GM.Globals[GM.GAM_CFG_FILE]}, {Ent.Singular(Ent.SECTION)}: {GM.Globals[GM.GAM_CFG_SECTION_NAME]}, '
               f'{GC.CUSTOMER_ID}: {GC.Values[GC.CUSTOMER_ID]}, {GC.DOMAIN}: {GC.Values[GC.DOMAIN]}\n'
               f'Time: {ISOformatTimeStamp(todaysTime())}\n'
               ))
  if sys.platform.startswith('win') and str(struct.calcsize('P')*8).find('32') != -1 and platform.machine().find('64') != -1:
    printKeyValueList([Msg.UPDATE_GAM_TO_64BIT])
  if timeOffset:
    offsetSeconds, offsetFormatted = getLocalGoogleTimeOffset(testLocation)
    printKeyValueList([Msg.YOUR_SYSTEM_TIME_DIFFERS_FROM_GOOGLE.format(testLocation, offsetFormatted)])
    if offsetSeconds > MAX_LOCAL_GOOGLE_TIME_OFFSET:
      if not noOffsetError:
        systemErrorExit(NETWORK_ERROR_RC, Msg.PLEASE_CORRECT_YOUR_SYSTEM_TIME)
      stderrWarningMsg(Msg.PLEASE_CORRECT_YOUR_SYSTEM_TIME)
  if forceCheck:
    doGAMCheckForUpdates(forceCheck)
  if extended:
    printKeyValueList([ssl.OPENSSL_VERSION])
    tls_ver, cipher_name = _getServerTLSUsed(testLocation)
    for lib in glverlibs.GAM_VER_LIBS:
      try:
        writeStdout(f'{lib} {lib_version(lib)}\n')
      except:
        pass
    printKeyValueList([f'{testLocation} connects using {tls_ver} {cipher_name}'])

# gam help
def doUsage():
  printBlankLine()
  doVersion(checkForArgs=False)
  writeStdout(Msg.HELP_SYNTAX.format(os.path.join(GM.Globals[GM.GAM_PATH], FN_GAMCOMMANDS_TXT)))
  writeStdout(Msg.HELP_WIKI.format(GAM_WIKI))

class NullHandler(logging.Handler):
  def emit(self, record):
    pass

def initializeLogging():
  nh = NullHandler()
  logging.getLogger().addHandler(nh)

def saveNonPickleableValues():
  savedValues = {GM.STDOUT: {}, GM.STDERR: {}, GM.SAVED_STDOUT: None,
                 GM.CMDLOG_HANDLER: None, GM.CMDLOG_LOGGER: None}
  savedValues[GM.SAVED_STDOUT] = GM.Globals[GM.SAVED_STDOUT]
  GM.Globals[GM.SAVED_STDOUT] = None
  savedValues[GM.STDOUT][GM.REDIRECT_FD] = GM.Globals[GM.STDOUT].get(GM.REDIRECT_FD, None)
  GM.Globals[GM.STDOUT].pop(GM.REDIRECT_FD, None)
  savedValues[GM.STDERR][GM.REDIRECT_FD] = GM.Globals[GM.STDERR].get(GM.REDIRECT_FD, None)
  GM.Globals[GM.STDERR].pop(GM.REDIRECT_FD, None)
  savedValues[GM.STDOUT][GM.REDIRECT_MULTI_FD] = GM.Globals[GM.STDOUT].get(GM.REDIRECT_MULTI_FD, None)
  GM.Globals[GM.STDOUT].pop(GM.REDIRECT_MULTI_FD, None)
  savedValues[GM.STDERR][GM.REDIRECT_MULTI_FD] = GM.Globals[GM.STDERR].get(GM.REDIRECT_MULTI_FD, None)
  GM.Globals[GM.STDERR].pop(GM.REDIRECT_MULTI_FD, None)
  savedValues[GM.CMDLOG_HANDLER] = GM.Globals[GM.CMDLOG_HANDLER]
  GM.Globals[GM.CMDLOG_HANDLER] = None
  savedValues[GM.CMDLOG_LOGGER] = GM.Globals[GM.CMDLOG_LOGGER]
  GM.Globals[GM.CMDLOG_LOGGER] = None
  return savedValues

def restoreNonPickleableValues(savedValues):
  GM.Globals[GM.SAVED_STDOUT] = savedValues[GM.SAVED_STDOUT]
  GM.Globals[GM.STDOUT][GM.REDIRECT_FD] = savedValues[GM.STDOUT][GM.REDIRECT_FD]
  GM.Globals[GM.STDERR][GM.REDIRECT_FD] = savedValues[GM.STDERR][GM.REDIRECT_FD]
  GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD] = savedValues[GM.STDOUT][GM.REDIRECT_MULTI_FD]
  GM.Globals[GM.STDERR][GM.REDIRECT_MULTI_FD] = savedValues[GM.STDERR][GM.REDIRECT_MULTI_FD]
  GM.Globals[GM.CMDLOG_HANDLER] = savedValues[GM.CMDLOG_HANDLER]
  GM.Globals[GM.CMDLOG_LOGGER] = savedValues[GM.CMDLOG_LOGGER]

def CSVFileQueueHandler(mpQueue, mpQueueStdout, mpQueueStderr, csvPF, datetimeNow, tzinfo, output_timeformat):
  global Cmd

  def reopenSTDFile(stdtype):
    if GM.Globals[stdtype][GM.REDIRECT_NAME] == 'null':
      GM.Globals[stdtype][GM.REDIRECT_FD] = open(os.devnull, GM.Globals[stdtype][GM.REDIRECT_MODE], encoding=UTF8)
    elif GM.Globals[stdtype][GM.REDIRECT_NAME] == '-':
      GM.Globals[stdtype][GM.REDIRECT_FD] = os.fdopen(os.dup([sys.stderr.fileno(), sys.stdout.fileno()][stdtype == GM.STDOUT]),
                                                      GM.Globals[stdtype][GM.REDIRECT_MODE], encoding=GM.Globals[GM.SYS_ENCODING])
    elif stdtype == GM.STDERR and GM.Globals[stdtype][GM.REDIRECT_NAME] == 'stdout':
      GM.Globals[stdtype][GM.REDIRECT_FD] = GM.Globals[GM.STDOUT][GM.REDIRECT_FD]
    else:
      GM.Globals[stdtype][GM.REDIRECT_FD] = openFile(GM.Globals[stdtype][GM.REDIRECT_NAME], GM.Globals[stdtype][GM.REDIRECT_MODE])
    if stdtype == GM.STDERR and GM.Globals[stdtype][GM.REDIRECT_NAME] == 'stdout':
      GM.Globals[stdtype][GM.REDIRECT_MULTI_FD] = GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD]
    else:
      GM.Globals[stdtype][GM.REDIRECT_MULTI_FD] = GM.Globals[stdtype][GM.REDIRECT_FD] if not GM.Globals[stdtype][GM.REDIRECT_MULTIPROCESS] else StringIOobject()

  GM.Globals[GM.DATETIME_NOW] = datetimeNow
  GC.Values[GC.TIMEZONE] = tzinfo
  GC.Values[GC.OUTPUT_TIMEFORMAT] = output_timeformat
  clearRowFilters = False
#  if sys.platform.startswith('win'):
#    signal.signal(signal.SIGINT, signal.SIG_IGN)
  if multiprocessing.get_start_method() == 'spawn':
    signal.signal(signal.SIGINT, signal.SIG_IGN)
    Cmd = glclargs.GamCLArgs()
  else:
    csvPF.SetColumnDelimiter(GC.Values[GC.CSV_OUTPUT_COLUMN_DELIMITER])
    csvPF.SetNoEscapeChar(GC.Values[GC.CSV_OUTPUT_NO_ESCAPE_CHAR])
    csvPF.SetQuoteChar(GC.Values[GC.CSV_OUTPUT_QUOTE_CHAR])
    csvPF.SetSortHeaders(GC.Values[GC.CSV_OUTPUT_SORT_HEADERS])
    csvPF.SetTimestampColumn(GC.Values[GC.CSV_OUTPUT_TIMESTAMP_COLUMN])
    csvPF.SetHeaderFilter(GC.Values[GC.CSV_OUTPUT_HEADER_FILTER])
    csvPF.SetHeaderDropFilter(GC.Values[GC.CSV_OUTPUT_HEADER_DROP_FILTER])
    csvPF.SetRowFilter(GC.Values[GC.CSV_OUTPUT_ROW_FILTER], GC.Values[GC.CSV_OUTPUT_ROW_FILTER_MODE])
    csvPF.SetRowDropFilter(GC.Values[GC.CSV_OUTPUT_ROW_DROP_FILTER], GC.Values[GC.CSV_OUTPUT_ROW_DROP_FILTER_MODE])
    csvPF.SetRowLimit(GC.Values[GC.CSV_OUTPUT_ROW_LIMIT])
  list_type = 'CSV'
  while True:
    dataType, dataItem = mpQueue.get()
    if dataType == GM.REDIRECT_QUEUE_NAME:
      list_type = dataItem
    elif dataType == GM.REDIRECT_QUEUE_TODRIVE:
      csvPF.todrive = dataItem
    elif dataType == GM.REDIRECT_QUEUE_CSVPF:
      csvPF.AddTitles(dataItem[0])
      csvPF.SetSortTitles(dataItem[1])
      csvPF.SetIndexedTitles(dataItem[2])
      csvPF.SetFormatJSON(dataItem[3])
      csvPF.AddJSONTitles(dataItem[4])
      csvPF.SetColumnDelimiter(dataItem[5])
      csvPF.SetNoEscapeChar(dataItem[6])
      csvPF.SetQuoteChar(dataItem[7])
      csvPF.SetSortHeaders(dataItem[8])
      csvPF.SetTimestampColumn(dataItem[9])
      csvPF.SetMapDrive3Titles(dataItem[10])
      csvPF.SetFixPaths(dataItem[11])
      csvPF.SetNodataFields(dataItem[12], dataItem[13], dataItem[14], dataItem[15], dataItem[16])
      csvPF.SetShowPermissionsLast(dataItem[17])
      csvPF.SetZeroBlankMimeTypeCounts(dataItem[18])
    elif dataType == GM.REDIRECT_QUEUE_DATA:
      csvPF.rows.extend(dataItem)
    elif dataType == GM.REDIRECT_QUEUE_ARGS:
      Cmd.InitializeArguments(dataItem)
    elif dataType == GM.REDIRECT_QUEUE_GLOBALS:
      GM.Globals = dataItem
      if multiprocessing.get_start_method() == 'spawn':
        reopenSTDFile(GM.STDOUT)
        reopenSTDFile(GM.STDERR)
    elif dataType == GM.REDIRECT_QUEUE_VALUES:
      GC.Values = dataItem
      csvPF.SetColumnDelimiter(GC.Values[GC.CSV_OUTPUT_COLUMN_DELIMITER])
      csvPF.SetNoEscapeChar(GC.Values[GC.CSV_OUTPUT_NO_ESCAPE_CHAR])
      csvPF.SetQuoteChar(GC.Values[GC.CSV_OUTPUT_QUOTE_CHAR])
      csvPF.SetSortHeaders(GC.Values[GC.CSV_OUTPUT_SORT_HEADERS])
      csvPF.SetTimestampColumn(GC.Values[GC.CSV_OUTPUT_TIMESTAMP_COLUMN])
      csvPF.SetHeaderFilter(GC.Values[GC.CSV_OUTPUT_HEADER_FILTER])
      csvPF.SetHeaderDropFilter(GC.Values[GC.CSV_OUTPUT_HEADER_DROP_FILTER])
      if not clearRowFilters:
        csvPF.SetRowFilter(GC.Values[GC.CSV_OUTPUT_ROW_FILTER], GC.Values[GC.CSV_OUTPUT_ROW_FILTER_MODE])
        csvPF.SetRowDropFilter(GC.Values[GC.CSV_OUTPUT_ROW_DROP_FILTER], GC.Values[GC.CSV_OUTPUT_ROW_DROP_FILTER_MODE])
      else:
        csvPF.SetRowFilter([], GC.Values[GC.CSV_OUTPUT_ROW_FILTER_MODE])
        csvPF.SetRowDropFilter([], GC.Values[GC.CSV_OUTPUT_ROW_DROP_FILTER_MODE])
      csvPF.SetRowLimit(GC.Values[GC.CSV_OUTPUT_ROW_LIMIT])
    elif dataType == GM.REDIRECT_QUEUE_CLEAR_ROW_FILTERS:
      clearRowFilters = dataItem
    else: #GM.REDIRECT_QUEUE_EOF
      break
  csvPF.writeCSVfile(list_type)
  if mpQueueStdout:
    mpQueueStdout.put((0, GM.REDIRECT_QUEUE_DATA, GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD].getvalue()))
  else:
    flushStdout()
  if mpQueueStderr and mpQueueStderr is not mpQueueStdout:
    mpQueueStderr.put((0, GM.REDIRECT_QUEUE_DATA, GM.Globals[GM.STDERR][GM.REDIRECT_MULTI_FD].getvalue()))
  else:
    flushStderr()

def initializeCSVFileQueueHandler(mpManager, mpQueueStdout, mpQueueStderr):
  mpQueue = mpManager.Queue()
  mpQueueHandler = multiprocessing.Process(target=CSVFileQueueHandler,
                                           args=(mpQueue, mpQueueStdout, mpQueueStderr,
                                                 GM.Globals[GM.CSVFILE][GM.REDIRECT_QUEUE_CSVPF],
                                                 GM.Globals[GM.DATETIME_NOW], GC.Values[GC.TIMEZONE],
                                                 GC.Values[GC.OUTPUT_TIMEFORMAT]))
  mpQueueHandler.start()
  return (mpQueue, mpQueueHandler)

def terminateCSVFileQueueHandler(mpQueue, mpQueueHandler):
  GM.Globals[GM.PARSER] = None
  GM.Globals[GM.CSVFILE][GM.REDIRECT_QUEUE] = None
  if multiprocessing.get_start_method() == 'spawn':
    mpQueue.put((GM.REDIRECT_QUEUE_ARGS, Cmd.AllArguments()))
    savedValues = saveNonPickleableValues()
    mpQueue.put((GM.REDIRECT_QUEUE_GLOBALS, GM.Globals))
    restoreNonPickleableValues(savedValues)
    mpQueue.put((GM.REDIRECT_QUEUE_VALUES, GC.Values))
  mpQueue.put((GM.REDIRECT_QUEUE_EOF, None))
  mpQueueHandler.join()

def StdQueueHandler(mpQueue, stdtype, gmGlobals, gcValues):

  PROCESS_MSG = '{0}: {1:6d}, {2:>5s}: {3}, RC: {4:3d}, Cmd: {5}\n'

  def _writeData(data):
    fd.write(data)

  def _writePidData(pid, data):
    try:
      if pid != 0 and GC.Values[GC.SHOW_MULTIPROCESS_INFO]:
        _writeData(PROCESS_MSG.format(pidData[pid]['queue'], pid, 'Start', pidData[pid]['start'], 0, pidData[pid]['cmd']))
      if data[1] is not None:
        _writeData(data[1])
      if GC.Values[GC.SHOW_MULTIPROCESS_INFO]:
        _writeData(PROCESS_MSG.format(pidData[pid]['queue'], pid, 'End', currentISOformatTimeStamp(), data[0], pidData[pid]['cmd']))
      fd.flush()
    except IOError as e:
      systemErrorExit(FILE_ERROR_RC, fdErrorMessage(fd, GM.Globals[stdtype][GM.REDIRECT_NAME], e))

#  if sys.platform.startswith('win'):
#    signal.signal(signal.SIGINT, signal.SIG_IGN)
  if multiprocessing.get_start_method() == 'spawn':
    signal.signal(signal.SIGINT, signal.SIG_IGN)
    GM.Globals = gmGlobals.copy()
    GC.Values = gcValues.copy()
  pid0DataItem = [KEYBOARD_INTERRUPT_RC, None]
  pidData = {}
  if multiprocessing.get_start_method() == 'spawn':
    if GM.Globals[stdtype][GM.REDIRECT_NAME] == 'null':
      fd = open(os.devnull, GM.Globals[stdtype][GM.REDIRECT_MODE], encoding=UTF8)
    elif GM.Globals[stdtype][GM.REDIRECT_NAME] == '-':
      fd = os.fdopen(os.dup([sys.stderr.fileno(), sys.stdout.fileno()][GM.Globals[stdtype][GM.REDIRECT_QUEUE] == 'stdout']),
                     GM.Globals[stdtype][GM.REDIRECT_MODE], encoding=GM.Globals[GM.SYS_ENCODING])
    elif GM.Globals[stdtype][GM.REDIRECT_NAME] == 'stdout' and GM.Globals[stdtype][GM.REDIRECT_QUEUE] == 'stderr':
      fd = os.fdopen(os.dup(sys.stdout.fileno()), GM.Globals[stdtype][GM.REDIRECT_MODE], encoding=GM.Globals[GM.SYS_ENCODING])
    else:
      fd = openFile(GM.Globals[stdtype][GM.REDIRECT_NAME], GM.Globals[stdtype][GM.REDIRECT_MODE])
  else:
    fd = GM.Globals[stdtype][GM.REDIRECT_FD]
  while True:
    try:
      pid, dataType, dataItem = mpQueue.get()
    except (EOFError, ValueError):
      break
    if dataType == GM.REDIRECT_QUEUE_START:
      pidData[pid] = {'queue': GM.Globals[stdtype][GM.REDIRECT_QUEUE],
                      'start': currentISOformatTimeStamp(),
                      'cmd': Cmd.QuotedArgumentList(dataItem)}
      if pid == 0 and GC.Values[GC.SHOW_MULTIPROCESS_INFO]:
        fd.write(PROCESS_MSG.format(pidData[pid]['queue'], pid, 'Start', pidData[pid]['start'], 0, pidData[pid]['cmd']))
    elif dataType == GM.REDIRECT_QUEUE_DATA:
      _writeData(dataItem)
    elif dataType == GM.REDIRECT_QUEUE_END:
      if pid != 0:
        _writePidData(pid, dataItem)
        del pidData[pid]
      else:
        pid0DataItem = dataItem
    else: #GM.REDIRECT_QUEUE_EOF
      break
  for pid in pidData:
    if pid != 0:
      _writePidData(pid, [KEYBOARD_INTERRUPT_RC, None])
  _writePidData(0, pid0DataItem)
  if fd not in [sys.stdout, sys.stderr]:
    try:
      fd.flush()
      fd.close()
    except IOError:
      pass
  GM.Globals[stdtype][GM.REDIRECT_FD] = None

def initializeStdQueueHandler(mpManager, stdtype, gmGlobals, gcValues):
  mpQueue = mpManager.Queue()
  mpQueueHandler = multiprocessing.Process(target=StdQueueHandler, args=(mpQueue, stdtype, gmGlobals, gcValues))
  mpQueueHandler.start()
  return (mpQueue, mpQueueHandler)

def batchWriteStderr(data):
  try:
    sys.stderr.write(data)
    sys.stderr.flush()
  except IOError as e:
    systemErrorExit(FILE_ERROR_RC, fileErrorMessage('stderr', e))

def writeStdQueueHandler(mpQueue, item):
  while True:
    try:
      mpQueue.put(item)
      return
    except Exception as e:
      time.sleep(1)
      batchWriteStderr(f'{currentISOformatTimeStamp()},{item[0]}/{GM.Globals[GM.NUM_BATCH_ITEMS]},Error,{str(e)}\n')

def terminateStdQueueHandler(mpQueue, mpQueueHandler):
  mpQueue.put((0, GM.REDIRECT_QUEUE_EOF, None))
  mpQueueHandler.join()

def ProcessGAMCommandMulti(pid, numItems, logCmd, mpQueueCSVFile, mpQueueStdout, mpQueueStderr,
                           debugLevel, todrive, printAguDomains,
                           printCrosOUs, printCrosOUsAndChildren,
                           output_dateformat, output_timeformat,
                           csvColumnDelimiter, csvNoEscapeChar, csvQuoteChar,
                           csvSortHeaders, csvTimestampColumn,
                           csvHeaderFilter, csvHeaderDropFilter,
                           csvHeaderForce, csvHeaderOrder,
                           csvRowFilter, csvRowFilterMode, csvRowDropFilter, csvRowDropFilterMode,
                           csvRowLimit,
                           showGettings, showGettingsGotNL,
                           args):
  global mplock

  with mplock:
    initializeLogging()
#    if sys.platform.startswith('win'):
    if multiprocessing.get_start_method() == 'spawn':
      signal.signal(signal.SIGINT, signal.SIG_IGN)
    GM.Globals[GM.API_CALLS_RETRY_DATA] = {}
    GM.Globals[GM.CMDLOG_LOGGER] = None
    GM.Globals[GM.CSVFILE] = {}
    GM.Globals[GM.CSV_DATA_DICT] = {}
    GM.Globals[GM.CSV_KEY_FIELD] = None
    GM.Globals[GM.CSV_SUBKEY_FIELD] = None
    GM.Globals[GM.CSV_DATA_FIELD] = None
    GM.Globals[GM.CSV_OUTPUT_COLUMN_DELIMITER] = csvColumnDelimiter
    GM.Globals[GM.CSV_OUTPUT_NO_ESCAPE_CHAR] = csvNoEscapeChar
    GM.Globals[GM.CSV_OUTPUT_HEADER_DROP_FILTER] = csvHeaderDropFilter[:]
    GM.Globals[GM.CSV_OUTPUT_HEADER_FILTER] = csvHeaderFilter[:]
    GM.Globals[GM.CSV_OUTPUT_HEADER_FORCE] = csvHeaderForce[:]
    GM.Globals[GM.CSV_OUTPUT_HEADER_ORDER] = csvHeaderOrder[:]
    GM.Globals[GM.CSV_OUTPUT_QUOTE_CHAR] = csvQuoteChar
    GM.Globals[GM.CSV_OUTPUT_ROW_DROP_FILTER] = csvRowDropFilter[:]
    GM.Globals[GM.CSV_OUTPUT_ROW_DROP_FILTER_MODE] = csvRowDropFilterMode
    GM.Globals[GM.CSV_OUTPUT_ROW_FILTER] = csvRowFilter[:]
    GM.Globals[GM.CSV_OUTPUT_ROW_FILTER_MODE] = csvRowFilterMode
    GM.Globals[GM.CSV_OUTPUT_ROW_LIMIT] = csvRowLimit
    GM.Globals[GM.CSV_OUTPUT_SORT_HEADERS] = csvSortHeaders[:]
    GM.Globals[GM.CSV_OUTPUT_TIMESTAMP_COLUMN] = csvTimestampColumn
    GM.Globals[GM.CSV_TODRIVE] = todrive.copy()
    GM.Globals[GM.DEBUG_LEVEL] = debugLevel
    GM.Globals[GM.OUTPUT_DATEFORMAT] = output_dateformat
    GM.Globals[GM.OUTPUT_TIMEFORMAT] = output_timeformat
    GM.Globals[GM.NUM_BATCH_ITEMS] = numItems
    GM.Globals[GM.PID] = pid
    GM.Globals[GM.PRINT_AGU_DOMAINS] = printAguDomains[:]
    GM.Globals[GM.PRINT_CROS_OUS] = printCrosOUs[:]
    GM.Globals[GM.PRINT_CROS_OUS_AND_CHILDREN] = printCrosOUsAndChildren[:]
    GM.Globals[GM.SAVED_STDOUT] = None
    GM.Globals[GM.SHOW_GETTINGS] = showGettings
    GM.Globals[GM.SHOW_GETTINGS_GOT_NL] = showGettingsGotNL
    GM.Globals[GM.SYSEXITRC] = 0
    GM.Globals[GM.PARSER] = None
    if mpQueueCSVFile:
      GM.Globals[GM.CSVFILE][GM.REDIRECT_QUEUE] = mpQueueCSVFile
    if mpQueueStdout:
      GM.Globals[GM.STDOUT] = {GM.REDIRECT_NAME: '', GM.REDIRECT_FD: None, GM.REDIRECT_MULTI_FD: StringIOobject()}
      if debugLevel:
        sys.stdout = GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD]
#      mpQueueStdout.put((pid, GM.REDIRECT_QUEUE_START, args))
      writeStdQueueHandler(mpQueueStdout,(pid, GM.REDIRECT_QUEUE_START, args))
    else:
      GM.Globals[GM.STDOUT] = {}
    if mpQueueStderr:
      if mpQueueStderr is not mpQueueStdout:
        GM.Globals[GM.STDERR] = {GM.REDIRECT_NAME: '', GM.REDIRECT_FD: None, GM.REDIRECT_MULTI_FD: StringIOobject()}
#        mpQueueStderr.put((pid, GM.REDIRECT_QUEUE_START, args))
        writeStdQueueHandler(mpQueueStderr, (pid, GM.REDIRECT_QUEUE_START, args))
      else:
        GM.Globals[GM.STDERR][GM.REDIRECT_MULTI_FD] = GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD]
    else:
      GM.Globals[GM.STDERR] = {}
  sysRC = ProcessGAMCommand(args)
  with mplock:
    if mpQueueStdout:
#      mpQueueStdout.put((pid, GM.REDIRECT_QUEUE_END, [sysRC, GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD].getvalue()]))
      writeStdQueueHandler(mpQueueStdout, (pid, GM.REDIRECT_QUEUE_END, [sysRC, GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD].getvalue()]))
      GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD].close()
      GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD] = None
    if mpQueueStderr and mpQueueStderr is not mpQueueStdout:
#      mpQueueStderr.put((pid, GM.REDIRECT_QUEUE_END, [sysRC, GM.Globals[GM.STDERR][GM.REDIRECT_MULTI_FD].getvalue()]))
      writeStdQueueHandler(mpQueueStderr, (pid, GM.REDIRECT_QUEUE_END, [sysRC, GM.Globals[GM.STDERR][GM.REDIRECT_MULTI_FD].getvalue()]))
      GM.Globals[GM.STDERR][GM.REDIRECT_MULTI_FD].close()
      GM.Globals[GM.STDERR][GM.REDIRECT_MULTI_FD] = None
  return (pid, sysRC, logCmd)

ERROR_PLURAL_SINGULAR = [Msg.ERRORS, Msg.ERROR]
PROCESS_PLURAL_SINGULAR = [Msg.PROCESSES, Msg.PROCESS]
THREAD_PLURAL_SINGULAR = [Msg.THREADS, Msg.THREAD]

def checkChildProcessRC(rc):
# Comparison
  if 'comp' in GM.Globals[GM.MULTIPROCESS_EXIT_CONDITION]:
    op = GM.Globals[GM.MULTIPROCESS_EXIT_CONDITION]['comp']
    value = GM.Globals[GM.MULTIPROCESS_EXIT_CONDITION]['value']
    if op == '<':
      return rc < value
    if op == '<=':
      return rc <= value
    if op == '>':
      return rc > value
    if op == '>=':
      return rc >= value
    if op == '!=':
      return rc != value
    return rc == value
# Range
  op = GM.Globals[GM.MULTIPROCESS_EXIT_CONDITION]['range']
  low = GM.Globals[GM.MULTIPROCESS_EXIT_CONDITION]['low']
  high = GM.Globals[GM.MULTIPROCESS_EXIT_CONDITION]['high']
  if op == '!=':
    return not low <= rc <= high
  return low <= rc <= high

def initGamWorker(l):
  global mplock
  mplock = l

def MultiprocessGAMCommands(items, showCmds):
  def poolCallback(result):
    poolProcessResults[0] -= 1
    if showCmds:
      batchWriteStderr(f'{currentISOformatTimeStamp()},{result[0]}/{numItems},End,{result[1]},{result[2]}\n')
    if GM.Globals[GM.CMDLOG_LOGGER]:
      GM.Globals[GM.CMDLOG_LOGGER].info(f'{currentISOformatTimeStamp()},{result[1]},{result[2]}')
    if GM.Globals[GM.MULTIPROCESS_EXIT_CONDITION] is not None and checkChildProcessRC(result[1]):
      GM.Globals[GM.MULTIPROCESS_EXIT_PROCESSING] = True

  def signal_handler(*_):
    nonlocal controlC
    controlC = True

  def handleControlC(source):
    nonlocal controlC
    batchWriteStderr(f'Control-C (Multiprocess-{source})\n')
    setSysExitRC(KEYBOARD_INTERRUPT_RC)
    batchWriteStderr(Msg.BATCH_CSV_TERMINATE_N_PROCESSES.format(currentISOformatTimeStamp(),
                                                                numItems, poolProcessResults[0],
                                                                PROCESS_PLURAL_SINGULAR[poolProcessResults[0] == 1]))
    pool.terminate()
    controlC = False

  if not items:
    return
  GM.Globals[GM.NUM_BATCH_ITEMS] = numItems = len(items)
  numPoolProcesses = min(numItems, GC.Values[GC.NUM_THREADS])
  if GC.Values[GC.MULTIPROCESS_POOL_LIMIT] == -1:
    parallelPoolProcesses = -1
  elif GC.Values[GC.MULTIPROCESS_POOL_LIMIT] == 0:
    parallelPoolProcesses = numPoolProcesses
  else:
    parallelPoolProcesses = min(numItems, GC.Values[GC.MULTIPROCESS_POOL_LIMIT])
#  origSigintHandler = signal.signal(signal.SIGINT, signal.SIG_IGN)
  signal.signal(signal.SIGINT, signal.SIG_IGN)
  mpManager = multiprocessing.Manager()
  l = mpManager.Lock()
  try:
    if multiprocessing.get_start_method() == 'spawn':
      pool = mpManager.Pool(processes=numPoolProcesses, initializer=initGamWorker, initargs=(l,), maxtasksperchild=200)
    else:
      pool = multiprocessing.Pool(processes=numPoolProcesses, initializer=initGamWorker, initargs=(l,), maxtasksperchild=200)
  except IOError as e:
    systemErrorExit(FILE_ERROR_RC, e)
  except AssertionError as e:
    Cmd.SetLocation(0)
    usageErrorExit(str(e))
  if multiprocessing.get_start_method() == 'spawn':
    savedValues = saveNonPickleableValues()
  if GM.Globals[GM.STDOUT][GM.REDIRECT_MULTIPROCESS]:
    mpQueueStdout, mpQueueHandlerStdout = initializeStdQueueHandler(mpManager, GM.STDOUT, GM.Globals, GC.Values)
    mpQueueStdout.put((0, GM.REDIRECT_QUEUE_START, Cmd.AllArguments()))
  else:
    mpQueueStdout = None
  if GM.Globals[GM.STDERR][GM.REDIRECT_MULTIPROCESS]:
    if GM.Globals[GM.STDERR][GM.REDIRECT_NAME] != 'stdout':
      mpQueueStderr, mpQueueHandlerStderr = initializeStdQueueHandler(mpManager, GM.STDERR, GM.Globals, GC.Values)
      mpQueueStderr.put((0, GM.REDIRECT_QUEUE_START, Cmd.AllArguments()))
    else:
      mpQueueStderr = mpQueueStdout
  else:
    mpQueueStderr = None
  if multiprocessing.get_start_method() == 'spawn':
    restoreNonPickleableValues(savedValues)
  if mpQueueStdout:
    mpQueueStdout.put((0, GM.REDIRECT_QUEUE_DATA, GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD].getvalue()))
    GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD].truncate(0)
  if mpQueueStderr and mpQueueStderr is not mpQueueStdout:
    mpQueueStderr.put((0, GM.REDIRECT_QUEUE_DATA, GM.Globals[GM.STDERR][GM.REDIRECT_MULTI_FD].getvalue()))
    GM.Globals[GM.STDERR][GM.REDIRECT_MULTI_FD].truncate(0)
  if GM.Globals[GM.CSVFILE][GM.REDIRECT_MULTIPROCESS]:
    mpQueueCSVFile, mpQueueHandlerCSVFile = initializeCSVFileQueueHandler(mpManager, mpQueueStdout, mpQueueStderr)
  else:
    mpQueueCSVFile = None
#  signal.signal(signal.SIGINT, origSigintHandler)
  controlC = False
  signal.signal(signal.SIGINT, signal_handler)
  batchWriteStderr(Msg.USING_N_PROCESSES.format(currentISOformatTimeStamp(),
                                                numItems, numPoolProcesses,
                                                PROCESS_PLURAL_SINGULAR[numPoolProcesses == 1]))
  try:
    pid = 0
    poolProcessResults = {pid: 0}
    for item in items:
      if GM.Globals[GM.MULTIPROCESS_EXIT_PROCESSING]:
        break
      if controlC:
        break
      if item[0] == Cmd.COMMIT_BATCH_CMD:
        batchWriteStderr(Msg.COMMIT_BATCH_WAIT_N_PROCESSES.format(currentISOformatTimeStamp(),
                                                                  numItems, poolProcessResults[0],
                                                                  PROCESS_PLURAL_SINGULAR[poolProcessResults[0] == 1]))
        while poolProcessResults[0] > 0:
          time.sleep(1)
          completedProcesses = []
          for p, result in iter(poolProcessResults.items()):
            if p != 0 and result.ready():
              poolCallback(result.get())
              completedProcesses.append(p)
          for p in completedProcesses:
            del poolProcessResults[p]
        batchWriteStderr(Msg.COMMIT_BATCH_COMPLETE.format(currentISOformatTimeStamp(), numItems, Msg.PROCESSES))
        if len(item) > 1:
          readStdin(f'{currentISOformatTimeStamp()},0/{numItems},{Cmd.QuotedArgumentList(item[1:])}')
        continue
      if item[0] == Cmd.PRINT_CMD:
        batchWriteStderr(Cmd.QuotedArgumentList(item[1:])+'\n')
        continue
      if item[0] == Cmd.SLEEP_CMD:
        batchWriteStderr(f'{currentISOformatTimeStamp()},0/{numItems},Sleepiing {item[1]} seconds\n')
        time.sleep(int(item[1]))
        continue
      pid += 1
      if not showCmds and ((pid % 100 == 0) or (pid == numItems)):
        batchWriteStderr(Msg.PROCESSING_ITEM_N_OF_M.format(currentISOformatTimeStamp(), pid, numItems))
      if showCmds or GM.Globals[GM.CMDLOG_LOGGER]:
        logCmd = Cmd.QuotedArgumentList(item)
        if showCmds:
          batchWriteStderr(f'{currentISOformatTimeStamp()},{pid}/{numItems},Start,0,{logCmd}\n')
      else:
        logCmd = ''
      poolProcessResults[pid] = pool.apply_async(ProcessGAMCommandMulti,
                                                 [pid, numItems, logCmd, mpQueueCSVFile, mpQueueStdout, mpQueueStderr,
                                                  GC.Values[GC.DEBUG_LEVEL], GM.Globals[GM.CSV_TODRIVE],
                                                  GC.Values[GC.PRINT_AGU_DOMAINS],
                                                  GC.Values[GC.PRINT_CROS_OUS], GC.Values[GC.PRINT_CROS_OUS_AND_CHILDREN],
                                                  GC.Values[GC.OUTPUT_DATEFORMAT], GC.Values[GC.OUTPUT_TIMEFORMAT],
                                                  GC.Values[GC.CSV_OUTPUT_COLUMN_DELIMITER],
                                                  GC.Values[GC.CSV_OUTPUT_NO_ESCAPE_CHAR],
                                                  GC.Values[GC.CSV_OUTPUT_QUOTE_CHAR],
                                                  GC.Values[GC.CSV_OUTPUT_SORT_HEADERS],
                                                  GC.Values[GC.CSV_OUTPUT_TIMESTAMP_COLUMN],
                                                  GC.Values[GC.CSV_OUTPUT_HEADER_FILTER],
                                                  GC.Values[GC.CSV_OUTPUT_HEADER_DROP_FILTER],
                                                  GC.Values[GC.CSV_OUTPUT_HEADER_FORCE],
                                                  GC.Values[GC.CSV_OUTPUT_HEADER_ORDER],
                                                  GC.Values[GC.CSV_OUTPUT_ROW_FILTER],
                                                  GC.Values[GC.CSV_OUTPUT_ROW_FILTER_MODE],
                                                  GC.Values[GC.CSV_OUTPUT_ROW_DROP_FILTER],
                                                  GC.Values[GC.CSV_OUTPUT_ROW_DROP_FILTER_MODE],
                                                  GC.Values[GC.CSV_OUTPUT_ROW_LIMIT],
                                                  GC.Values[GC.SHOW_GETTINGS], GC.Values[GC.SHOW_GETTINGS_GOT_NL],
                                                  item])
      poolProcessResults[0] += 1
      if parallelPoolProcesses > 0:
        while poolProcessResults[0] == parallelPoolProcesses:
          completedProcesses = []
          for p, result in iter(poolProcessResults.items()):
            if p != 0 and result.ready():
              poolCallback(result.get())
              completedProcesses.append(p)
          if completedProcesses:
            for p in completedProcesses:
              del poolProcessResults[p]
            break
          time.sleep(1)
    processWaitStart = time.time()
    if not controlC:
      if GC.Values[GC.PROCESS_WAIT_LIMIT] > 0:
        waitRemaining = GC.Values[GC.PROCESS_WAIT_LIMIT]
      else:
        waitRemaining = 'unlimited'
      while poolProcessResults[0] > 0:
        batchWriteStderr(Msg.BATCH_CSV_WAIT_N_PROCESSES.format(currentISOformatTimeStamp(),
                                                               numItems, poolProcessResults[0],
                                                               PROCESS_PLURAL_SINGULAR[poolProcessResults[0] == 1],
                                                               Msg.BATCH_CSV_WAIT_LIMIT.format(waitRemaining)))
        completedProcesses = []
        for p, result in iter(poolProcessResults.items()):
          if p != 0 and result.ready():
            poolCallback(result.get())
            completedProcesses.append(p)
        for p in completedProcesses:
          del poolProcessResults[p]
        if poolProcessResults[0] > 0:
          if controlC:
            handleControlC('SIG')
            break
          time.sleep(5)
          if GC.Values[GC.PROCESS_WAIT_LIMIT] > 0:
            delta = int(time.time()-processWaitStart)
            if delta >= GC.Values[GC.PROCESS_WAIT_LIMIT]:
              batchWriteStderr(Msg.BATCH_CSV_TERMINATE_N_PROCESSES.format(currentISOformatTimeStamp(),
                                                                          numItems, poolProcessResults[0],
                                                                          PROCESS_PLURAL_SINGULAR[poolProcessResults[0] == 1]))
              pool.terminate()
              break
            waitRemaining = GC.Values[GC.PROCESS_WAIT_LIMIT] - delta
      pool.close()
    else:
      handleControlC('SIG')
  except KeyboardInterrupt:
    handleControlC('KBI')
  pool.join()
  batchWriteStderr(Msg.BATCH_CSV_PROCESSING_COMPLETE.format(currentISOformatTimeStamp(), numItems))
  if mpQueueCSVFile:
    terminateCSVFileQueueHandler(mpQueueCSVFile, mpQueueHandlerCSVFile)
  if mpQueueStdout:
    mpQueueStdout.put((0, GM.REDIRECT_QUEUE_END, [GM.Globals[GM.SYSEXITRC], GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD].getvalue()]))
    GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD].close()
    GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD] = None
    terminateStdQueueHandler(mpQueueStdout, mpQueueHandlerStdout)
  if mpQueueStderr and mpQueueStderr is not mpQueueStdout:
    mpQueueStderr.put((0, GM.REDIRECT_QUEUE_END, [GM.Globals[GM.SYSEXITRC], GM.Globals[GM.STDERR][GM.REDIRECT_MULTI_FD].getvalue()]))
    GM.Globals[GM.STDERR][GM.REDIRECT_MULTI_FD].close()
    GM.Globals[GM.STDERR][GM.REDIRECT_MULTI_FD] = None
    terminateStdQueueHandler(mpQueueStderr, mpQueueHandlerStderr)

def threadBatchWorker(showCmds=False, numItems=0):
  while True:
    pid, item, logCmd = GM.Globals[GM.TBATCH_QUEUE].get()
    try:
      sysRC = subprocess.call(item, stdout=GM.Globals[GM.STDOUT].get(GM.REDIRECT_MULTI_FD, sys.stdout),
                              stderr=GM.Globals[GM.STDERR].get(GM.REDIRECT_MULTI_FD, sys.stderr))
      if showCmds:
        batchWriteStderr(f'{currentISOformatTimeStamp()},{pid}/{numItems},End,{sysRC},{logCmd}\n')
      if GM.Globals[GM.MULTIPROCESS_EXIT_CONDITION] is not None and checkChildProcessRC(sysRC):
        GM.Globals[GM.MULTIPROCESS_EXIT_PROCESSING] = True
    except Exception as e:
      batchWriteStderr(f'{currentISOformatTimeStamp()},{pid}/{numItems},Error,{str(e)},{logCmd}\n')
    GM.Globals[GM.TBATCH_QUEUE].task_done()

BATCH_COMMANDS = [Cmd.GAM_CMD, Cmd.COMMIT_BATCH_CMD, Cmd.PRINT_CMD, Cmd.SLEEP_CMD]
TBATCH_COMMANDS = [Cmd.GAM_CMD, Cmd.COMMIT_BATCH_CMD, Cmd.EXECUTE_CMD, Cmd.PRINT_CMD, Cmd.SLEEP_CMD]

def ThreadBatchGAMCommands(items, showCmds):
  if not items:
    return
  pythonCmd = [sys.executable]
  if not getattr(sys, 'frozen', False): # we're not frozen
    pythonCmd.append(os.path.realpath(Cmd.Argument(0)))
  GM.Globals[GM.NUM_BATCH_ITEMS] = numItems = len(items)
  numWorkerThreads = min(numItems, GC.Values[GC.NUM_TBATCH_THREADS])
# GM.Globals[GM.TBATCH_QUEUE].put() gets blocked when trying to create more items than there are workers
  GM.Globals[GM.TBATCH_QUEUE] = queue.Queue(maxsize=numWorkerThreads)
  batchWriteStderr(Msg.USING_N_PROCESSES.format(currentISOformatTimeStamp(),
                                                numItems, numWorkerThreads,
                                                THREAD_PLURAL_SINGULAR[numWorkerThreads == 1]))
  for _ in range(numWorkerThreads):
    t = threading.Thread(target=threadBatchWorker, kwargs={'showCmds': showCmds, 'numItems': numItems})
    t.daemon = True
    t.start()
  pid = 0
  numThreadsInUse = 0
  for item in items:
    if GM.Globals[GM.MULTIPROCESS_EXIT_PROCESSING]:
      break
    if item[0] == Cmd.COMMIT_BATCH_CMD:
      batchWriteStderr(Msg.COMMIT_BATCH_WAIT_N_PROCESSES.format(currentISOformatTimeStamp(),
                                                                numItems, numThreadsInUse,
                                                                THREAD_PLURAL_SINGULAR[numThreadsInUse == 1]))
      GM.Globals[GM.TBATCH_QUEUE].join()
      batchWriteStderr(Msg.COMMIT_BATCH_COMPLETE.format(currentISOformatTimeStamp(), numItems, Msg.THREADS))
      numThreadsInUse = 0
      if len(item) > 1:
        readStdin(f'{currentISOformatTimeStamp()},0/{numItems},{Cmd.QuotedArgumentList(item[1:])}')
      continue
    if item[0] == Cmd.PRINT_CMD:
      batchWriteStderr(f'{currentISOformatTimeStamp()},0/{numItems},{Cmd.QuotedArgumentList(item[1:])}\n')
      continue
    if item[0] == Cmd.SLEEP_CMD:
      batchWriteStderr(f'{currentISOformatTimeStamp()},0/{numItems},Sleeping {item[1]} seconds\n')
      time.sleep(int(item[1]))
      continue
    pid += 1
    if not showCmds and ((pid % 100 == 0) or (pid == numItems)):
      batchWriteStderr(Msg.PROCESSING_ITEM_N_OF_M.format(currentISOformatTimeStamp(), pid, numItems))
    if showCmds:
      logCmd = Cmd.QuotedArgumentList(item)
      batchWriteStderr(f'{currentISOformatTimeStamp()},{pid}/{numItems},Start,{Cmd.QuotedArgumentList(item)}\n')
    else:
      logCmd = ''
    if item[0] == Cmd.GAM_CMD:
      GM.Globals[GM.TBATCH_QUEUE].put((pid, pythonCmd+item[1:], logCmd))
    else:
      GM.Globals[GM.TBATCH_QUEUE].put((pid, item[1:], logCmd))
    numThreadsInUse += 1
  GM.Globals[GM.TBATCH_QUEUE].join()
  if showCmds:
    batchWriteStderr(f'{currentISOformatTimeStamp()},0/{numItems},Complete\n')

def _getShowCommands():
  if checkArgumentPresent('showcmds'):
    return getBoolean()
  return GC.Values[GC.SHOW_COMMANDS]

def _getSkipRows():
  if checkArgumentPresent('skiprows'):
    return getInteger(minVal=0)
#  return GC.Values[GC.CSV_INPUT_ROW_SKIP]
  return 0

def _getMaxRows():
  if checkArgumentPresent('maxrows'):
    return getInteger(minVal=0)
  return GC.Values[GC.CSV_INPUT_ROW_LIMIT]

# gam batch <BatchContent> [showcmds [<Boolean>]]
def doBatch(threadBatch=False):
  filename = getString(Cmd.OB_FILE_NAME)
  if (filename == '-') and (GC.Values[GC.DEBUG_LEVEL] > 0):
    Cmd.Backup()
    usageErrorExit(Msg.BATCH_CSV_LOOP_DASH_DEBUG_INCOMPATIBLE.format(Cmd.BATCH_CMD))
  filenameLower = filename.lower()
  if filenameLower not in {'gdoc', 'gcsdoc'}:
    encoding = getCharSet()
    f = openFile(filename, encoding=encoding, stripUTFBOM=True)
  elif filenameLower == 'gdoc':
    f = getGDocData(filenameLower)
    getCharSet()
  else: #filenameLower == 'gcsdoc':
    f = getStorageFileData(filenameLower)
    getCharSet()
  showCmds = _getShowCommands()
  checkForExtraneousArguments()
  validCommands = BATCH_COMMANDS if not threadBatch else TBATCH_COMMANDS
  kwValues = {}
  items = []
  errors = 0
  try:
    for line in f:
      if line.startswith('#'):
        continue
      if kwValues:
        for kw, value in iter(kwValues.items()):
          line = line.replace(f'%{kw}%', value)
      try:
        argv = shlex.split(line)
      except ValueError as e:
        writeStderr(f'Command: >>>{line.strip()}<<<\n')
        writeStderr(f'{ERROR_PREFIX}{str(e)}\n')
        errors += 1
        continue
      if argv:
        cmd = argv[0].strip().lower()
        if cmd == Cmd.SET_CMD:
          if len(argv) == 3:
            kwValues[argv[1]] = argv[2]
          else:
            writeStderr(f'Command: >>>{Cmd.QuotedArgumentList([argv[0]])}<<< {Cmd.QuotedArgumentList(argv[1:])}\n')
            writeStderr(f'{ERROR_PREFIX}{Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_INVALID][1]}: {Msg.EXPECTED} <{Cmd.SET_CMD} keyword value>)>\n')
            errors += 1
          continue
        if cmd == Cmd.CLEAR_CMD:
          if len(argv) == 2:
            kwValues.pop(argv[1], None)
          else:
            writeStderr(f'Command: >>>{Cmd.QuotedArgumentList([argv[0]])}<<< {Cmd.QuotedArgumentList(argv[1:])}\n')
            writeStderr(f'{ERROR_PREFIX}{Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_INVALID][1]}: {Msg.EXPECTED} <{Cmd.CLEAR_CMD} keyword>)>\n')
            errors += 1
          continue
        if cmd == Cmd.SLEEP_CMD:
          if len(argv) != 2 or not argv[1].isdigit():
            writeStderr(f'Command: >>>{Cmd.QuotedArgumentList([argv[0]])}<<< {Cmd.QuotedArgumentList(argv[1:])}\n')
            writeStderr(f'{ERROR_PREFIX}{Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_INVALID][1]}: {Msg.EXPECTED} <{Cmd.SLEEP_CMD} integer>)>\n')
            errors += 1
            continue
        if (not cmd) or ((len(argv) == 1) and (cmd not in [Cmd.COMMIT_BATCH_CMD, Cmd.PRINT_CMD])):
          continue
        if cmd in validCommands:
          items.append(argv)
        else:
          writeStderr(f'Command: >>>{Cmd.QuotedArgumentList([argv[0]])}<<< {Cmd.QuotedArgumentList(argv[1:])}\n')
          writeStderr(f'{ERROR_PREFIX}{Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_INVALID][1]}: {Msg.EXPECTED} <{formatChoiceList(validCommands)}>\n')
          errors += 1
  except IOError as e:
    systemErrorExit(FILE_ERROR_RC, fileErrorMessage(filename, e))
  closeFile(f)
  if errors == 0:
    if not threadBatch:
      MultiprocessGAMCommands(items, showCmds)
    else:
      ThreadBatchGAMCommands(items, showCmds)
  else:
    writeStderr(Msg.BATCH_NOT_PROCESSED_ERRORS.format(ERROR_PREFIX, filename, errors, ERROR_PLURAL_SINGULAR[errors == 1]))
    setSysExitRC(USAGE_ERROR_RC)

# gam tbatch <BatchContent> [showcmds [<Boolean>]]
def doThreadBatch():
  adjustRedirectedSTDFilesIfNotMultiprocessing()
  doBatch(True)

def doAutoBatch(entityType, entityList, CL_command):
  remaining = Cmd.Remaining()
  items = []
  initial_argv = [Cmd.GAM_CMD]
  if GM.Globals[GM.SECTION] and not GM.Globals[GM.GAM_CFG_SECTION]:
    initial_argv.extend([Cmd.SELECT_CMD, GM.Globals[GM.SECTION]])
  for entity in entityList:
    items.append(initial_argv+[entityType, entity, CL_command]+remaining)
  MultiprocessGAMCommands(items, GC.Values[GC.SHOW_COMMANDS])

# Process command line arguments, find substitutions
# An argument containing instances of ~~xxx~!~pattern~!~replacement~~ has ~~...~~ replaced by re.sub(pattern, replacement, value of field xxx from the CSV file)
# For example, ~~primaryEmail~!~^(.+)@(.+)$~!~\1 AT \2~~ would replace foo@bar.com (from the primaryEmail column) with foo AT bar.com
# An argument containing instances of ~~xxx~~ has xxx replaced by the value of field xxx from the CSV file
# An argument containing exactly ~xxx is replaced by the value of field xxx from the CSV file
# Otherwise, the argument is preserved as is

SUB_PATTERN = re.compile(r'~~(.+?)~~')
RE_PATTERN = re.compile(r'~~(.+?)~!~(.+?)~!~(.+?)~~')
SUB_TYPE = 'sub'
RE_TYPE = 're'

# SubFields is a dictionary; the key is the argument number, the value is a list of tuples that mark
# the substition (type, fieldname, start, end). Type is 'sub' for simple substitution, 're' for regex substitution.
# Example: update user '~User' address type work unstructured '~~Street~~, ~~City~~, ~~State~~ ~~ZIP~~' primary
# {2: [('sub', 'User', 0, 5)], 7: [('sub', 'Street', 0, 10), ('sub', 'City', 12, 20), ('sub', 'State', 22, 31), ('sub', 'ZIP', 32, 39)]}
def getSubFields(initial_argv, fieldNames):
  subFields = {}
  GAM_argv = initial_argv[:]
  GAM_argvI = len(GAM_argv)
  while Cmd.ArgumentsRemaining():
    myarg = Cmd.Current()
    if not myarg:
      GAM_argv.append(myarg)
    elif SUB_PATTERN.search(myarg):
      pos = 0
      subFields.setdefault(GAM_argvI, [])
      while True:
        submatch = SUB_PATTERN.search(myarg, pos)
        if not submatch:
          break
        rematch = RE_PATTERN.match(submatch.group(0))
        if not rematch:
          fieldName = submatch.group(1)
          if fieldName not in fieldNames:
            csvFieldErrorExit(fieldName, fieldNames)
          subFields[GAM_argvI].append((SUB_TYPE, fieldName, submatch.start(), submatch.end()))
        else:
          fieldName = rematch.group(1)
          if fieldName not in fieldNames:
            csvFieldErrorExit(fieldName, fieldNames)
          try:
            re.compile(rematch.group(2))
            subFields[GAM_argvI].append((RE_TYPE, fieldName, submatch.start(), submatch.end(), rematch.group(2), rematch.group(3)))
          except re.error as e:
            usageErrorExit(f'{Cmd.OB_RE_PATTERN} {Msg.ERROR}: {e}')
        pos = submatch.end()
      GAM_argv.append(myarg)
    elif myarg[0] == '~':
      fieldName = myarg[1:]
      if fieldName in fieldNames:
        subFields[GAM_argvI] = [(SUB_TYPE, fieldName, 0, len(myarg))]
        GAM_argv.append(myarg)
      else:
        csvFieldErrorExit(fieldName, fieldNames)
    else:
      GAM_argv.append(myarg)
    GAM_argvI += 1
    Cmd.Advance()
  return(GAM_argv, subFields)

def processSubFields(GAM_argv, row, subFields):
  argv = GAM_argv[:]
  for GAM_argvI, fields in iter(subFields.items()):
    oargv = argv[GAM_argvI][:]
    argv[GAM_argvI] = ''
    pos = 0
    for field in fields:
      argv[GAM_argvI] += oargv[pos:field[2]]
      if field[0] == SUB_TYPE:
        if row[field[1]]:
          argv[GAM_argvI] += row[field[1]]
      else:
        if row[field[1]]:
          argv[GAM_argvI] += re.sub(field[4], field[5], row[field[1]])
      pos = field[3]
    argv[GAM_argvI] += oargv[pos:]
  return argv

# gam csv <CSVLoopContent> [warnifnodata]
#	[columndelimiter <Character>] [quotechar <Character>] [fields <FieldNameList>]
#	(matchfield|skipfield <FieldName> <RESearchPattern>)* [showcmds [<Boolean>]]
#	[skiprows <Integer>] [maxrows <Integer>]
#	gam <GAM argument list>
def doCSV(testMode=False):
  filename = getString(Cmd.OB_FILE_NAME)
  if (filename == '-') and (GC.Values[GC.DEBUG_LEVEL] > 0):
    Cmd.Backup()
    usageErrorExit(Msg.BATCH_CSV_LOOP_DASH_DEBUG_INCOMPATIBLE.format(Cmd.CSV_CMD))
  f, csvFile, fieldnames = openCSVFileReader(filename)
  matchFields, skipFields = getMatchSkipFields(fieldnames)
  showCmds = _getShowCommands()
  skipRows = _getSkipRows()
  maxRows = _getMaxRows()
  checkArgumentPresent(Cmd.GAM_CMD, required=True)
  if not Cmd.ArgumentsRemaining():
    missingArgumentExit(Cmd.OB_GAM_ARGUMENT_LIST)
  initial_argv = [Cmd.GAM_CMD]
  if GM.Globals[GM.SECTION] and not GM.Globals[GM.GAM_CFG_SECTION] and not Cmd.PeekArgumentPresent(Cmd.SELECT_CMD):
    initial_argv.extend([Cmd.SELECT_CMD, GM.Globals[GM.SECTION]])
  GAM_argv, subFields = getSubFields(initial_argv, fieldnames)
  if GC.Values[GC.CSV_INPUT_ROW_FILTER] or GC.Values[GC.CSV_INPUT_ROW_DROP_FILTER]:
    CheckInputRowFilterHeaders(fieldnames, GC.Values[GC.CSV_INPUT_ROW_FILTER], GC.Values[GC.CSV_INPUT_ROW_DROP_FILTER])
  items = []
  i = 0
  for row in csvFile:
    if checkMatchSkipFields(row, fieldnames, matchFields, skipFields):
      i += 1
      if skipRows:
        if i <= skipRows:
          continue
        i = 1
        skipRows = 0
      items.append(processSubFields(GAM_argv, row, subFields))
      if maxRows and i >= maxRows:
        break
  closeFile(f)
  if not testMode:
    MultiprocessGAMCommands(items, showCmds)
  else:
    numItems = min(len(items), 10)
    writeStdout(Msg.CSV_FILE_HEADERS.format(filename))
    Ind.Increment()
    for field in fieldnames:
      writeStdout(f'{Ind.Spaces()}{field}\n')
    Ind.Decrement()
    writeStdout(Msg.CSV_SAMPLE_COMMANDS.format(numItems, GAM))
    Ind.Increment()
    for i in range(numItems):
      writeStdout(f'{Ind.Spaces()}{Cmd.QuotedArgumentList(items[i])}\n')
    Ind.Decrement()

def doCSVTest():
  doCSV(testMode=True)

# gam loop <CSVLoopContent> [warnifnodata]
#	[columndelimiter <Character>] [quotechar <Character>] [fields <FieldNameList>]
#	(matchfield|skipfield <FieldName> <RESearchPattern>)* [showcmds [<Boolean>]]
#	[skiprows <Integer>] [maxrows <Integer>]
#	gam <GAM argument list>
def doLoop(loopCmd):
  filename = getString(Cmd.OB_FILE_NAME)
  if (filename == '-') and (GC.Values[GC.DEBUG_LEVEL] > 0):
    Cmd.Backup()
    usageErrorExit(Msg.BATCH_CSV_LOOP_DASH_DEBUG_INCOMPATIBLE.format(Cmd.LOOP_CMD))
  f, csvFile, fieldnames = openCSVFileReader(filename)
  matchFields, skipFields = getMatchSkipFields(fieldnames)
  showCmds = _getShowCommands()
  skipRows = _getSkipRows()
  maxRows = _getMaxRows()
  checkArgumentPresent(Cmd.GAM_CMD, required=True)
  if not Cmd.ArgumentsRemaining():
    missingArgumentExit(Cmd.OB_GAM_ARGUMENT_LIST)
  if GC.Values[GC.CSV_INPUT_ROW_FILTER] or GC.Values[GC.CSV_INPUT_ROW_DROP_FILTER]:
    CheckInputRowFilterHeaders(fieldnames, GC.Values[GC.CSV_INPUT_ROW_FILTER], GC.Values[GC.CSV_INPUT_ROW_DROP_FILTER])
  choice = Cmd.Current().strip().lower()
  if choice == Cmd.LOOP_CMD:
    usageErrorExit(Msg.NESTED_LOOP_CMD_NOT_ALLOWED)
# gam loop ... gam redirect|select|config ... process gam.cfg on each iteration
# gam redirect|select|config ... loop ... gam redirect|select|config ... process gam.cfg on each iteration
# gam loop ... gam !redirect|select|config ... no further processing of gam.cfg
# gam redirect|select|config ... loop ... gam !redirect|select|config ... no further processing of gam.cfg
  processGamCfg = choice in Cmd.GAM_META_COMMANDS
  GAM_argv, subFields = getSubFields([Cmd.GAM_CMD], fieldnames)
  multi = GM.Globals[GM.CSVFILE][GM.REDIRECT_MULTIPROCESS]
  if multi:
    mpManager = multiprocessing.Manager()
    mpQueue, mpQueueHandler = initializeCSVFileQueueHandler(mpManager, None, None)
  else:
    mpQueue = None
  GM.Globals[GM.CSVFILE][GM.REDIRECT_QUEUE] = mpQueue
# Set up command logging at top level only
  if GM.Globals[GM.CMDLOG_LOGGER]:
    LoopGlobals = GM.Globals
  else:
    LoopGlobals = {GM.CMDLOG_LOGGER: None, GM.CMDLOG_HANDLER: None}
    if (GM.Globals[GM.PID] > 0) and GC.Values[GC.CMDLOG]:
      openGAMCommandLog(LoopGlobals, 'looplog')
  if LoopGlobals[GM.CMDLOG_LOGGER]:
    writeGAMCommandLog(LoopGlobals, loopCmd, '*')
  if not showCmds:
    i = 0
    for row in csvFile:
      if checkMatchSkipFields(row, fieldnames, matchFields, skipFields):
        i += 1
        if skipRows:
          if i <= skipRows:
            continue
          i = 1
          skipRows = 0
        item = processSubFields(GAM_argv, row, subFields)
        logCmd = Cmd.QuotedArgumentList(item)
        if i % 100 == 0:
          batchWriteStderr(Msg.PROCESSING_ITEM_N.format(currentISOformatTimeStamp(), i))
        sysRC = ProcessGAMCommand(item, processGamCfg=processGamCfg, inLoop=True)
        if (GM.Globals[GM.PID] > 0) and LoopGlobals[GM.CMDLOG_LOGGER]:
          writeGAMCommandLog(LoopGlobals, logCmd, sysRC)
        if (sysRC > 0) and (GM.Globals[GM.SYSEXITRC] <= HARD_ERROR_RC):
          break
        if maxRows and i >= maxRows:
          break
    closeFile(f)
  else:
    items = []
    i = 0
    for row in csvFile:
      if checkMatchSkipFields(row, fieldnames, matchFields, skipFields):
        i += 1
        if skipRows:
          if i <= skipRows:
            continue
          i = 1
          skipRows = 0
        items.append(processSubFields(GAM_argv, row, subFields))
        if maxRows and i >= maxRows:
          break
    closeFile(f)
    numItems = len(items)
    pid = 0
    for item in items:
      pid += 1
      logCmd = Cmd.QuotedArgumentList(item)
      batchWriteStderr(f'{currentISOformatTimeStamp()},{pid}/{numItems},Start,0,{logCmd}\n')
      sysRC = ProcessGAMCommand(item, processGamCfg=processGamCfg, inLoop=True)
      batchWriteStderr(f'{currentISOformatTimeStamp()},{pid}/{numItems},End,{sysRC},{logCmd}\n')
      if (GM.Globals[GM.PID] > 0) and LoopGlobals[GM.CMDLOG_LOGGER]:
        writeGAMCommandLog(LoopGlobals, logCmd, sysRC)
      if (sysRC > 0) and (GM.Globals[GM.SYSEXITRC] <= HARD_ERROR_RC):
        break
  if (GM.Globals[GM.PID] > 0) and LoopGlobals[GM.CMDLOG_LOGGER]:
    closeGAMCommandLog(LoopGlobals)
  if multi:
    terminateCSVFileQueueHandler(mpQueue, mpQueueHandler)

def _doList(entityList, entityType):
  buildGAPIObject(API.DIRECTORY)
  if GM.Globals[GM.CSV_DATA_DICT]:
    keyField = GM.Globals[GM.CSV_KEY_FIELD]
    dataField = GM.Globals[GM.CSV_DATA_FIELD]
  else:
    keyField = 'Entity'
    dataField = 'Data'
  csvPF = CSVPrintFile(keyField)
  if checkArgumentPresent('todrive'):
    csvPF.GetTodriveParameters()
  if entityList is None:
    entityList = getEntityList(Cmd.OB_ENTITY)
  showData = checkArgumentPresent('data')
  if showData:
    if not entityType:
      itemType, itemList = getEntityToModify(crosAllowed=True)
    else:
      itemType = None
      itemList = getEntityList(Cmd.OB_ENTITY)
    entityItemLists = itemList if isinstance(itemList, dict) else None
    csvPF.AddTitle(dataField)
  else:
    entityItemLists = None
  dataDelimiter = getDelimiter()
  checkForExtraneousArguments()
  _, _, entityList = getEntityArgument(entityList)
  for entity in entityList:
    entityEmail = normalizeEmailAddressOrUID(entity)
    if showData:
      if entityItemLists:
        if entity not in entityItemLists:
          csvPF.WriteRow({keyField: entityEmail})
          continue
        itemList = entityItemLists[entity]
        if itemType == Cmd.ENTITY_USERS:
          for i, item in enumerate(itemList):
            itemList[i] = normalizeEmailAddressOrUID(item)
      if dataDelimiter:
        csvPF.WriteRow({keyField: entityEmail, dataField: dataDelimiter.join(itemList)})
      else:
        for item in itemList:
          csvPF.WriteRow({keyField: entityEmail, dataField: item})
    else:
      csvPF.WriteRow({keyField: entityEmail})
  csvPF.writeCSVfile('Entity')

# gam list [todrive <ToDriveAttribute>*] <EntityList> [data <CrOSTypeEntity>|<UserTypeEntity> [delimiter <Character>]]
def doListType():
  _doList(None, None)

# gam <CrOSTypeEntity> list [todrive <ToDriveAttribute>*] [data <EntityList> [delimiter <Character>]]
def doListCrOS(entityList):
  _doList(entityList, Cmd.ENTITY_CROS)

# gam <UserTypeEntity> list [todrive <ToDriveAttribute>*] [data <EntityList> [delimiter <Character>]]
def doListUser(entityList):
  _doList(entityList, Cmd.ENTITY_USERS)

def _showCount(entityList, entityType):
  buildGAPIObject(API.DIRECTORY)
  checkForExtraneousArguments()
  _, count, entityList = getEntityArgument(entityList)
  actionPerformedNumItems(count, entityType)

# gam <CrOSTypeEntity> show count
def showCountCrOS(entityList):
  _showCount(entityList, Ent.CHROME_DEVICE)

# gam <UserTypeEntity> show count
def showCountUser(entityList):
  _showCount(entityList, Ent.USER)

VALIDEMAIL_PATTERN = re.compile(r'^[^@]+@[^@]+\.[^@]+$')

def _getValidateLoginHint(login_hint, projectId=None):
  while True:
    if not login_hint:
      if not projectId:
        login_hint = readStdin(Msg.ENTER_GSUITE_ADMIN_EMAIL_ADDRESS).strip()
      else:
        login_hint = readStdin(Msg.ENTER_MANAGE_GCP_PROJECT_EMAIL_ADDRESS.format(projectId)).strip()
    if login_hint.find('@') == -1 and GC.Values[GC.DOMAIN]:
      login_hint = f'{login_hint}@{GC.Values[GC.DOMAIN]}'
    if VALIDEMAIL_PATTERN.match(login_hint):
      return login_hint
    sys.stdout.write(f'{ERROR_PREFIX}Invalid email address: {login_hint}\n')
    login_hint = None

def getOAuthClientIDAndSecret():
  cs_data = readFile(GC.Values[GC.CLIENT_SECRETS_JSON], continueOnError=True, displayError=True)
  if not cs_data:
    invalidClientSecretsJsonExit(Msg.NO_DATA)
  try:
    cs_json = json.loads(cs_data)
    if not cs_json:
      systemErrorExit(CLIENT_SECRETS_JSON_REQUIRED_RC, Msg.NO_CLIENT_ACCESS_CREATE_UPDATE_ALLOWED)
    return (cs_json['installed']['client_id'], cs_json['installed']['client_secret'])
  except (IndexError, KeyError, SyntaxError, TypeError, ValueError) as e:
    invalidClientSecretsJsonExit(str(e))

def getScopesFromUser(scopesList, clientAccess, currentScopes=None):
  OAUTH2_CMDS = ['s', 'u', 'e', 'c']
  oauth2_menu = ''
  numScopes = len(scopesList)
  for a_scope in scopesList:
    oauth2_menu += f"[%%s] %2d)  {a_scope['name']}"
    if a_scope['subscopes']:
      oauth2_menu += f' (supports {" and ".join(a_scope["subscopes"])})'
    oauth2_menu += '\n'
  oauth2_menu += '''
Select an unselected scope [ ] by entering a number; yields [*]
For scopes that support readonly, enter a number and an 'r' to grant read-only access; yields [R]
For scopes that support action, enter a number and an 'a' to grant action-only access; yields [A]
Clear read-only access [R] or action-only access [A] from a scope by entering a number; yields [*]
Unselect a selected scope [*] by entering a number; yields [ ]
Select all default scopes by entering an 's'; yields [*] for default scopes, [ ] for others
Unselect all scopes by entering a 'u'; yields [ ] for all scopes
Exit without changes/authorization by entering an 'e'
Continue to authorization by entering a 'c'
'''
  if clientAccess:
    oauth2_menu += '''  Note, if all scopes are selected, Google will probably generate an authorization error
'''
  menu = oauth2_menu % tuple(range(numScopes))
  selectedScopes = ['*'] * numScopes
  if currentScopes is None and clientAccess:
    lock = FileLock(GM.Globals[GM.OAUTH2_TXT_LOCK])
    with lock:
      _, credentials = getOauth2TxtCredentials(exitOnError=False)
      if credentials and credentials.scopes is not None:
        currentScopes = sorted(credentials.scopes)
  if currentScopes is not None:
    if clientAccess:
      i = 0
      for a_scope in scopesList:
        selectedScopes[i] = ' '
        possibleScope = a_scope['scope']
        for currentScope in currentScopes:
          if currentScope == possibleScope:
            selectedScopes[i] = '*'
            break
          if 'readonly' in a_scope['subscopes']:
            if currentScope == possibleScope+'.readonly':
              selectedScopes[i] = 'R'
              break
          if 'action' in a_scope['subscopes']:
            if currentScope == possibleScope+'.action':
              selectedScopes[i] = 'A'
              break
        i += 1
    else:
      i = 0
      for a_scope in scopesList:
        selectedScopes[i] = ' '
        api = a_scope['api']
        possibleScope = a_scope['scope']
        if api in currentScopes:
          for scope in currentScopes[api]:
            if scope == possibleScope:
              selectedScopes[i] = '*'
              break
            if 'readonly' in a_scope['subscopes']:
              if (scope == possibleScope+'.readonly') or (scope == a_scope.get('roscope')):
                selectedScopes[i] = 'R'
                break
        i += 1
  else:
    i = 0
    for a_scope in scopesList:
      if a_scope.get('offByDefault'):
        selectedScopes[i] = ' '
      elif a_scope.get('roByDefault'):
        selectedScopes[i] = 'R'
      else:
        selectedScopes[i] = '*'
      i += 1
  prompt = f'\nPlease enter 0-{numScopes-1}[a|r] or {"|".join(OAUTH2_CMDS)}: '
  while True:
    os.system(['clear', 'cls'][sys.platform.startswith('win')])
    sys.stdout.write(menu % tuple(selectedScopes))
    while True:
      choice = readStdin(prompt)
      if choice:
        selection = choice.lower()
        if selection.find('r') >= 0:
          mode = 'R'
          selection = selection.replace('r', '')
        elif selection.find('a') >= 0:
          mode = 'A'
          selection = selection.replace('a', '')
        else:
          mode = ' '
        if selection and selection.isdigit():
          selection = int(selection)
        if isinstance(selection, int) and selection < numScopes:
          if mode == 'R':
            if 'readonly' not in scopesList[selection]['subscopes']:
              sys.stdout.write(f'{ERROR_PREFIX}Scope {selection} does not support read-only mode!\n')
              continue
          elif mode == 'A':
            if 'action' not in scopesList[selection]['subscopes']:
              sys.stdout.write(f'{ERROR_PREFIX}Scope {selection} does not support action-only mode!\n')
              continue
          elif selectedScopes[selection] != '*':
            mode = '*'
          else:
            mode = ' '
          selectedScopes[selection] = mode
          break
        if isinstance(selection, str) and selection in OAUTH2_CMDS:
          if selection == 's':
            i = 0
            for a_scope in scopesList:
              selectedScopes[i] = ' ' if a_scope.get('offByDefault', False) else '*'
              i += 1
          elif selection == 'u':
            for i in range(numScopes):
              selectedScopes[i] = ' '
          elif selection == 'e':
            return None
          break
        sys.stdout.write(f'{ERROR_PREFIX}Invalid input "{choice}"\n')
    if selection == 'c':
      break
  return selectedScopes

def _localhost_to_ip():
  '''returns IPv4 or IPv6 loopback address which localhost resolves to.
     If localhost does not resolve to valid loopback IP address then returns
     127.0.0.1'''
  # TODO gethostbyname() will only ever return ipv4
  # find a way to support IPv6 here and get preferred IP
  # note that IPv6 may be broken on some systems also :-(
  # for now IPv4 should do.
  local_ip = socket.gethostbyname('localhost')
#  local_ip = socket.getaddrinfo('localhost', None)[0][-1][0] # works with ipv6, makes wsgiref fail
  if not ipaddress.ip_address(local_ip).is_loopback:
    local_ip = '127.0.0.1'
  return local_ip

def _waitForHttpClient(d):
  wsgi_app = google_auth_oauthlib.flow._RedirectWSGIApp(Msg.AUTHENTICATION_FLOW_COMPLETE_CLOSE_BROWSER.format(GAM))
  wsgiref.simple_server.WSGIServer.allow_reuse_address = False
  # Convert hostname to IP since apparently binding to the IP
  # reduces odds of firewall blocking us
  local_ip = _localhost_to_ip()
  for port in range(8080, 8099):
    try:
      local_server = wsgiref.simple_server.make_server(
        local_ip,
        port,
        wsgi_app,
        handler_class=wsgiref.simple_server.WSGIRequestHandler
        )
      break
    except OSError:
      pass
  redirect_uri_format = "http://{}:{}/" if d['trailing_slash'] else "http://{}:{}"
  # provide redirect_uri to main process so it can formulate auth_url
  d['redirect_uri'] = redirect_uri_format.format(*local_server.server_address)
  # wait until main process provides auth_url
  # so we can open it in web browser.
  while 'auth_url' not in d:
    time.sleep(0.1)
  if d['open_browser']:
    webbrowser.open(d['auth_url'], new=1, autoraise=True)
  try:
    local_server.handle_request()
    authorization_response = wsgi_app.last_request_uri.replace("http", "https")
    d['code'] = authorization_response
  except:
    pass
  local_server.server_close()

def _waitForUserInput(d):
  sys.stdin = open(0, DEFAULT_FILE_READ_MODE, encoding=UTF8)
  d['code'] = readStdin(Msg.ENTER_VERIFICATION_CODE_OR_URL)

class _GamOauthFlow(google_auth_oauthlib.flow.InstalledAppFlow):
  def run_dual(self, **kwargs):
    mgr = multiprocessing.Manager()
    d = mgr.dict()
    d['trailing_slash'] = True
    d['open_browser'] = not GC.Values[GC.NO_BROWSER]
    httpClientProcess = multiprocessing.Process(target=_waitForHttpClient, args=(d,))
    userInputProcess = multiprocessing.Process(target=_waitForUserInput, args=(d,))
    httpClientProcess.start()
    # we need to wait until web server starts on avail port
    # so we know redirect_uri to use
    while 'redirect_uri' not in d:
      time.sleep(0.1)
    self.redirect_uri = d['redirect_uri']
    d['auth_url'], _ = super().authorization_url(**kwargs)
    d['auth_url'] = shortenURL(d['auth_url'])
    print(Msg.OAUTH2_GO_TO_LINK_MESSAGE.format(url=d['auth_url']))
    userInputProcess.start()
    userInput = False
    checkHttp = checkUser = True
    alive = 2
    while alive > 0:
      time.sleep(0.1)
      if checkHttp and not httpClientProcess.is_alive():
        if 'code' in d:
          if checkUser:
            userInputProcess.terminate()
          break
        checkHttp = False
        alive -= 1
      if checkUser and not userInputProcess.is_alive():
        userInput = True
        if 'code' in d:
          if checkHttp:
            httpClientProcess.terminate()
          break
        checkUser = False
        alive -= 1
    if 'code' not in d:
      systemErrorExit(SYSTEM_ERROR_RC, Msg.AUTHENTICATION_FLOW_FAILED)
    while True:
      code = d['code']
      if code.startswith('http'):
        parsed_url = urlparse(code)
        parsed_params = parse_qs(parsed_url.query)
        code = parsed_params.get('code', [None])[0]
      try:
        fetch_args = {'code': code}
        if GC.Values[GC.CACERTS_PEM]:
          fetch_args['verify'] = GC.Values[GC.CACERTS_PEM]
        self.fetch_token(**fetch_args)
        break
      except Exception as e:
        if not userInput:
          systemErrorExit(INVALID_TOKEN_RC, str(e))
        stderrErrorMsg(str(e))
        _waitForUserInput(d)
    print(Msg.AUTHENTICATION_FLOW_COMPLETE)
    return self.credentials

class Credentials(google.oauth2.credentials.Credentials):
  """Google OAuth2.0 Credentials with GAM-specific properties and methods."""

  def __init__(self,
               token,
               refresh_token=None,
               id_token=None,
               token_uri=None,
               client_id=None,
               client_secret=None,
               scopes=None,
               quota_project_id=None,
               expiry=None,
               id_token_data=None,
               filename=None):
    """A thread-safe OAuth2.0 credentials object.

    Credentials adds additional utility properties and methods to a
    standard OAuth2.0 credentials object. When used to store credentials on
    disk, it implements a file lock to avoid collision during writes.

    Args:
      token: Optional String, The OAuth 2.0 access token. Can be None if refresh
        information is provided.
      refresh_token: String, The OAuth 2.0 refresh token. If specified,
        credentials can be refreshed.
      id_token: String, The Open ID Connect ID Token.
      token_uri: String, The OAuth 2.0 authorization server's token endpoint
        URI. Must be specified for refresh, can be left as None if the token can
        not be refreshed.
      client_id: String, The OAuth 2.0 client ID. Must be specified for refresh,
        can be left as None if the token can not be refreshed.
      client_secret: String, The OAuth 2.0 client secret. Must be specified for
        refresh, can be left as None if the token can not be refreshed.
      scopes: Sequence[str], The scopes used to obtain authorization.
        This parameter is used by :meth:`has_scopes`. OAuth 2.0 credentials can
          not request additional scopes after authorization. The scopes must be
          derivable from the refresh token if refresh information is provided
          (e.g. The refresh token scopes are a superset of this or contain a
          wild card scope like
            'https://www.googleapis.com/auth/any-api').
      quota_project_id: String, The project ID used for quota and billing. This
        project may be different from the project used to create the
        credentials.
      expiry: datetime.datetime, The time at which the provided token will
        expire.
      id_token_data: Oauth2.0 ID Token data which was previously fetched for
        this access token against the google.oauth2.id_token library.
      filename: String, Path to a file that will be used to store the
        credentials. If provided, a lock file of the same name and a ".lock"
          extension will be created for concurrency controls. Note: New
            credentials are not saved to disk until write() or refresh() are
            called.

    Raises:
      TypeError: If id_token_data is not the required dict type.
    """
    super().__init__(token=token,
                     refresh_token=refresh_token,
                     id_token=id_token,
                     token_uri=token_uri,
                     client_id=client_id,
                     client_secret=client_secret,
                     scopes=scopes,
                     quota_project_id=quota_project_id)

    # Load data not restored by the super class
    self.expiry = expiry
    if id_token_data and not isinstance(id_token_data, dict):
      raise TypeError(f'Expected type id_token_data dict but received {type(id_token_data)}')
    self._id_token_data = id_token_data.copy() if id_token_data else None

    # If a filename is provided, use a lock file to control concurrent access
    # to the resource. If no filename is provided, use a thread lock that has
    # the same interface as FileLock in order to simplify the implementation.
    if filename:
      # Convert relative paths into absolute
      self._filename = os.path.abspath(filename)
    else:
      self._filename = None

  # Use a property to prevent external mutation of the filename.
  @property
  def filename(self):
    return self._filename

  @classmethod
  def from_authorized_user_info_gam(cls, info, filename=None):
    """Generates Credentials from JSON containing authorized user info.

    Args:
      info: Dict, authorized user info in Google format.
      filename: String, the filename used to store these credentials on disk. If
        no filename is provided, the credentials will not be saved to disk.

    Raises:
      ValueError: If missing fields are detected in the info.
    """
    # We need all of these keys
    keys_needed = {'client_id', 'client_secret'}
    # We need 1 or more of these keys
    keys_need_one_of = {'refresh_token', 'auth_token', 'token'}
    missing = keys_needed.difference(info.keys())
    has_one_of = set(info) & keys_need_one_of
    if missing or not has_one_of:
      raise ValueError(
        'Authorized user info was not in the expected format, missing '
        f'fields {", ".join(missing)} and one of {", ".join(keys_need_one_of)}.')

    expiry = info.get('token_expiry')
    if expiry:
      # Convert the raw expiry to datetime
      expiry = datetime.datetime.strptime(expiry, YYYYMMDDTHHMMSSZ_FORMAT)
    id_token_data = info.get('decoded_id_token')

    # Provide backwards compatibility with field names when loading from JSON.
    # Some field names may be different, depending on when/how the credentials
    # were pickled.
    return cls(token=info.get('token', info.get('auth_token', '')),
               refresh_token=info.get('refresh_token', ''),
               id_token=info.get('id_token_jwt', info.get('id_token')),
               token_uri=info.get('token_uri'),
               client_id=info['client_id'],
               client_secret=info['client_secret'],
               scopes=info.get('scopes'),
               quota_project_id=info.get('quota_project_id'),
               expiry=expiry,
               id_token_data=id_token_data,
               filename=filename)

  @classmethod
  def from_google_oauth2_credentials(cls, credentials, filename=None):
    """Generates Credentials from a google.oauth2.Credentials object."""
    info = json.loads(credentials.to_json())
    # Add properties which are not exported with the native to_json() output.
    info['id_token'] = credentials.id_token
    if credentials.expiry:
      info['token_expiry'] = credentials.expiry.strftime(YYYYMMDDTHHMMSSZ_FORMAT)
    info['quota_project_id'] = credentials.quota_project_id

    return cls.from_authorized_user_info_gam(info, filename=filename)

  @classmethod
  def from_client_secrets(cls,
                          client_id,
                          client_secret,
                          scopes,
                          access_type='offline',
                          login_hint=None,
                          filename=None,
                          open_browser=True):
    """Runs an OAuth Flow from client secrets to generate credentials.

    Args:
      client_id: String, The OAuth2.0 Client ID.
      client_secret: String, The OAuth2.0 Client Secret.
      scopes: Sequence[str], A list of scopes to include in the credentials.
      access_type: String, 'offline' or 'online'.  Indicates whether your
        application can refresh access tokens when the user is not present at
        the browser. Valid parameter values are online, which is the default
        value, and offline.  Set the value to offline if your application needs
        to refresh access tokens when the user is not present at the browser.
        This is the method of refreshing access tokens described later in this
        document. This value instructs the Google authorization server to return
        a refresh token and an access token the first time that your application
        exchanges an authorization code for tokens.
      login_hint: String, The email address that will be displayed on the Google
        login page as a hint for the user to login to the correct account.
      filename: String, the path to a file to use to save the credentials.
      open_browser: Boolean: whether or not GAM should try to open the browser
        automatically.

    Returns:
      Credentials
    """
    client_config = {
      'installed': {
        'client_id': client_id,
        'client_secret': client_secret,
        'redirect_uris': ['http://localhost'],
        'auth_uri': API.GOOGLE_OAUTH2_ENDPOINT,
        'token_uri': API.GOOGLE_OAUTH2_TOKEN_ENDPOINT,
        }
    }

    flow = _GamOauthFlow.from_client_config(client_config,
                                            scopes,
                                            autogenerate_code_verifier=True)
    flow_kwargs = {'access_type': access_type,
                   'open_browser': open_browser}
    if login_hint:
      flow_kwargs['login_hint'] = login_hint
    flow.run_dual(**flow_kwargs)
    return cls.from_google_oauth2_credentials(flow.credentials, filename=filename)

  def to_json(self, strip=None):
    """Creates a JSON representation of a Credentials.

    Args:
        strip: Sequence[str], Optional list of members to exclude from the
          generated JSON.

    Returns:
        str: A JSON representation of this instance, suitable to pass to
             from_json().
    """
    expiry = self.expiry.strftime(YYYYMMDDTHHMMSSZ_FORMAT) if self.expiry else None
    prep = {
      'token': self.token,
      'refresh_token': self.refresh_token,
      'token_uri': self.token_uri,
      'client_id': self.client_id,
      'client_secret': self.client_secret,
      'id_token': self.id_token,
      # Google auth doesn't currently give us scopes back on refresh.
      # 'scopes': sorted(self.scopes),
      'token_expiry': expiry,
      'decoded_id_token': self._id_token_data,
      }

    # Remove empty entries
    prep = {k: v for k, v in prep.items() if v is not None}

    # Remove entries that explicitly need to be removed
    if strip is not None:
      prep = {k: v for k, v in prep.items() if k not in strip}

    return json.dumps(prep, indent=2, sort_keys=True)

def doOAuthRequest(currentScopes, login_hint, verifyScopes=False):
  client_id, client_secret = getOAuthClientIDAndSecret()
  scopesList = API.getClientScopesList(GC.Values[GC.TODRIVE_CLIENTACCESS])
  if not currentScopes or verifyScopes:
    selectedScopes = getScopesFromUser(scopesList, True, currentScopes)
    if selectedScopes is None:
      return False
    scopes = set(API.REQUIRED_SCOPES)
    i = 0
    for scope in scopesList:
      if selectedScopes[i] == '*':
        if scope['scope']:
          scopes.add(scope['scope'])
      elif selectedScopes[i] == 'R':
        scopes.add(f'{scope["scope"]}.readonly')
      elif selectedScopes[i] == 'A':
        scopes.add(f'{scope["scope"]}.action')
      i += 1
  else:
    scopes = set(currentScopes+API.REQUIRED_SCOPES)
  if API.STORAGE_READWRITE_SCOPE in scopes:
    scopes.discard(API.STORAGE_READONLY_SCOPE)
  login_hint = _getValidateLoginHint(login_hint)
# Needs to be set so oauthlib doesn't puke when Google changes our scopes
  os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = 'true'
  credentials = Credentials.from_client_secrets(
    client_id,
    client_secret,
    scopes=list(scopes),
    access_type='offline',
    login_hint=login_hint,
    open_browser=not GC.Values[GC.NO_BROWSER])
  lock = FileLock(GM.Globals[GM.OAUTH2_TXT_LOCK])
  with lock:
    writeClientCredentials(credentials, GC.Values[GC.OAUTH2_TXT])
  entityActionPerformed([Ent.OAUTH2_TXT_FILE, GC.Values[GC.OAUTH2_TXT]])
  return True

# gam oauth|oauth2 create|request [<EmailAddress>]
# gam oauth|oauth2 create|request [admin <EmailAddress>] [scope|scopes <APIScopeURLList>]
def doOAuthCreate():
  if not Cmd.PeekArgumentPresent(['admin', 'scope', 'scopes']):
    login_hint = getEmailAddress(noUid=True, optional=True)
    scopes = None
    checkForExtraneousArguments()
  else:
    login_hint = None
    scopes = []
    scopesList = API.getClientScopesList(GC.Values[GC.TODRIVE_CLIENTACCESS])
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg == 'admin':
        login_hint = getEmailAddress(noUid=True)
      elif myarg in {'scope', 'scopes'}:
        for uscope in getString(Cmd.OB_API_SCOPE_URL_LIST).lower().replace(',', ' ').split():
          if uscope in {'openid', 'email', API.USERINFO_EMAIL_SCOPE, 'profile', API.USERINFO_PROFILE_SCOPE}:
            continue
          for scope in scopesList:
            if ((uscope == scope['scope']) or
                (uscope.endswith('.action') and 'action' in scope['subscopes']) or
                (uscope.endswith('.readonly') and 'readonly' in scope['subscopes'])):
              scopes.append(uscope)
              break
          else:
            invalidChoiceExit(uscope, API.getClientScopesURLs(GC.Values[GC.TODRIVE_CLIENTACCESS]), True)
      else:
        unknownArgumentExit()
    if len(scopes) == 0:
      scopes = None
  doOAuthRequest(scopes, login_hint)

def exitIfNoOauth2Txt():
  if not os.path.isfile(GC.Values[GC.OAUTH2_TXT]):
    entityActionNotPerformedWarning([Ent.OAUTH2_TXT_FILE, GC.Values[GC.OAUTH2_TXT]], Msg.DOES_NOT_EXIST)
    sys.exit(GM.Globals[GM.SYSEXITRC])

# gam oauth|oauth2 delete|revoke
def doOAuthDelete():
  checkForExtraneousArguments()
  exitIfNoOauth2Txt()
  lock = FileLock(GM.Globals[GM.OAUTH2_TXT_LOCK], timeout=10)
  with lock:
    _, credentials = getOauth2TxtCredentials(noScopes=True)
    if not credentials:
      return
    entityType = Ent.OAUTH2_TXT_FILE
    entityName = GC.Values[GC.OAUTH2_TXT]
    sys.stdout.write(f'{Ent.Singular(entityType)}: {entityName}, will be Deleted in 3...')
    sys.stdout.flush()
    time.sleep(1)
    sys.stdout.write('2...')
    sys.stdout.flush()
    time.sleep(1)
    sys.stdout.write('1...')
    sys.stdout.flush()
    time.sleep(1)
    sys.stdout.write('boom!\n')
    sys.stdout.flush()
    httpObj = getHttpObj()
    params = {'token': credentials.refresh_token}
    revoke_uri = f'https://accounts.google.com/o/oauth2/revoke?{urlencode(params)}'
    httpObj.request(revoke_uri, 'GET')
    deleteFile(GC.Values[GC.OAUTH2_TXT], continueOnError=True)
    entityActionPerformed([entityType, entityName])

# gam oauth|oauth2 info|verify [showsecret] [accesstoken <AccessToken> idtoken <IDToken>] [showdetails]
def doOAuthInfo():
  credentials = access_token = id_token = None
  showDetails = showSecret = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'accesstoken':
      access_token = getString(Cmd.OB_ACCESS_TOKEN)
    elif myarg == 'idtoken':
      id_token = getString(Cmd.OB_ID_TOKEN)
    elif myarg == 'showdetails':
      showDetails = True
    elif myarg == 'showsecret':
      showSecret = True
    else:
      unknownArgumentExit()
  exitIfNoOauth2Txt()
  if not access_token and not id_token:
    credentials = getClientCredentials(noScopes=True)
    access_token = credentials.token
    printEntity([Ent.OAUTH2_TXT_FILE, GC.Values[GC.OAUTH2_TXT]])
  oa2 = buildGAPIObject(API.OAUTH2)
  try:
    token_info = callGAPI(oa2, 'tokeninfo',
                          throwReasons=[GAPI.INVALID],
                          access_token=access_token, id_token=id_token)
  except GAPI.invalid as e:
    entityActionFailedExit([Ent.OAUTH2_TXT_FILE, GC.Values[GC.OAUTH2_TXT]], str(e))
  if 'issued_to' in token_info:
    printKeyValueList(['Client ID', token_info['issued_to']])
  if credentials is not None and showSecret:
    printKeyValueList(['Secret', credentials.client_secret])
  if 'scope' in token_info:
    scopes = token_info['scope'].split(' ')
    printKeyValueList(['Scopes', len(scopes)])
    Ind.Increment()
    for scope in sorted(scopes):
      printKeyValueList([scope])
    Ind.Decrement()
  if 'email' in token_info:
    printKeyValueList(['Google Workspace Admin', f'{token_info["email"]}'])
  if 'expires_in' in token_info:
    printKeyValueList(['Expires', ISOformatTimeStamp((datetime.datetime.now()+datetime.timedelta(seconds=token_info['expires_in'])).replace(tzinfo=GC.Values[GC.TIMEZONE]))])
  if showDetails:
    for k, v in sorted(iter(token_info.items())):
      if k not in  ['email', 'expires_in', 'issued_to', 'scope']:
        printKeyValueList([k, v])
  printBlankLine()

# gam oauth|oauth2 update [<EmailAddress>]
# gam oauth|oauth2 update [admin <EmailAddress>]
def doOAuthUpdate():
  if Cmd.PeekArgumentPresent(['admin']):
    Cmd.Advance()
    login_hint = getEmailAddress(noUid=True)
  else:
    login_hint = getEmailAddress(noUid=True, optional=True)
  checkForExtraneousArguments()
  exitIfNoOauth2Txt()
  lock = FileLock(GM.Globals[GM.OAUTH2_TXT_LOCK])
  with lock:
    jsonData = readFile(GC.Values[GC.OAUTH2_TXT], continueOnError=True, displayError=False)
  if not jsonData:
    invalidOauth2TxtExit(Msg.NO_DATA)
  try:
    jsonDict = json.loads(jsonData)
    if 'client_id' in jsonDict:
      if 'scopes' in jsonDict:
        currentScopes = jsonDict['scopes']
      else:
        currentScopes = API.getClientScopesURLs(GC.Values[GC.TODRIVE_CLIENTACCESS])
    else:
      currentScopes = []
  except (AttributeError, IndexError, KeyError, SyntaxError, TypeError, ValueError) as e:
    invalidOauth2TxtExit(str(e))
  if not doOAuthRequest(currentScopes, login_hint, verifyScopes=True):
    entityActionNotPerformedWarning([Ent.OAUTH2_TXT_FILE, GC.Values[GC.OAUTH2_TXT]], Msg.USER_CANCELLED)
    sys.exit(GM.Globals[GM.SYSEXITRC])

# gam oauth|oauth2 refresh
def doOAuthRefresh():
  checkForExtraneousArguments()
  exitIfNoOauth2Txt()
  getClientCredentials(forceRefresh=True, forceWrite=True, filename=GC.Values[GC.OAUTH2_TXT], refreshOnly=True)
  entityActionPerformed([Ent.OAUTH2_TXT_FILE, GC.Values[GC.OAUTH2_TXT]])

# gam oauth|oauth2 export [<FileName>]
def doOAuthExport():
  if Cmd.ArgumentsRemaining():
    filename = getString(Cmd.OB_FILE_NAME)
    checkForExtraneousArguments()
  else:
    filename = GC.Values[GC.OAUTH2_TXT]
  getClientCredentials(forceRefresh=True, forceWrite=True, filename=filename, refreshOnly=True)
  if filename != '-':
    entityModifierNewValueActionPerformed([Ent.OAUTH2_TXT_FILE, GC.Values[GC.OAUTH2_TXT]], Act.MODIFIER_TO, filename)

def getCRMService(login_hint):
  scopes = [API.CLOUD_PLATFORM_SCOPE]
  client_id = GAM_PROJECT_CREATION_CLIENT_ID
  client_secret = 'qM3dP8f_4qedwzWQE1VR4zzU'
  credentials = Credentials.from_client_secrets(
    client_id,
    client_secret,
    scopes=scopes,
    access_type='online',
    login_hint=login_hint,
    open_browser=not GC.Values[GC.NO_BROWSER])
  httpObj = transportAuthorizedHttp(credentials, http=getHttpObj())
  return (httpObj, getAPIService(API.CLOUDRESOURCEMANAGER, httpObj))

def enableGAMProjectAPIs(httpObj, projectId, login_hint, checkEnabled, i=0, count=0):
  apis = API.PROJECT_APIS[:]
  projectName = f'projects/{projectId}'
  serveu = getAPIService(API.SERVICEUSAGE, httpObj)
  status = True
  if checkEnabled:
    try:
      services = callGAPIpages(serveu.services(), 'list', 'services',
                               throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED],
                               parent=projectName, filter='state:ENABLED',
                               fields='nextPageToken,services(name)')
      Act.Set(Act.CHECK)
      jcount = len(services)
      entityPerformActionNumItems([Ent.PROJECT, projectId], jcount, Ent.API, i, count)
      Ind.Increment()
      j = 0
      for service in sorted(services, key=lambda k: k['name']):
        j += 1
        if 'name' in service:
          serviceName = service['name'].split('/')[-1]
          if serviceName in apis:
            printEntityKVList([Ent.API, serviceName], ['Already enabled'], j, jcount)
            apis.remove(serviceName)
          else:
            printEntityKVList([Ent.API, serviceName], ['Already enabled (non-GAM which is fine)'], j, jcount)
      Ind.Decrement()
    except (GAPI.notFound, GAPI.permissionDenied) as e:
      entityActionFailedWarning([Ent.PROJECT, projectId], str(e), i, count)
      status = False
  jcount = len(apis)
  if status and jcount > 0:
    Act.Set(Act.ENABLE)
    entityPerformActionNumItems([Ent.PROJECT, projectId], jcount, Ent.API, i, count)
    failed = 0
    Ind.Increment()
    j = 0
    for api in apis:
      j += 1
      serviceName = f'projects/{projectId}/services/{api}'
      while True:
        try:
          callGAPI(serveu.services(), 'enable',
                   throwReasons=[GAPI.FAILED_PRECONDITION, GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED, GAPI.INTERNAL_ERROR],
                   retryReasons=[GAPI.INTERNAL_ERROR],
                   name=serviceName)
          entityActionPerformed([Ent.API, api], j, jcount)
          break
        except GAPI.failedPrecondition as e:
          entityActionFailedWarning([Ent.API, api], str(e), j, jcount)
          readStdin(Msg.ACCEPT_CLOUD_TOS.format(login_hint))
        except (GAPI.forbidden, GAPI.permissionDenied, GAPI.internalError) as e:
          entityActionFailedWarning([Ent.API, api], str(e), j, jcount)
          failed += 1
          break
    Ind.Decrement()
    if not checkEnabled:
      status = failed <= 2
    else:
      status = failed == 0
  return status

# gam enable apis [auto|manual]
def doEnableAPIs():
  automatic = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'auto':
      automatic = True
    elif myarg == 'manual':
      automatic = False
    else:
      unknownArgumentExit()
  request = getTLSv1_2Request()
  try:
    _, projectId = google.auth.default(scopes=[API.IAM_SCOPE], request=request)
  except (google.auth.exceptions.DefaultCredentialsError, google.auth.exceptions.RefreshError):
    projectId = readStdin(Msg.WHAT_IS_YOUR_PROJECT_ID).strip()
  while automatic is None:
    a_or_m = readStdin(Msg.ENABLE_PROJECT_APIS_AUTOMATICALLY_OR_MANUALLY).strip().lower()
    if a_or_m.startswith('a'):
      automatic = True
      break
    if a_or_m.startswith('m'):
      automatic = False
      break
    writeStdout(Msg.PLEASE_ENTER_A_OR_M)
  if automatic:
    login_hint = _getValidateLoginHint(None)
    httpObj, _ = getCRMService(login_hint)
    enableGAMProjectAPIs(httpObj, projectId, login_hint, True)
  else:
    apis = API.PROJECT_APIS[:]
    chunk_size = 20
    writeStdout('Using an account with project access, please use ALL of these URLs to enable 20 APIs at a time:\n\n')
    for chunk in range(0, len(apis), chunk_size):
      apiid = ",".join(apis[chunk:chunk+chunk_size])
      url = f'https://console.cloud.google.com/apis/enableflow?apiid={apiid}&project={projectId}'
      writeStdout(f'    {url}\n\n')

def _waitForSvcAcctCompletion(i):
  sleep_time = i*5
  if i > 3:
    sys.stdout.write(Msg.WAITING_FOR_ITEM_CREATION_TO_COMPLETE_SLEEPING.format(Ent.Singular(Ent.SVCACCT), sleep_time))
  time.sleep(sleep_time)

def _grantRotateRights(iam, projectId, service_account, account_type='serviceAccount'):
  body = {'policy': {'bindings': [{'role': 'roles/iam.serviceAccountKeyAdmin',
                                   'members': [f'{account_type}:{service_account}']}]}}
  maxRetries = 10
  kvList = [Ent.PROJECT, projectId, Ent.SVCACCT, service_account]
  printEntityMessage(kvList, Msg.GRANTING_RIGHTS_TO_ROTATE_ITS_OWN_PRIVATE_KEY.format('Granting'))
  for retry in range(1, maxRetries+1):
    try:
      callGAPI(iam.projects().serviceAccounts(), 'setIamPolicy',
               throwReasons=[GAPI.INVALID_ARGUMENT],
               resource=f'projects/{projectId}/serviceAccounts/{service_account}', body=body)
      printEntityMessage(kvList, Msg.GRANTING_RIGHTS_TO_ROTATE_ITS_OWN_PRIVATE_KEY.format('Granted'))
      return True
    except GAPI.invalidArgument as e:
      entityActionFailedWarning(kvList, str(e))
      if 'does not exist' not in str(e) or retry == maxRetries:
        return False
      _waitForSvcAcctCompletion(retry)
    except Exception as e:
      entityActionFailedWarning(kvList, str(e))
      return False

def _createOauth2serviceJSON(httpObj, projectInfo, svcAcctInfo, create_key=True):
  iam = getAPIService(API.IAM, httpObj)
  try:
    service_account = callGAPI(iam.projects().serviceAccounts(), 'create',
                               throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.ALREADY_EXISTS],
                               name=f'projects/{projectInfo["projectId"]}',
                               body={'accountId': svcAcctInfo['name'],
                                     'serviceAccount': {'displayName': svcAcctInfo['displayName'],
                                                        'description': svcAcctInfo['description']}})
    entityActionPerformed([Ent.PROJECT, projectInfo['projectId'], Ent.SVCACCT, service_account['name'].rsplit('/', 1)[-1]])
  except (GAPI.notFound, GAPI.permissionDenied) as e:
    entityActionFailedWarning([Ent.PROJECT, projectInfo['projectId']], str(e))
    return False
  except GAPI.alreadyExists as e:
    entityActionFailedWarning([Ent.PROJECT, projectInfo['projectId'], Ent.SVCACCT, svcAcctInfo['name']], str(e))
    writeStderr(Msg.RERUN_THE_COMMAND_AND_SPECIFY_A_NEW_SANAME)
    return False
  GM.Globals[GM.SVCACCT_SCOPES_DEFINED] = False
  if create_key and not doProcessSvcAcctKeys(mode='retainexisting', iam=iam,
                                             projectId=service_account['projectId'],
                                             clientEmail=service_account['email'],
                                             clientId=service_account['uniqueId']):
    return False
  sa_email = service_account['name'].rsplit('/', 1)[-1]
  return _grantRotateRights(iam, projectInfo['projectId'], sa_email)

def _createClientSecretsOauth2service(httpObj, login_hint, appInfo, projectInfo, svcAcctInfo, create_key=True):
  def _checkClientAndSecret(csHttpObj, client_id, client_secret):
    post_data = {'client_id': client_id, 'client_secret': client_secret,
                 'code': 'ThisIsAnInvalidCodeOnlyBeingUsedToTestIfClientAndSecretAreValid',
                 'redirect_uri': 'http://127.0.0.1:8080', 'grant_type': 'authorization_code'}
    _, content = csHttpObj.request(API.GOOGLE_OAUTH2_TOKEN_ENDPOINT, 'POST', urlencode(post_data),
                                   headers={'Content-type': 'application/x-www-form-urlencoded'})
    try:
      content = json.loads(content)
    except (IndexError, KeyError, SyntaxError, TypeError, ValueError) as e:
      sys.stderr.write(f'{str(e)}: {content}')
      return False
    if not 'error' in content or not 'error_description' in content:
      sys.stderr.write(f'Unknown error: {content}\n')
      return False
    if content['error'] == 'invalid_grant':
      return True
    if content['error_description'] == 'The OAuth client was not found.':
      sys.stderr.write(Msg.IS_NOT_A_VALID_CLIENT_ID.format(client_id))
      return False
    if content['error_description'] == 'Unauthorized':
      sys.stderr.write(Msg.IS_NOT_A_VALID_CLIENT_SECRET.format(client_secret))
      return False
    sys.stderr.write(f'Unknown error: {content}\n')
    return False

  if not enableGAMProjectAPIs(httpObj, projectInfo['projectId'], login_hint, False):
    return
  sys.stdout.write(Msg.SETTING_GAM_PROJECT_CONSENT_SCREEN_CREATING_CLIENT)
  console_url = f'https://console.cloud.google.com/auth/clients?project={projectInfo["projectId"]}&authuser={login_hint}'
  csHttpObj = getHttpObj()
  while True:
    sys.stdout.write(Msg.CREATE_CLIENT_INSTRUCTIONS.format(console_url, appInfo['applicationTitle'], appInfo['supportEmail']))
    client_id = readStdin(Msg.ENTER_YOUR_CLIENT_ID).strip()
    if not client_id:
      client_id = readStdin('').strip()
    client_secret = readStdin(Msg.ENTER_YOUR_CLIENT_SECRET).strip()
    if not client_secret:
      client_secret = readStdin('').strip()
    client_valid = _checkClientAndSecret(csHttpObj, client_id, client_secret)
    if client_valid:
      break
    sys.stdout.write('\n')
  cs_data = f'''{{
    "installed": {{
        "auth_provider_x509_cert_url": "{API.GOOGLE_AUTH_PROVIDER_X509_CERT_URL}",
        "auth_uri": "{API.GOOGLE_OAUTH2_ENDPOINT}",
        "client_id": "{client_id}",
        "client_secret": "{client_secret}",
        "created_by": "{login_hint}",
        "project_id": "{projectInfo['projectId']}",
        "token_uri": "{API.GOOGLE_OAUTH2_TOKEN_ENDPOINT}"
    }}
}}'''
  writeFile(GC.Values[GC.CLIENT_SECRETS_JSON], cs_data, continueOnError=False)
  sys.stdout.write(Msg.TRUST_GAM_CLIENT_ID.format(GAM, client_id))
  readStdin('')
  if not _createOauth2serviceJSON(httpObj, projectInfo, svcAcctInfo, create_key):
    return
  sys.stdout.write(Msg.YOUR_GAM_PROJECT_IS_CREATED_AND_READY_TO_USE)

def _getProjects(crm, pfilter, returnNF=False):
  try:
    projects = callGAPIpages(crm.projects(), 'search', 'projects',
                             throwReasons=[GAPI.BAD_REQUEST, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                             query=pfilter)
    if projects:
      return projects
    if (not pfilter) or pfilter == GAM_PROJECT_FILTER:
      return []
    if pfilter.startswith('id:'):
      projects = [callGAPI(crm.projects(), 'get',
                           throwReasons=[GAPI.BAD_REQUEST, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                           name=f'projects/{pfilter[3:]}')]
      if projects or not returnNF:
        return projects
    return []
  except (GAPI.badRequest, GAPI.invalidArgument) as e:
    entityActionFailedExit([Ent.PROJECT, pfilter], str(e))
  except GAPI.permissionDenied:
    if (not pfilter) or (not pfilter.startswith('id:')) or (not returnNF):
      return []
  return [{'projectId': pfilter[3:], 'state': 'NF'}]

def _checkProjectFound(project, i, count):
  if project.get('state', '') != 'NF':
    return True
  entityActionFailedWarning([Ent.PROJECT, project['projectId']], Msg.DOES_NOT_EXIST, i, count)
  return False

def convertGCPFolderNameToID(parent, crm):
  folders = callGAPIpages(crm.folders(), 'search', 'folders',
                          query=f'displayName="{parent}"')
  if not folders:
    entityActionFailedExit([Ent.PROJECT_FOLDER, parent], Msg.NOT_FOUND)
  jcount = len(folders)
  if jcount > 1:
    entityActionNotPerformedWarning([Ent.PROJECT_FOLDER, parent],
                                    Msg.PLEASE_SELECT_ENTITY_TO_PROCESS.format(jcount, Ent.Plural(Ent.PROJECT_FOLDER), 'use in create', 'parent <String>'))
    Ind.Increment()
    j = 0
    for folder in folders:
      j += 1
      printKeyValueListWithCount(['Name', folder['name'], 'ID', folder['displayName']], j, jcount)
    Ind.Decrement()
    systemErrorExit(MULTIPLE_PROJECT_FOLDERS_FOUND_RC, None)
  return folders[0]['name']

PROJECTID_PATTERN = re.compile(r'^[a-z][a-z0-9-]{4,28}[a-z0-9]$')
PROJECTID_FORMAT_REQUIRED = '[a-z][a-z0-9-]{4,28}[a-z0-9]'
def _checkProjectId(projectId):
  if not PROJECTID_PATTERN.match(projectId):
    Cmd.Backup()
    invalidArgumentExit(PROJECTID_FORMAT_REQUIRED)

PROJECTNAME_PATTERN = re.compile('^[a-zA-Z0-9 '+"'"+'"!-]{4,30}$')
PROJECTNAME_FORMAT_REQUIRED = '[a-zA-Z0-9 \'"!-]{4,30}'
def _checkProjectName(projectName):
  if not PROJECTNAME_PATTERN.match(projectName):
    Cmd.Backup()
    invalidArgumentExit(PROJECTNAME_FORMAT_REQUIRED)

def _getSvcAcctInfo(myarg, svcAcctInfo):
  if myarg == 'saname':
    svcAcctInfo['name'] = getString(Cmd.OB_STRING, minLen=6, maxLen=30)
    _checkProjectId(svcAcctInfo['name'])
  elif myarg == 'sadisplayname':
    svcAcctInfo['displayName'] = getString(Cmd.OB_STRING, maxLen=100)
  elif myarg == 'sadescription':
    svcAcctInfo['description'] = getString(Cmd.OB_STRING, maxLen=256)
  else:
    return False
  return True

def _getAppInfo(myarg, appInfo):
  if myarg == 'appname':
    appInfo['applicationTitle'] = getString(Cmd.OB_STRING)
  elif myarg == 'supportemail':
    appInfo['supportEmail'] = getEmailAddress(noUid=True)
  else:
    return False
  return True

def _generateProjectSvcAcctId(prefix):
  return f'{prefix}-{"".join(random.choice(LOWERNUMERIC_CHARS) for _ in range(5))}'

def _getLoginHintProjectInfo(createCmd):
  login_hint = None
  create_key = True
  appInfo = {'applicationTitle': '', 'supportEmail': ''}
  projectInfo = {'projectId': '', 'parent': '', 'name': ''}
  svcAcctInfo = {'name': '', 'displayName': '', 'description': ''}
  if not Cmd.PeekArgumentPresent(['admin', 'appname', 'supportemail', 'project', 'parent',
                                  'projectname', 'saname', 'sadisplayname', 'sadescription',
                                  'algorithm', 'localkeysize', 'validityhours', 'yubikey', 'nokey']):
    login_hint = getString(Cmd.OB_EMAIL_ADDRESS, optional=True)
    if login_hint and login_hint.find('@') == -1:
      Cmd.Backup()
      login_hint = None
    projectInfo['projectId'] = getString(Cmd.OB_STRING, optional=True, minLen=6, maxLen=30).strip()
    if projectInfo['projectId']:
      _checkProjectId(projectInfo['projectId'])
    checkForExtraneousArguments()
  else:
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg == 'admin':
        login_hint = getEmailAddress(noUid=True)
      elif myarg == 'nokey':
        create_key = False
      elif myarg == 'project':
        projectInfo['projectId'] = getString(Cmd.OB_STRING, minLen=6, maxLen=30)
        _checkProjectId(projectInfo['projectId'])
      elif createCmd and myarg == 'parent':
        projectInfo['parent'] = getString(Cmd.OB_STRING)
      elif myarg == 'projectname':
        projectInfo['name'] = getString(Cmd.OB_STRING, minLen=4, maxLen=30)
        _checkProjectName(projectInfo['name'])
      elif _getSvcAcctInfo(myarg, svcAcctInfo):
        pass
      elif _getAppInfo(myarg, appInfo):
        pass
      elif myarg in {'algorithm', 'localkeysize', 'validityhours', 'yubikey'}:
        Cmd.Backup()
        break
      else:
        unknownArgumentExit()
  if not projectInfo['projectId']:
    if createCmd:
      projectInfo['projectId'] = _generateProjectSvcAcctId('gam-project')
    else:
      projectInfo['projectId'] = readStdin(Msg.WHAT_IS_YOUR_PROJECT_ID).strip()
      if not PROJECTID_PATTERN.match(projectInfo['projectId']):
        systemErrorExit(USAGE_ERROR_RC, f'{Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_INVALID][1]} {Cmd.OB_PROJECT_ID}: {Msg.EXPECTED} <{PROJECTID_FORMAT_REQUIRED}>')
  if not projectInfo['name']:
    projectInfo['name'] = 'GAM Project' if not GC.Values[GC.USE_PROJECTID_AS_NAME] else projectInfo['projectId']
  if not svcAcctInfo['name']:
    svcAcctInfo['name'] = projectInfo['projectId']
  if not svcAcctInfo['displayName']:
    svcAcctInfo['displayName'] = projectInfo['name']
  if not svcAcctInfo['description']:
    svcAcctInfo['description'] = svcAcctInfo['displayName']
  login_hint = _getValidateLoginHint(login_hint, projectInfo['projectId'])
  if not appInfo['applicationTitle']:
    appInfo['applicationTitle'] = 'GAM' if not GC.Values[GC.USE_PROJECTID_AS_NAME] else projectInfo['projectId']
  if not appInfo['supportEmail']:
    appInfo['supportEmail'] = login_hint
  httpObj, crm = getCRMService(login_hint)
  if projectInfo['parent'] and not projectInfo['parent'].startswith('organizations/') and not projectInfo['parent'].startswith('folders/'):
    projectInfo['parent'] = convertGCPFolderNameToID(projectInfo['parent'], crm)
  projects = _getProjects(crm, f'id:{projectInfo["projectId"]}')
  if not createCmd:
    if not projects:
      entityActionFailedExit([Ent.USER, login_hint, Ent.PROJECT, projectInfo['projectId']], Msg.DOES_NOT_EXIST)
    if projects[0]['state'] != 'ACTIVE':
      entityActionFailedExit([Ent.USER, login_hint, Ent.PROJECT, projectInfo['projectId']], Msg.NOT_ACTIVE)
  else:
    if projects:
      entityActionFailedExit([Ent.USER, login_hint, Ent.PROJECT, projectInfo['projectId']], Msg.DUPLICATE)
  return (crm, httpObj, login_hint, appInfo, projectInfo, svcAcctInfo, create_key)

def _getCurrentProjectId():
  jsonData = readFile(GC.Values[GC.OAUTH2SERVICE_JSON], continueOnError=True, displayError=False)
  if jsonData:
    try:
      return json.loads(jsonData)['project_id']
    except (IndexError, KeyError, SyntaxError, TypeError, ValueError):
      pass
  jsonData = readFile(GC.Values[GC.CLIENT_SECRETS_JSON], continueOnError=True, displayError=True)
  if not jsonData:
    invalidClientSecretsJsonExit(Msg.NO_DATA)
  try:
    return json.loads(jsonData)['installed']['project_id']
  except (IndexError, KeyError, SyntaxError, TypeError, ValueError) as e:
    invalidClientSecretsJsonExit(str(e))

GAM_PROJECT_FILTER = 'id:gam-project-*'
PROJECTID_FILTER_REQUIRED = '<ProjectIDEntity>'
PROJECTS_CREATESVCACCT_OPTIONS = {'saname', 'sadisplayname', 'sadescription'}
PROJECTS_DELETESVCACCT_OPTIONS = {'saemail', 'saname', 'sauniqueid'}
PROJECTS_PRINTSHOW_OPTIONS = {'showsakeys', 'showiampolicies', 'onememberperrow', 'states', 'todrive', 'delimiter', 'formatjson', 'quotechar'}

def _getLoginHintProjects(createSvcAcctCmd=False, deleteSvcAcctCmd=False, printShowCmd=False, readOnly=False):
  if checkArgumentPresent(['admin']):
    login_hint = getEmailAddress(noUid=True)
  else:
    login_hint = getString(Cmd.OB_EMAIL_ADDRESS, optional=True)
  if login_hint and login_hint.find('@') == -1:
    Cmd.Backup()
    login_hint = None
  if readOnly and login_hint and login_hint != _getAdminEmail():
    readOnly = False
  projectIds = None
  pfilter = getString(Cmd.OB_STRING, optional=True)
  if not pfilter:
    pfilter = 'current' if not printShowCmd else GAM_PROJECT_FILTER
  elif printShowCmd and pfilter in PROJECTS_PRINTSHOW_OPTIONS:
    pfilter = GAM_PROJECT_FILTER
    Cmd.Backup()
  elif createSvcAcctCmd and pfilter in PROJECTS_CREATESVCACCT_OPTIONS:
    pfilter = 'current'
    Cmd.Backup()
  elif deleteSvcAcctCmd and pfilter in PROJECTS_DELETESVCACCT_OPTIONS:
    pfilter = 'current'
    Cmd.Backup()
  elif printShowCmd and pfilter.lower() == 'all':
    pfilter = None
  elif pfilter.lower() == 'current':
    pfilter = 'current'
  elif pfilter.lower() == 'gam':
    pfilter = GAM_PROJECT_FILTER
  elif pfilter.lower() == 'filter':
    pfilter = getString(Cmd.OB_STRING)
  elif pfilter.lower() == 'select':
    projectIds = getEntityList(Cmd.OB_PROJECT_ID_ENTITY, False)
    projectId = None
  elif PROJECTID_PATTERN.match(pfilter):
    pfilter = f'id:{pfilter}'
  elif pfilter.startswith('id:') and PROJECTID_PATTERN.match(pfilter[3:]):
    pass
  else:
    Cmd.Backup()
    invalidArgumentExit(['', 'all|'][printShowCmd]+PROJECTID_FILTER_REQUIRED)
  if not printShowCmd and not createSvcAcctCmd and not deleteSvcAcctCmd:
    checkForExtraneousArguments()
  if projectIds is None:
    if pfilter in {'current', 'id:current'}:
      projectId = _getCurrentProjectId()
    else:
      projectId = f'filter {pfilter or "all"}'
  login_hint = _getValidateLoginHint(login_hint, projectId)
  crm = None
  if readOnly:
    _, crm = buildGAPIServiceObject(API.CLOUDRESOURCEMANAGER, None)
    if crm:
      httpObj = crm._http
  if not crm:
    httpObj, crm = getCRMService(login_hint)
  if projectIds is None:
    if pfilter in {'current', 'id:current'}:
      if not printShowCmd:
        projects = [{'projectId': projectId}]
      else:
        projects = _getProjects(crm, f'id:{projectId}', returnNF=True)
    else:
      projects = _getProjects(crm, pfilter, returnNF=printShowCmd)
  else:
    projects = []
    for projectId in projectIds:
      projects.extend(_getProjects(crm, f'id:{projectId}', returnNF=True))
  return (crm, httpObj, login_hint, projects)

def _checkForExistingProjectFiles(projectFiles):
  for a_file in projectFiles:
    if os.path.exists(a_file):
      systemErrorExit(JSON_ALREADY_EXISTS_RC, Msg.AUTHORIZATION_FILE_ALREADY_EXISTS.format(a_file, Act.ToPerform()))

def getGCPOrg(crm, login_hint, login_domain):
  try:
    getorg = callGAPI(crm.organizations(), 'search',
                      throwReasons=[GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                      query=f'domain:{login_domain}')
  except (GAPI.invalidArgument, GAPI.permissionDenied) as e:
    entityActionFailedExit([Ent.USER, login_hint, Ent.DOMAIN, login_domain], str(e))
  try:
    organization = getorg['organizations'][0]['name']
    sys.stdout.write(Msg.YOUR_ORGANIZATION_NAME_IS.format(organization))
    return organization
  except (KeyError, IndexError):
    systemErrorExit(3, Msg.YOU_HAVE_NO_RIGHTS_TO_CREATE_PROJECTS_AND_YOU_ARE_NOT_A_SUPER_ADMIN)

# gam create gcpfolder <String>
# gam create gcpfolder [admin <EmailAddress] folder <String>
def doCreateGCPFolder():
  login_hint = None
  if not Cmd.PeekArgumentPresent(['admin', 'folder']):
    name = getString(Cmd.OB_STRING)
    checkForExtraneousArguments()
  else:
    name = ''
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg == 'admin':
        login_hint = getEmailAddress(noUid=True)
      elif myarg == 'folder':
        name = getString(Cmd.OB_STRING)
      else:
        unknownArgumentExit()
    if not name:
      missingChoiceExit('folder')
  login_hint = _getValidateLoginHint(login_hint)
  login_domain = getEmailAddressDomain(login_hint)
  _, crm = getCRMService(login_hint)
  organization = getGCPOrg(crm, login_hint, login_domain)
  try:
    result = callGAPI(crm.folders(), 'create',
                      throwReasons=[GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                      body={'parent': organization, 'displayName': name})
  except (GAPI.invalidArgument, GAPI.permissionDenied) as e:
    entityActionFailedExit([Ent.USER, login_hint, Ent.GCP_FOLDER, name], str(e))
  entityActionPerformed([Ent.USER, login_hint, Ent.GCP_FOLDER, name, Ent.GCP_FOLDER_NAME, result['name']])

# gam create project [<EmailAddress>] [<ProjectID>]
# gam create project [admin <EmailAddress>] [project <ProjectID>]
#	[appname <String>] [supportemail <EmailAddress>]
#	[projectname <ProjectName>] [parent <String>]
#	[saname <ServiceAccountName>] [sadisplayname <ServiceAccountDisplayName>] [sadescription <ServiceAccountDescription>]
#	[(algorithm KEY_ALG_RSA_1024|KEY_ALG_RSA_2048)|
#	 (localkeysize 1024|2048|4096 [validityhours <Number>])|
#	 (yubikey yubikey_pin yubikey_slot AUTHENTICATION yubikey_serialnumber <String>)|
#	 nokey]
def doCreateProject():
  _checkForExistingProjectFiles([GC.Values[GC.OAUTH2SERVICE_JSON], GC.Values[GC.CLIENT_SECRETS_JSON]])
  sys.stdout.write(Msg.TRUST_GAM_CLIENT_ID.format(GAM_PROJECT_CREATION, GAM_PROJECT_CREATION_CLIENT_ID))
  readStdin('')
  crm, httpObj, login_hint, appInfo, projectInfo, svcAcctInfo, create_key = _getLoginHintProjectInfo(True)
  login_domain = getEmailAddressDomain(login_hint)
  body = {'projectId': projectInfo['projectId'], 'displayName': projectInfo['name']}
  if projectInfo['parent']:
    body['parent'] = projectInfo['parent']
  while True:
    create_again = False
    sys.stdout.write(Msg.CREATING_PROJECT.format(body['displayName']))
    try:
      create_operation = callGAPI(crm.projects(), 'create',
                                  throwReasons=[GAPI.BAD_REQUEST, GAPI.ALREADY_EXISTS,
                                                GAPI.FAILED_PRECONDITION, GAPI.PERMISSION_DENIED],
                                  body=body)
    except (GAPI.badRequest, GAPI.alreadyExists, GAPI.failedPrecondition, GAPI.permissionDenied) as e:
      entityActionFailedExit([Ent.USER, login_hint, Ent.PROJECT, projectInfo['projectId']], str(e))
    operation_name = create_operation['name']
    time.sleep(5) # Google recommends always waiting at least 5 seconds
    for i in range(1, 10):
      sys.stdout.write(Msg.CHECKING_PROJECT_CREATION_STATUS)
      status = callGAPI(crm.operations(), 'get',
                        name=operation_name)
      if 'error' in status:
        if status['error'].get('message', '') == 'No permission to create project in organization':
          sys.stdout.write(Msg.NO_RIGHTS_GOOGLE_CLOUD_ORGANIZATION)
          organization = getGCPOrg(crm, login_hint, login_domain)
          org_policy = callGAPI(crm.organizations(), 'getIamPolicy',
                                resource=organization)
          if 'bindings' not in org_policy:
            org_policy['bindings'] = []
            sys.stdout.write(Msg.LOOKS_LIKE_NO_ONE_HAS_RIGHTS_TO_YOUR_GOOGLE_CLOUD_ORGANIZATION_ATTEMPTING_TO_GIVE_YOU_CREATE_RIGHTS)
          else:
            sys.stdout.write(Msg.THE_FOLLOWING_RIGHTS_SEEM_TO_EXIST)
            for a_policy in org_policy['bindings']:
              if 'role' in a_policy:
                sys.stdout.write(f'  Role: {a_policy["role"]}\n')
              if 'members' in a_policy:
                sys.stdout.write('  Members:\n')
                for member in a_policy['members']:
                  sys.stdout.write(f'    {member}\n')
          my_role = 'roles/resourcemanager.projectCreator'
          sys.stdout.write(Msg.GIVING_LOGIN_HINT_THE_CREATOR_ROLE.format(login_hint, my_role))
          org_policy['bindings'].append({'role': my_role, 'members': [f'user:{login_hint}']})
          callGAPI(crm.organizations(), 'setIamPolicy',
                   resource=organization, body={'policy': org_policy})
          create_again = True
          break
        try:
          if status['error']['details'][0]['violations'][0]['description'] == 'Callers must accept Terms of Service':
            readStdin(Msg.ACCEPT_CLOUD_TOS.format(login_hint))
            create_again = True
            break
        except (IndexError, KeyError):
          pass
        systemErrorExit(1, str(status)+'\n')
      if status.get('done', False):
        break
      sleep_time = min(2 ** i, 60)
      sys.stdout.write(Msg.PROJECT_STILL_BEING_CREATED_SLEEPING.format(sleep_time))
      time.sleep(sleep_time)
    if create_again:
      continue
    if not status.get('done', False):
      systemErrorExit(1, Msg.FAILED_TO_CREATE_PROJECT.format(status))
    elif 'error' in status:
      systemErrorExit(2, status['error']+'\n')
    break
# Try to set policy on project to allow Service Account Key Upload
#  orgp = getAPIService(API.ORGPOLICY, httpObj)
#  projectParent = f"projects/{projectInfo['projectId']}"
#  policyName = f'{projectParent}/policies/iam.managed.disableServiceAccountKeyUpload'
#  try:
#    result = callGAPI(orgp.projects().policies(), 'get',
#                      throwReasons=[GAPI.NOT_FOUND, GAPI.FAILED_PRECONDITION, GAPI.PERMISSION_DENIED],
#                      name=policyName)
#    if result['spec']['rules'][0]['enforce']:
#      callGAPI(orgp.projects().policies(), 'patch',
#               throwReasons=[GAPI.FAILED_PRECONDITION, GAPI.PERMISSION_DENIED],
#               name=policyName, body={'spec': {'rules': [{'enforce': False}]}}, updateMask='policy.spec')
#  except GAPI.notFound:
#    callGAPI(orgp.projects().policies(), 'create',
#             throwReasons=[GAPI.BAD_REQUEST, GAPI.FAILED_PRECONDITION, GAPI.PERMISSION_DENIED],
#             parent=projectParent, body={'name': policyName, 'spec': {'rules': [{'enforce': False}]}})
#  except (GAPI.badRequest, GAPI.failedPrecondition, GAPI.permissionDenied):
#    pass
# Create client_secrets.json and oauth2service.json
  _createClientSecretsOauth2service(httpObj, login_hint, appInfo, projectInfo, svcAcctInfo, create_key)

# gam use project [<EmailAddress>] [<ProjectID>]
# gam use project [admin <EmailAddress>] [project <ProjectID>]
#	[appname <String>] [supportemail <EmailAddress>]
#	[saname <ServiceAccountName>] [sadisplayname <ServiceAccountDisplayName>] [sadescription <ServiceAccountDescription>]
#	[(algorithm KEY_ALG_RSA_1024|KEY_ALG_RSA_2048)|
#	 (localkeysize 1024|2048|4096 [validityhours <Number>])|
#	 (yubikey yubikey_pin yubikey_slot AUTHENTICATION yubikey_serialnumber <String>)]
def doUseProject():
  _checkForExistingProjectFiles([GC.Values[GC.OAUTH2SERVICE_JSON], GC.Values[GC.CLIENT_SECRETS_JSON]])
  _, httpObj, login_hint, appInfo, projectInfo, svcAcctInfo, create_key = _getLoginHintProjectInfo(False)
  _createClientSecretsOauth2service(httpObj, login_hint, appInfo, projectInfo, svcAcctInfo, create_key)

# gam update project [[admin] <EmailAddress>] [<ProjectIDEntity>]
def doUpdateProject():
  _, httpObj, login_hint, projects = _getLoginHintProjects()
  count = len(projects)
  entityPerformActionNumItems([Ent.USER, login_hint], count, Ent.PROJECT)
  Ind.Increment()
  i = 0
  for project in projects:
    i += 1
    if not _checkProjectFound(project, i, count):
      continue
    projectId = project['projectId']
    Act.Set(Act.UPDATE)
    if not enableGAMProjectAPIs(httpObj, projectId, login_hint, True, i, count):
      continue
    iam = getAPIService(API.IAM, httpObj)
    _getSvcAcctData() # needed to read in GM.OAUTH2SERVICE_JSON_DATA
    _grantRotateRights(iam, projectId, GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['client_email'])
  Ind.Decrement()

# gam delete project [[admin] <EmailAddress>] [<ProjectIDEntity>]
def doDeleteProject():
  crm, _, login_hint, projects = _getLoginHintProjects()
  count = len(projects)
  entityPerformActionNumItems([Ent.USER, login_hint], count, Ent.PROJECT)
  Ind.Increment()
  i = 0
  for project in projects:
    i += 1
    if not _checkProjectFound(project, i, count):
      continue
    projectId = project['projectId']
    try:
      callGAPI(crm.projects(), 'delete',
               throwReasons=[GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED],
               name=project['name'])
      entityActionPerformed([Ent.PROJECT, projectId])
    except (GAPI.forbidden, GAPI.permissionDenied) as e:
      entityActionFailedWarning([Ent.PROJECT, projectId], str(e))
  Ind.Decrement()

PROJECT_TIMEOBJECTS = ['createTime']
PROJECT_STATE_CHOICE_MAP = {
  'all': {'ACTIVE', 'DELETE_REQUESTED'},
  'active': {'ACTIVE'},
  'deleterequested': {'DELETE_REQUESTED'}
  }

# gam print projects [[admin] <EmailAddress>] [all|<ProjectIDEntity>] [todrive <ToDriveAttribute>*]
#	[states all|active|deleterequested] [showiampolicies 0|1|3 [onememberperrow]]
#	[delimiter <Character>] [formatjson [quotechar <Character>]]
# gam show projects [[admin] <EmailAddress>] [all|<ProjectIDEntity>]
#	[states all|active|deleterequested] [showiampolicies 0|1|3]
def doPrintShowProjects():
  def _getProjectPolicies(crm, project, policyBody, i, count):
    try:
      policy = callGAPI(crm.projects(), 'getIamPolicy',
                        throwReasons=[GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED],
                        resource=project['name'], body=policyBody)
      return policy
    except (GAPI.forbidden, GAPI.permissionDenied) as e:
      entityActionFailedWarning([Ent.PROJECT, project['projectId'], Ent.IAM_POLICY, None], str(e), i, count)
    return {}

  readOnly = not Cmd.ArgumentIsAhead('showiampolicies')
  crm, _, login_hint, projects = _getLoginHintProjects(printShowCmd=True, readOnly=readOnly)
  csvPF = CSVPrintFile(['User', 'projectId']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  oneMemberPerRow = False
  showIAMPolicies = -1
  lifecycleStates = PROJECT_STATE_CHOICE_MAP['active']
  policy = None
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif csvPF and myarg == 'onememberperrow':
      oneMemberPerRow = True
    elif myarg == 'states':
      lifecycleStates = getChoice(PROJECT_STATE_CHOICE_MAP, mapChoice=True)
    elif myarg == 'showiampolicies':
      showIAMPolicies = int(getChoice(['0', '1', '3']))
      policyBody = {'options': {"requestedPolicyVersion": showIAMPolicies}}
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if not csvPF:
    count = len(projects)
    entityPerformActionNumItems([Ent.USER, login_hint], count, Ent.PROJECT)
    Ind.Increment()
    i = 0
    for project in projects:
      i += 1
      if not _checkProjectFound(project, i, count):
        continue
      if project['state'] not in lifecycleStates:
        continue
      projectId = project['projectId']
      if showIAMPolicies >= 0:
        policy = _getProjectPolicies(crm, project, policyBody, i, count)
      printEntity([Ent.PROJECT, projectId], i, count)
      Ind.Increment()
      printKeyValueList(['name', project['name']])
      printKeyValueList(['displayName', project['displayName']])
      for field in ['createTime', 'updateTime', 'deleteTime']:
        if field in project:
          printKeyValueList([field, formatLocalTime(project[field])])
      printKeyValueList(['state', project['state']])
      jcount = len(project.get('labels', []))
      if jcount > 0:
        printKeyValueList(['labels', jcount])
        Ind.Increment()
        for k, v in iter(project['labels'].items()):
          printKeyValueList([k, v])
        Ind.Decrement()
      if 'parent' in project:
        printKeyValueList(['parent', project['parent']])
      if policy:
        printKeyValueList([Ent.Singular(Ent.IAM_POLICY), ''])
        Ind.Increment()
        bindings = policy.get('bindings', [])
        jcount = len(bindings)
        printKeyValueList(['version', policy['version']])
        printKeyValueList(['bindings', jcount])
        Ind.Increment()
        j = 0
        for binding in bindings:
          j += 1
          printKeyValueListWithCount(['role', binding['role']], j, jcount)
          Ind.Increment()
          for member in binding.get('members', []):
            printKeyValueList(['member', member])
          if 'condition' in binding:
            printKeyValueList(['condition', ''])
            Ind.Increment()
            for k, v in iter(binding['condition'].items()):
              printKeyValueList([k, v])
            Ind.Decrement()
          Ind.Decrement()
        Ind.Decrement()
        Ind.Decrement()
      Ind.Decrement()
    Ind.Decrement()
  else:
    if not FJQC.formatJSON:
      csvPF.AddTitles(['projectId', 'name', 'displayName', 'createTime', 'updateTime', 'deleteTime', 'state'])
      csvPF.SetSortAllTitles()
    count = len(projects)
    i = 0
    for project in projects:
      i += 1
      if not _checkProjectFound(project, i, count):
        continue
      if project['state'] not in lifecycleStates:
        continue
      projectId = project['projectId']
      if showIAMPolicies >= 0:
        policy = _getProjectPolicies(crm, project, policyBody, i, count)
      if FJQC.formatJSON:
        if policy is not None:
          project['policy'] = policy
        row = flattenJSON(project, flattened={'User': login_hint}, timeObjects=PROJECT_TIMEOBJECTS)
        if csvPF.CheckRowTitles(row):
          csvPF.WriteRowNoFilter({'User': login_hint, 'projectId': projectId,
                                  'JSON': json.dumps(cleanJSON(project),
                                                     ensure_ascii=False, sort_keys=True)})
        continue
      row = flattenJSON(project, flattened={'User': login_hint}, timeObjects=PROJECT_TIMEOBJECTS)
      if not policy:
        csvPF.WriteRowTitles(row)
        continue
      row[f'policy{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}version'] = policy['version']
      for binding in policy.get('bindings', []):
        prow = row.copy()
        prow[f'policy{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}role'] = binding['role']
        if 'condition' in binding:
          for k, v in iter(binding['condition'].items()):
            prow[f'policy{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}condition{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{k}'] = v
        members = binding.get('members', [])
        if not oneMemberPerRow:
          prow[f'policy{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}members'] = delimiter.join(members)
          csvPF.WriteRowTitles(prow)
        else:
          for member in members:
            mrow = prow.copy()
            mrow[f'policy{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}member'] = member
            csvPF.WriteRowTitles(mrow)
    csvPF.writeCSVfile('Projects')

# gam info currentprojectid
def doInfoCurrentProjectId():
  checkForExtraneousArguments()
  printEntity([Ent.PROJECT_ID, _getCurrentProjectId()])

# gam create svcacct [[admin] <EmailAddress>] [<ProjectIDEntity>]
#	[saname <ServiceAccountName>] [sadisplayname <ServiceAccountDisplayName>] [sadescription <ServiceAccountDescription>]
#	[(algorithm KEY_ALG_RSA_1024|KEY_ALG_RSA_2048)|
#	 (localkeysize 1024|2048|4096 [validityhours <Number>])|
#	 (yubikey yubikey_pin yubikey_slot AUTHENTICATION yubikey_serialnumber <String>)]
def doCreateSvcAcct():
  _checkForExistingProjectFiles([GC.Values[GC.OAUTH2SERVICE_JSON]])
  _, httpObj, login_hint, projects = _getLoginHintProjects(createSvcAcctCmd=True)
  svcAcctInfo = {'name': '', 'displayName': '', 'description': ''}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if _getSvcAcctInfo(myarg, svcAcctInfo):
      pass
    else:
      unknownArgumentExit()
  if not svcAcctInfo['name']:
    svcAcctInfo['name'] = _generateProjectSvcAcctId('gam-svcacct')
  if not svcAcctInfo['displayName']:
    svcAcctInfo['displayName'] = svcAcctInfo['name']
  if not svcAcctInfo['description']:
    svcAcctInfo['description'] = svcAcctInfo['displayName']
  count = len(projects)
  entityPerformActionSubItemModifierNumItems([Ent.USER, login_hint], Ent.SVCACCT, Act.MODIFIER_TO, count, Ent.PROJECT)
  Ind.Increment()
  i = 0
  for project in projects:
    i += 1
    if not _checkProjectFound(project, i, count):
      continue
    projectInfo = {'projectId': project['projectId']}
    _createOauth2serviceJSON(httpObj, projectInfo, svcAcctInfo)
  Ind.Decrement()

# gam delete svcacct [[admin] <EmailAddress>] [<ProjectIDEntity>]
#	(saemail <ServiceAccountEmail>)|(saname <ServiceAccountName>)|(sauniqueid <ServiceAccountUniqueID>)
def doDeleteSvcAcct():
  _, httpObj, login_hint, projects = _getLoginHintProjects(deleteSvcAcctCmd=True)
  iam = getAPIService(API.IAM, httpObj)
  clientEmail = clientId = clientName = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'saemail':
      clientEmail = getEmailAddress(noUid=True)
      clientName = clientId = None
    elif myarg == 'saname':
      clientName = getString(Cmd.OB_STRING, minLen=6, maxLen=30).strip()
      _checkProjectId(clientName)
      clientEmail = clientId = None
    elif myarg == 'sauniqueid':
      clientId = getInteger(minVal=0)
      clientEmail = clientName = None
    else:
      unknownArgumentExit()
  if not clientEmail and not clientId and not clientName:
    missingArgumentExit('email|name|uniqueid')
  count = len(projects)
  entityPerformActionSubItemModifierNumItems([Ent.USER, login_hint], Ent.SVCACCT, Act.MODIFIER_FROM, count, Ent.PROJECT)
  Ind.Increment()
  i = 0
  for project in projects:
    i += 1
    if not _checkProjectFound(project, i, count):
      continue
    projectId = project['projectId']
    try:
      if clientEmail:
        saName = clientEmail
      elif clientName:
        saName = f'{clientName}@{projectId}.iam.gserviceaccount.com'
      else: #clientId
        saName = clientId
      callGAPI(iam.projects().serviceAccounts(), 'delete',
               throwReasons=[GAPI.NOT_FOUND, GAPI.BAD_REQUEST],
               name=f'projects/{projectId}/serviceAccounts/{saName}')
      entityActionPerformed([Ent.PROJECT, projectId, Ent.SVCACCT, saName], i, count)
    except (GAPI.notFound, GAPI.badRequest) as e:
      entityActionFailedWarning([Ent.PROJECT, projectId, Ent.SVCACCT, saName], str(e), i, count)
    Ind.Decrement()

def _getSvcAcctKeyProjectClientFields():
  return (GM.Globals[GM.OAUTH2SERVICE_JSON_DATA].get('private_key_id', ''),
          GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['project_id'],
          GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['client_email'],
          GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['client_id'])

# gam <UserTypeEntity> check serviceaccount (scope|scopes <APIScopeURLList>)* [usecolor]
# gam <UserTypeEntity> update serviceaccount (scope|scopes <APIScopeURLList>)* [usecolor]
def checkServiceAccount(users):
  def printMessage(message):
    writeStdout(Ind.Spaces()+message+'\n')

  def printPassFail(description, result):
    writeStdout(Ind.Spaces()+f'{description:73} {result}'+'\n')

  def authorizeScopes(message):
    long_url = ('https://admin.google.com/ac/owl/domainwidedelegation'
                f'?clientScopeToAdd={",".join(checkScopes)}'
                f'&clientIdToAdd={service_account}&overwriteClientId=true')
    if GC.Values[GC.DOMAIN]:
      long_url += f'&dn={GC.Values[GC.DOMAIN]}'
    long_url += f'&authuser={_getAdminEmail()}'
    short_url = shortenURL(long_url)
    printLine(message.format('', short_url))

  credentials = getSvcAcctCredentials([API.USERINFO_EMAIL_SCOPE], None, forceOauth=True)
  allScopes = API.getSvcAcctScopes(GC.Values[GC.USER_SERVICE_ACCOUNT_ACCESS_ONLY], Act.Get() == Act.UPDATE)
  checkScopesSet = set()
  saScopes = {}
  useColor = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'scope', 'scopes'}:
      for scope in getString(Cmd.OB_API_SCOPE_URL_LIST).lower().replace(',', ' ').split():
        api = API.getSvcAcctScopeAPI(scope)
        if api is not None:
          saScopes.setdefault(api, [])
          saScopes[api].append(scope)
          checkScopesSet.add(scope)
        else:
          invalidChoiceExit(scope, allScopes, True)
    elif myarg == 'usecolor':
      useColor = True
    else:
      unknownArgumentExit()
  if useColor:
    testPass = createGreenText('PASS')
    testFail = createRedText('FAIL')
    testWarn = createYellowText('WARN')
  else:
    testPass = 'PASS'
    testFail = 'FAIL'
    testWarn = 'WARN'
  if Act.Get() == Act.CHECK:
    if not checkScopesSet:
      for scope in iter(GM.Globals[GM.SVCACCT_SCOPES].values()):
        checkScopesSet.update(scope)
  else:
    if not checkScopesSet:
      scopesList = API.getSvcAcctScopesList(GC.Values[GC.USER_SERVICE_ACCOUNT_ACCESS_ONLY], True)
      selectedScopes = getScopesFromUser(scopesList, False, GM.Globals[GM.SVCACCT_SCOPES])
      if selectedScopes is None:
        return False
      i = 0
      for scope in scopesList:
        if selectedScopes[i] == '*':
          saScopes.setdefault(scope['api'], [])
          saScopes[scope['api']].append(scope['scope'])
          checkScopesSet.add(scope['scope'])
        elif selectedScopes[i] == 'R':
          saScopes.setdefault(scope['api'], [])
          if 'roscope' not in scope:
            saScopes[scope['api']].append(f'{scope["scope"]}.readonly')
            checkScopesSet.add(f'{scope["scope"]}.readonly')
          else:
            saScopes[scope['api']].append(scope['roscope'])
            checkScopesSet.add(scope['roscope'])
        i += 1
    if API.DRIVEACTIVITY in saScopes and API.DRIVE3 in saScopes:
      saScopes[API.DRIVEACTIVITY].append(API.DRIVE_SCOPE)
    if API.DRIVE3 in saScopes:
      saScopes[API.DRIVE2] = saScopes[API.DRIVE3]
    GM.Globals[GM.OAUTH2SERVICE_JSON_DATA][API.OAUTH2SA_SCOPES] = saScopes
    writeFile(GC.Values[GC.OAUTH2SERVICE_JSON],
              json.dumps(GM.Globals[GM.OAUTH2SERVICE_JSON_DATA], ensure_ascii=False, sort_keys=True, indent=2),
              continueOnError=False)
  checkScopes = sorted(checkScopesSet)
  jcount = len(checkScopes)
  printMessage(Msg.SYSTEM_TIME_STATUS)
  offsetSeconds, offsetFormatted = getLocalGoogleTimeOffset()
  if offsetSeconds <= MAX_LOCAL_GOOGLE_TIME_OFFSET:
    timeStatus = testPass
  else:
    timeStatus = testFail
  Ind.Increment()
  printPassFail(Msg.YOUR_SYSTEM_TIME_DIFFERS_FROM_GOOGLE.format(GOOGLE_TIMECHECK_LOCATION, offsetFormatted), timeStatus)
  Ind.Decrement()
  oa2 = buildGAPIObject(API.OAUTH2)
  printMessage(Msg.SERVICE_ACCOUNT_PRIVATE_KEY_AUTHENTICATION)
  # We are explicitly not doing DwD here, just confirming service account can auth
  auth_error = ''
  try:
    request = transportCreateRequest()
    credentials.refresh(request)
    sa_token_info = callGAPI(oa2, 'tokeninfo', access_token=credentials.token)
    if sa_token_info:
      saTokenStatus = testPass
    else:
      saTokenStatus = testFail
  except (httplib2.HttpLib2Error, google.auth.exceptions.TransportError, RuntimeError) as e:
    handleServerError(e)
  except google.auth.exceptions.RefreshError as e:
    saTokenStatus = testFail
    if isinstance(e.args, tuple):
      e = e.args[0]
    auth_error = ' - '+str(e)
  Ind.Increment()
  printPassFail(f'Authentication{auth_error}', saTokenStatus)
  Ind.Decrement()
  if saTokenStatus == testFail:
    invalidOauth2serviceJsonExit(f'Authentication{auth_error}')
  _getSvcAcctData() # needed to read in GM.OAUTH2SERVICE_JSON_DATA
  if GM.Globals[GM.SVCACCT_SCOPES_DEFINED] and API.IAM not in GM.Globals[GM.SVCACCT_SCOPES]:
    GM.Globals[GM.SVCACCT_SCOPES][API.IAM] = [API.CLOUD_PLATFORM_SCOPE]
  key_type = GM.Globals[GM.OAUTH2SERVICE_JSON_DATA].get('key_type', 'default')
  if key_type == 'default':
    printMessage(Msg.SERVICE_ACCOUNT_CHECK_PRIVATE_KEY_AGE)
    _, iam = buildGAPIServiceObject(API.IAM, None)
    currentPrivateKeyId, projectId, _, clientId = _getSvcAcctKeyProjectClientFields()
    name = f'projects/{projectId}/serviceAccounts/{clientId}/keys/{currentPrivateKeyId}'
    Ind.Increment()
    try:
      key = callGAPI(iam.projects().serviceAccounts().keys(), 'get',
                     throwReasons=[GAPI.BAD_REQUEST, GAPI.INVALID, GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED],
                     name=name, fields='validAfterTime')
      key_created, _ = iso8601.parse_date(key['validAfterTime'])
      key_age = todaysTime()-key_created
      printPassFail(Msg.SERVICE_ACCOUNT_PRIVATE_KEY_AGE.format(key_age.days), testWarn if key_age.days > 30 else testPass)
    except GAPI.permissionDenied:
      printMessage(Msg.UPDATE_PROJECT_TO_VIEW_MANAGE_SAKEYS)
      printPassFail(Msg.SERVICE_ACCOUNT_PRIVATE_KEY_AGE.format('UNKNOWN'), testWarn)
    except (GAPI.badRequest, GAPI.invalid, GAPI.notFound) as e:
      entityActionFailedWarning([Ent.PROJECT, GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['project_id'],
                                 Ent.SVCACCT, GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['client_email']],
                                str(e))
      printPassFail(Msg.SERVICE_ACCOUNT_PRIVATE_KEY_AGE.format('UNKNOWN'), testWarn)
  else:
    printPassFail(Msg.SERVICE_ACCOUNT_SKIPPING_KEY_AGE_CHECK.format(key_type), testPass)
  Ind.Decrement()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    allScopesPass = True
    user = convertUIDtoEmailAddress(user)
    printKeyValueListWithCount([Msg.DOMAIN_WIDE_DELEGATION_AUTHENTICATION, '',
                                Ent.Singular(Ent.USER), user,
                                Ent.Choose(Ent.SCOPE, jcount), jcount],
                               i, count)
    Ind.Increment()
    j = 0
    for scope in checkScopes:
      j += 1
      # try with and without email scope
      for scopes in [[scope, API.USERINFO_EMAIL_SCOPE], [scope]]:
        try:
          credentials = getSvcAcctCredentials(scopes, user)
          credentials.refresh(request)
          break
        except (httplib2.HttpLib2Error, google.auth.exceptions.TransportError, RuntimeError) as e:
          handleServerError(e)
        except google.auth.exceptions.RefreshError:
          continue
      if credentials.token:
        token_info = callGAPI(oa2, 'tokeninfo', access_token=credentials.token)
        if scope in token_info.get('scope', '').split(' ') and user == token_info.get('email', user).lower():
          scopeStatus = testPass
        else:
          scopeStatus = testFail
          allScopesPass = False
      else:
        scopeStatus = testFail
        allScopesPass = False
      printPassFail(scope, f'{scopeStatus}{currentCount(j, jcount)}')
    Ind.Decrement()
    service_account = GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['client_id']
    if allScopesPass:
      if Act.Get() == Act.CHECK:
        printLine(Msg.SCOPE_AUTHORIZATION_PASSED.format(service_account))
      else:
        authorizeScopes(Msg.SCOPE_AUTHORIZATION_UPDATE_PASSED)
    else:
      # Tack on email scope for more accurate checking
      checkScopes.append(API.USERINFO_EMAIL_SCOPE)
      setSysExitRC(SCOPES_NOT_AUTHORIZED_RC)
      authorizeScopes(Msg.SCOPE_AUTHORIZATION_FAILED)
    printBlankLine()

# gam check svcacct <UserTypeEntity> (scope|scopes <APIScopeURLList>)*
# gam update svcacct <UserTypeEntity> (scope|scopes <APIScopeURLList>)*
def doCheckUpdateSvcAcct():
  _, entityList = getEntityToModify(defaultEntityType=Cmd.ENTITY_USER)
  checkServiceAccount(entityList)

def _getSAKeys(iam, projectId, clientEmail, name, keyTypes):
  try:
    keys = callGAPIitems(iam.projects().serviceAccounts().keys(), 'list', 'keys',
                         throwReasons=[GAPI.BAD_REQUEST, GAPI.PERMISSION_DENIED],
                         name=name, fields='*', keyTypes=keyTypes)
    return (True, keys)
  except GAPI.permissionDenied:
    entityActionFailedWarning([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], Msg.UPDATE_PROJECT_TO_VIEW_MANAGE_SAKEYS)
  except GAPI.badRequest as e:
    entityActionFailedWarning([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], str(e))
  return (False, None)

SVCACCT_KEY_TIME_OBJECTS = {'validAfterTime', 'validBeforeTime'}

def _showSAKeys(keys, count, currentPrivateKeyId):
  Ind.Increment()
  i = 0
  for key in keys:
    i += 1
    keyName = key.pop('name').rsplit('/', 1)[-1]
    printKeyValueListWithCount(['name', keyName], i, count)
    Ind.Increment()
    for k, v in sorted(iter(key.items())):
      if k not in SVCACCT_KEY_TIME_OBJECTS:
        printKeyValueList([k, v])
      else:
        printKeyValueList([k, formatLocalTime(v)])
    if keyName == currentPrivateKeyId:
      printKeyValueList(['usedToAuthenticateThisRequest', True])
    Ind.Decrement()
  Ind.Decrement()

SVCACCT_DISPLAY_FIELDS = ['displayName', 'description', 'oauth2ClientId', 'uniqueId', 'disabled']
SVCACCT_KEY_TYPE_CHOICE_MAP = {
  'all': None,
  'system': 'SYSTEM_MANAGED',
  'systemmanaged': 'SYSTEM_MANAGED',
  'user': 'USER_MANAGED',
  'usermanaged': 'USER_MANAGED'
  }

# gam print svcaccts [[admin] <EmailAddress>] [all|<ProjectIDEntity>]
#	[showsakeys all|system|user]
#	[todrive <ToDriveAttribute>*] [formatjson [quotechar <Character>]]
# gam show svcaccts [<EmailAddress>] [all|<ProjectIDEntity>]
#	[showsakeys all|system|user]
def doPrintShowSvcAccts():
  _, httpObj, login_hint, projects = _getLoginHintProjects(printShowCmd=True, readOnly=False)
  csvPF = CSVPrintFile(['User', 'projectId']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  iam = getAPIService(API.IAM, httpObj)
  keyTypes = None
  showSAKeys = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'showsakeys':
      keyTypes = getChoice(SVCACCT_KEY_TYPE_CHOICE_MAP, mapChoice=True)
      showSAKeys = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  count = len(projects)
  if not csvPF:
    entityPerformActionSubItemModifierNumItems([Ent.USER, login_hint], Ent.SVCACCT, Act.MODIFIER_FOR, count, Ent.PROJECT)
  else:
    csvPF.AddTitles(['projectId']+SVCACCT_DISPLAY_FIELDS)
    csvPF.SetSortAllTitles()
  i = 0
  for project in projects:
    i += 1
    if not _checkProjectFound(project, i, count):
      continue
    projectId = project['projectId']
    if csvPF:
      printGettingAllEntityItemsForWhom(Ent.SVCACCT, projectId, i, count)
    if project['state'] != 'ACTIVE':
      entityActionNotPerformedWarning([Ent.PROJECT, projectId], Msg.DELETED, i, count)
      continue
    try:
      svcAccts = callGAPIpages(iam.projects().serviceAccounts(), 'list', 'accounts',
                               throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED],
                               name=f'projects/{projectId}')
      jcount = len(svcAccts)
      if not csvPF:
        entityPerformActionNumItems([Ent.PROJECT, projectId], jcount, Ent.SVCACCT, i, count)
        Ind.Increment()
        j = 0
        for svcAcct in svcAccts:
          j += 1
          printKeyValueListWithCount(['email', svcAcct['email']], j, jcount)
          Ind.Increment()
          for field in SVCACCT_DISPLAY_FIELDS:
            if field in svcAcct:
              printKeyValueList([field, svcAcct[field]])
          if showSAKeys:
            name = f"projects/{projectId}/serviceAccounts/{svcAcct['oauth2ClientId']}"
            status, keys = _getSAKeys(iam, projectId, svcAcct['email'], name, keyTypes)
            if status:
              kcount = len(keys)
              if kcount > 0:
                printKeyValueList([Ent.Choose(Ent.SVCACCT_KEY, kcount), kcount])
                _showSAKeys(keys, kcount, '')
          Ind.Decrement()
        Ind.Decrement()
      else:
        for svcAcct in svcAccts:
          if showSAKeys:
            name = f"projects/{projectId}/serviceAccounts/{svcAcct['oauth2ClientId']}"
            status, keys = _getSAKeys(iam, projectId, svcAcct['email'], name, keyTypes)
            if status:
              svcAcct['keys'] = keys
          row = flattenJSON(svcAcct, flattened={'User': login_hint}, timeObjects=SVCACCT_KEY_TIME_OBJECTS)
          if not FJQC.formatJSON:
            csvPF.WriteRowTitles(row)
          elif csvPF.CheckRowTitles(row):
            csvPF.WriteRowNoFilter({'User': login_hint, 'projectId': projectId,
                                    'JSON': json.dumps(cleanJSON(svcAcct, timeObjects=SVCACCT_KEY_TIME_OBJECTS),
                                                       ensure_ascii=False, sort_keys=True)})
    except (GAPI.notFound, GAPI.permissionDenied) as e:
      entityActionFailedWarning([Ent.PROJECT, projectId], str(e), i, count)
  if csvPF:
    csvPF.writeCSVfile('Service Accounts')

def _generatePrivateKeyAndPublicCert(projectId, clientEmail, name, key_size, b64enc_pub=True, validityHours=0):
  if projectId:
    printEntityMessage([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], Msg.GENERATING_NEW_PRIVATE_KEY)
  else:
    writeStdout(Msg.GENERATING_NEW_PRIVATE_KEY+'\n')
  private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size, backend=default_backend())
  private_pem = private_key.private_bytes(encoding=serialization.Encoding.PEM,
                                          format=serialization.PrivateFormat.PKCS8,
                                          encryption_algorithm=serialization.NoEncryption()).decode()

  if projectId:
    printEntityMessage([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], Msg.EXTRACTING_PUBLIC_CERTIFICATE)
  else:
    writeStdout(Msg.EXTRACTING_PUBLIC_CERTIFICATE+'\n')
  public_key = private_key.public_key()
  builder = x509.CertificateBuilder()
  # suppress cryptography warnings on service account email length
  with warnings.catch_warnings():
    warnings.filterwarnings('ignore', message='.*Attribute\'s length.*')
    builder = builder.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,
                                                                 name,
                                                                 _validate=False)]))
    builder = builder.issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,
                                                                name,
                                                                _validate=False)]))
  # Gooogle seems to enforce the not before date strictly. Set the not before
  # date to be UTC two minutes ago which should cover any clock skew.
  now = datetime.datetime.utcnow()
  builder = builder.not_valid_before(now - datetime.timedelta(minutes=2))
  # Google defaults to 12/31/9999 date for end time if there's no
  # policy to restrict key age
  if validityHours:
    expires = now + datetime.timedelta(hours=validityHours) - datetime.timedelta(minutes=2)
    builder = builder.not_valid_after(expires)
  else:
    builder = builder.not_valid_after(datetime.datetime(9999, 12, 31, 23, 59))
  builder = builder.serial_number(x509.random_serial_number())
  builder = builder.public_key(public_key)
  builder = builder.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
  builder = builder.add_extension(x509.KeyUsage(key_cert_sign=False,
                                                crl_sign=False, digital_signature=True, content_commitment=False,
                                                key_encipherment=False, data_encipherment=False, key_agreement=False,
                                                encipher_only=False, decipher_only=False), critical=True)
  builder = builder.add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]), critical=True)
  certificate = builder.sign(private_key=private_key, algorithm=hashes.SHA256(), backend=default_backend())
  public_cert_pem = certificate.public_bytes(serialization.Encoding.PEM).decode()
  if projectId:
    printEntityMessage([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], Msg.DONE_GENERATING_PRIVATE_KEY_AND_PUBLIC_CERTIFICATE)
  else:
    writeStdout(Msg.DONE_GENERATING_PRIVATE_KEY_AND_PUBLIC_CERTIFICATE+'\n')
  if not b64enc_pub:
    return (private_pem, public_cert_pem)
  publicKeyData = base64.b64encode(public_cert_pem.encode())
  if isinstance(publicKeyData, bytes):
    publicKeyData = publicKeyData.decode()
  return (private_pem, publicKeyData)

def _formatOAuth2ServiceData(service_data):
  quotedEmail = quote(service_data.get('client_email', ''))
  service_data['auth_provider_x509_cert_url'] = API.GOOGLE_AUTH_PROVIDER_X509_CERT_URL
  service_data['auth_uri'] = API.GOOGLE_OAUTH2_ENDPOINT
  service_data['client_x509_cert_url'] = f'https://www.googleapis.com/robot/v1/metadata/x509/{quotedEmail}'
  service_data['token_uri'] = API.GOOGLE_OAUTH2_TOKEN_ENDPOINT
  service_data['type'] = 'service_account'
  GM.Globals[GM.OAUTH2SERVICE_JSON_DATA] = service_data.copy()
  return json.dumps(GM.Globals[GM.OAUTH2SERVICE_JSON_DATA], indent=2, sort_keys=True)

def doProcessSvcAcctKeys(mode=None, iam=None, projectId=None, clientEmail=None, clientId=None):
  def getSAKeyParms(body, new_data):
    nonlocal local_key_size, validityHours
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg == 'algorithm':
        body['keyAlgorithm'] = getChoice(["key_alg_rsa_1024", "key_alg_rsa_2048"]).upper()
        local_key_size = 0
      elif myarg == 'localkeysize':
        local_key_size = int(getChoice(['1024', '2048', '4096']))
      elif myarg == 'validityhours':
        validityHours = getInteger()
      elif myarg == 'yubikey':
        new_data['key_type'] = 'yubikey'
      elif myarg == 'yubikeyslot':
        new_data['yubikey_slot'] = getString(Cmd.OB_STRING).upper()
      elif myarg == 'yubikeypin':
        new_data['yubikey_pin'] = readStdin('Enter your YubiKey PIN: ')
      elif myarg == 'yubikeyserialnumber':
        new_data['yubikey_serial_number'] = getInteger()
      else:
        unknownArgumentExit()

  local_key_size = 2048
  validityHours = 0
  body = {}
  if mode is None:
    mode = getChoice(['retainnone', 'retainexisting', 'replacecurrent'])
  if iam is None or mode == 'upload':
    if iam is None:
      _, iam = buildGAPIServiceObject(API.IAM, None)
    _getSvcAcctData()
    currentPrivateKeyId, projectId, clientEmail, clientId = _getSvcAcctKeyProjectClientFields()
    # dict() ensures we have a real copy, not pointer
    new_data = dict(GM.Globals[GM.OAUTH2SERVICE_JSON_DATA])
    # assume default key type unless we are told otherwise
    new_data['key_type'] = 'default'
    getSAKeyParms(body, new_data)
  else:
    new_data = {
      'client_email': clientEmail,
      'project_id': projectId,
      'client_id': clientId,
      'key_type': 'default'
    }
    getSAKeyParms(body, new_data)
  name = f'projects/{projectId}/serviceAccounts/{clientId}'
  if mode != 'retainexisting':
    try:
      keys = callGAPIitems(iam.projects().serviceAccounts().keys(), 'list', 'keys',
                           throwReasons=[GAPI.BAD_REQUEST, GAPI.PERMISSION_DENIED],
                           name=name, keyTypes='USER_MANAGED')
    except GAPI.permissionDenied:
      entityActionFailedWarning([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], Msg.UPDATE_PROJECT_TO_VIEW_MANAGE_SAKEYS)
      return False
    except GAPI.badRequest as e:
      entityActionFailedWarning([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], str(e))
      return False
  if new_data.get('key_type') == 'yubikey':
    # Use yubikey private key
    new_data['yubikey_key_type'] = f'RSA{local_key_size}'
    new_data.pop('private_key', None)
    yk = yubikey.YubiKey(new_data)
    if 'yubikey_serial_number' not in new_data:
      new_data['yubikey_serial_number'] = yk.get_serial_number()
      yk = yubikey.YubiKey(new_data)
    if 'yubikey_slot' not in new_data:
      new_data['yubikey_slot'] = 'AUTHENTICATION'
    publicKeyData = yk.get_certificate()
  elif local_key_size:
    # Generate private key locally, store in file
    new_data['private_key'], publicKeyData = _generatePrivateKeyAndPublicCert(projectId, clientEmail, name,
                                                                              local_key_size, validityHours=validityHours)
    new_data['key_type'] = 'default'
    for key in list(new_data):
      if key.startswith('yubikey_'):
        new_data.pop(key, None)
  if local_key_size:
    Act.Set(Act.UPLOAD)
    maxRetries = 10
    printEntityMessage([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], Msg.UPLOADING_NEW_PUBLIC_CERTIFICATE_TO_GOOGLE)
    for retry in range(1, maxRetries+1):
      try:
        result = callGAPI(iam.projects().serviceAccounts().keys(), 'upload',
                          throwReasons=[GAPI.NOT_FOUND, GAPI.BAD_REQUEST, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                          name=name, body={'publicKeyData': publicKeyData})
        newPrivateKeyId = result['name'].rsplit('/', 1)[-1]
        break
      except GAPI.notFound as e:
        if retry == maxRetries:
          entityActionFailedWarning([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], str(e))
          return False
        _waitForSvcAcctCompletion(retry)
      except GAPI.permissionDenied:
        if retry == maxRetries:
          entityActionFailedWarning([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], Msg.UPDATE_PROJECT_TO_VIEW_MANAGE_SAKEYS)
          return False
        _waitForSvcAcctCompletion(retry)
      except GAPI.badRequest as e:
        entityActionFailedWarning([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], str(e))
        return False
      except GAPI.failedPrecondition as e:
        entityActionFailedWarning([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], str(e))
        if 'iam.disableServiceAccountKeyUpload' not in str(e) and 'iam.managed.disableServiceAccountKeyUpload' not in str(e):
          return False
        if retry == maxRetries or mode != 'upload':
          sys.stdout.write(Msg.ENABLE_SERVICE_ACCOUNT_PRIVATE_KEY_UPLOAD.format(projectId))
          new_data['private_key'] = ''
          newPrivateKeyId = ''
          break
        _waitForSvcAcctCompletion(retry)
    new_data['private_key_id'] = newPrivateKeyId
    oauth2service_data = _formatOAuth2ServiceData(new_data)
  else:
    Act.Set(Act.CREATE)
    maxRetries = 10
    for retry in range(1, maxRetries+1):
      try:
        result = callGAPI(iam.projects().serviceAccounts().keys(), 'create',
                          throwReasons=[GAPI.BAD_REQUEST, GAPI.PERMISSION_DENIED],
                          name=name, body=body)
        newPrivateKeyId = result['name'].rsplit('/', 1)[-1]
        break
      except GAPI.permissionDenied:
        if retry == maxRetries:
          entityActionFailedWarning([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], Msg.UPDATE_PROJECT_TO_VIEW_MANAGE_SAKEYS)
          return False
        _waitForSvcAcctCompletion(retry)
      except GAPI.badRequest as e:
        entityActionFailedWarning([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], str(e))
        return False
    oauth2service_data = base64.b64decode(result['privateKeyData']).decode(UTF8)
  if newPrivateKeyId != '':
    entityActionPerformed([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail, Ent.SVCACCT_KEY, newPrivateKeyId])
  if GM.Globals[GM.SVCACCT_SCOPES_DEFINED]:
    try:
      GM.Globals[GM.OAUTH2SERVICE_JSON_DATA] = json.loads(oauth2service_data)
    except (IndexError, KeyError, SyntaxError, TypeError, ValueError) as e:
      invalidOauth2serviceJsonExit(str(e))
    GM.Globals[GM.OAUTH2SERVICE_JSON_DATA][API.OAUTH2SA_SCOPES] = GM.Globals[GM.SVCACCT_SCOPES]
    oauth2service_data = json.dumps(GM.Globals[GM.OAUTH2SERVICE_JSON_DATA], ensure_ascii=False, sort_keys=True, indent=2)
  writeFile(GC.Values[GC.OAUTH2SERVICE_JSON], oauth2service_data, continueOnError=False)
  Act.Set(Act.UPDATE)
  entityActionPerformed([Ent.OAUTH2SERVICE_JSON_FILE, GC.Values[GC.OAUTH2SERVICE_JSON],
                         Ent.SVCACCT_KEY, newPrivateKeyId])
  if mode in {'retainexisting', 'upload'}:
    return newPrivateKeyId != ''
  Act.Set(Act.REVOKE)
  count = len(keys) if mode == 'retainnone' else 1
  entityPerformActionNumItems([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], count, Ent.SVCACCT_KEY)
  Ind.Increment()
  i = 0
  for key in keys:
    keyName = key['name'].rsplit('/', 1)[-1]
    if mode == 'retainnone' or keyName == currentPrivateKeyId and keyName != newPrivateKeyId:
      i += 1
      maxRetries = 5
      for retry in range(1, maxRetries+1):
        try:
          callGAPI(iam.projects().serviceAccounts().keys(), 'delete',
                   throwReasons=[GAPI.BAD_REQUEST, GAPI.PERMISSION_DENIED],
                   name=key['name'])
          entityActionPerformed([Ent.SVCACCT_KEY, keyName], i, count)
          break
        except GAPI.permissionDenied:
          if retry == maxRetries:
            entityActionFailedWarning([Ent.SVCACCT_KEY, keyName], Msg.UPDATE_PROJECT_TO_VIEW_MANAGE_SAKEYS)
            break
          _waitForSvcAcctCompletion(retry)
        except GAPI.badRequest as e:
          entityActionFailedWarning([Ent.SVCACCT_KEY, keyName], str(e), i, count)
          break
      if mode != 'retainnone':
        break
  Ind.Decrement()
  return True

# gam create sakey|sakeys
# gam rotate sakey|sakeys retain_existing
#	(algorithm KEY_ALG_RSA_1024|KEY_ALG_RSA_2048)|
#	(localkeysize 1024|2048|4096 [validityhours <Number>])|
#	(yubikey yubikey_pin yubikey_slot AUTHENTICATION yubikey_serialnumber <String>)
def doCreateSvcAcctKeys():
  doProcessSvcAcctKeys(mode='retainexisting')

# gam update sakey|sakeys
# gam rotate sakey|sakeys replace_current
#	(algorithm KEY_ALG_RSA_1024|KEY_ALG_RSA_2048)|
#	(localkeysize 1024|2048|4096 [validityhours <Number>])|
#	(yubikey yubikey_pin yubikey_slot AUTHENTICATION yubikey_serialnumber <String>)
def doUpdateSvcAcctKeys():
  doProcessSvcAcctKeys(mode='replacecurrent')

# gam replace sakey|sakeys
# gam rotate sakey|sakeys retain_none
#	(algorithm KEY_ALG_RSA_1024|KEY_ALG_RSA_2048)|
#	(localkeysize 1024|2048|4096 [validityhours <Number>])|
#	(yubikey yubikey_pin yubikey_slot AUTHENTICATION yubikey_serialnumber <String>)
def doReplaceSvcAcctKeys():
  doProcessSvcAcctKeys(mode='retainnone')

# gam upload sakey|sakeys [admin <EmailAddress>]
#	(algorithm KEY_ALG_RSA_1024|KEY_ALG_RSA_2048)|
#	(localkeysize 1024|2048|4096 [validityhours <Number>])|
#	(yubikey yubikey_pin yubikey_slot AUTHENTICATION yubikey_serialnumber <String>)
def doUploadSvcAcctKeys():
  login_hint = getEmailAddress(noUid=True) if checkArgumentPresent(['admin']) else None
  httpObj, _ = getCRMService(login_hint)
  iam = getAPIService(API.IAM, httpObj)
  if doProcessSvcAcctKeys(mode='upload', iam=iam):
    sa_email = GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['client_email']
    _grantRotateRights(iam, GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['project_id'], sa_email)
    sys.stdout.write(Msg.YOUR_GAM_PROJECT_IS_CREATED_AND_READY_TO_USE)

# gam delete sakeys <ServiceAccountKeyList>
def doDeleteSvcAcctKeys():
  _, iam = buildGAPIServiceObject(API.IAM, None)
  doit = False
  keyList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'doit':
      doit = True
    else:
      Cmd.Backup()
      keyList.extend(getString(Cmd.OB_SERVICE_ACCOUNT_KEY_LIST, minLen=0).strip().replace(',', ' ').split())
  currentPrivateKeyId, projectId, clientEmail, clientId = _getSvcAcctKeyProjectClientFields()
  name = f'projects/{projectId}/serviceAccounts/{clientId}'
  try:
    keys = callGAPIitems(iam.projects().serviceAccounts().keys(), 'list', 'keys',
                         throwReasons=[GAPI.BAD_REQUEST, GAPI.PERMISSION_DENIED],
                         name=name, keyTypes='USER_MANAGED')
  except GAPI.permissionDenied:
    entityActionFailedWarning([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], Msg.UPDATE_PROJECT_TO_VIEW_MANAGE_SAKEYS)
    return
  except GAPI.badRequest as e:
    entityActionFailedWarning([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], str(e))
    return
  Act.Set(Act.REVOKE)
  count = len(keyList)
  entityPerformActionNumItems([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], count, Ent.SVCACCT_KEY)
  Ind.Increment()
  i = 0
  for dkeyName in keyList:
    i += 1
    for key in keys:
      keyName = key['name'].rsplit('/', 1)[-1]
      if keyName == dkeyName:
        if keyName == currentPrivateKeyId and not doit:
          entityActionNotPerformedWarning([Ent.SVCACCT_KEY, keyName],
                                          Msg.USE_DOIT_ARGUMENT_TO_PERFORM_ACTION+Msg.ON_CURRENT_PRIVATE_KEY, i, count)
          break
        try:
          callGAPI(iam.projects().serviceAccounts().keys(), 'delete',
                   throwReasons=[GAPI.BAD_REQUEST, GAPI.PERMISSION_DENIED],
                   name=key['name'])
          entityActionPerformed([Ent.SVCACCT_KEY, keyName], i, count)
        except GAPI.permissionDenied:
          entityActionFailedWarning([Ent.SVCACCT_KEY, keyName], Msg.UPDATE_PROJECT_TO_VIEW_MANAGE_SAKEYS)
        except GAPI.badRequest as e:
          entityActionFailedWarning([Ent.SVCACCT_KEY, keyName], str(e), i, count)
        break
    else:
      entityActionNotPerformedWarning([Ent.SVCACCT_KEY, dkeyName], Msg.NOT_FOUND, i, count)
  Ind.Decrement()

# gam show sakeys [all|system|user]
def doShowSvcAcctKeys():
  _, iam = buildGAPIServiceObject(API.IAM, None)
  keyTypes = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in SVCACCT_KEY_TYPE_CHOICE_MAP:
      keyTypes = SVCACCT_KEY_TYPE_CHOICE_MAP[myarg]
    else:
      unknownArgumentExit()
  currentPrivateKeyId, projectId, clientEmail, clientId = _getSvcAcctKeyProjectClientFields()
  name = f'projects/{projectId}/serviceAccounts/{clientId}'
  status, keys = _getSAKeys(iam, projectId, clientEmail, name, keyTypes)
  if not status:
    return
  count = len(keys)
  entityPerformActionNumItems([Ent.PROJECT, projectId, Ent.SVCACCT, clientEmail], count, Ent.SVCACCT_KEY)
  if count > 0:
    _showSAKeys(keys, count, currentPrivateKeyId)

# gam create gcpserviceaccount|signjwtserviceaccount
def doCreateGCPServiceAccount():
  checkForExtraneousArguments()
  _checkForExistingProjectFiles([GC.Values[GC.OAUTH2SERVICE_JSON]])
  sa_info = {'key_type': 'signjwt', 'token_uri': API.GOOGLE_OAUTH2_TOKEN_ENDPOINT, 'type': 'service_account'}
  request = getTLSv1_2Request()
  try:
    credentials, sa_info['project_id'] = google.auth.default(scopes=[API.IAM_SCOPE], request=request)
  except (google.auth.exceptions.DefaultCredentialsError, google.auth.exceptions.RefreshError) as e:
    systemErrorExit(API_ACCESS_DENIED_RC, str(e))
  credentials.refresh(request)
  sa_info['client_email'] = credentials.service_account_email
  oa2 = buildGAPIObjectNoAuthentication(API.OAUTH2)
  try:
    token_info = callGAPI(oa2, 'tokeninfo',
                          throwReasons=[GAPI.INVALID],
                          access_token=credentials.token)
  except GAPI.invalid as e:
    systemErrorExit(API_ACCESS_DENIED_RC, str(e))
  sa_info['client_id'] = token_info['issued_to']
  sa_output = json.dumps(sa_info, ensure_ascii=False, sort_keys=True, indent=2)
  writeStdout(f'Writing SignJWT service account data:\n\n{sa_output}\n')
  writeFile(GC.Values[GC.OAUTH2SERVICE_JSON], sa_output, continueOnError=False)

# Audit command utilities
def getAuditParameters(emailAddressRequired=True, requestIdRequired=True, destUserRequired=False):
  auditObject = getEmailAuditObject()
  emailAddress = getEmailAddress(noUid=True, optional=not emailAddressRequired)
  parameters = {}
  if emailAddress:
    parameters['auditUser'] = emailAddress
    parameters['auditUserName'], auditObject.domain = splitEmailAddress(emailAddress)
    if requestIdRequired:
      parameters['requestId'] = getString(Cmd.OB_REQUEST_ID)
    if destUserRequired:
      destEmailAddress = getEmailAddress(noUid=True)
      parameters['auditDestUser'] = destEmailAddress
      parameters['auditDestUserName'], destDomain = splitEmailAddress(destEmailAddress)
      if auditObject.domain != destDomain:
        Cmd.Backup()
        invalidArgumentExit(f'{parameters["auditDestUserName"]}@{auditObject.domain}')
  return (auditObject, parameters)

# Audit monitor command utilities
def _showMailboxMonitorRequestStatus(request, i=0, count=0):
  printKeyValueListWithCount(['Destination', normalizeEmailAddressOrUID(request['destUserName'])], i, count)
  Ind.Increment()
  printKeyValueList(['Begin', request.get('beginDate', 'immediately')])
  printKeyValueList(['End', request['endDate']])
  printKeyValueList(['Monitor Incoming', request['outgoingEmailMonitorLevel']])
  printKeyValueList(['Monitor Outgoing', request['incomingEmailMonitorLevel']])
  printKeyValueList(['Monitor Chats', request.get('chatMonitorLevel', 'NONE')])
  printKeyValueList(['Monitor Drafts', request.get('draftMonitorLevel', 'NONE')])
  Ind.Decrement()

# gam audit monitor create <EmailAddress> <DestEmailAddress> [begin <DateTime>] [end <DateTime>] [incoming_headers] [outgoing_headers] [nochats] [nodrafts] [chat_headers] [draft_headers]
def doCreateMonitor():
  auditObject, parameters = getAuditParameters(emailAddressRequired=True, requestIdRequired=False, destUserRequired=True)
  #end_date defaults to 30 days in the future...
  end_date = (GM.Globals[GM.DATETIME_NOW]+datetime.timedelta(days=30)).strftime(YYYYMMDD_HHMM_FORMAT)
  begin_date = None
  incoming_headers_only = outgoing_headers_only = drafts_headers_only = chats_headers_only = False
  drafts = chats = True
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'begin':
      begin_date = getYYYYMMDD_HHMM()
    elif myarg == 'end':
      end_date = getYYYYMMDD_HHMM()
    elif myarg == 'incomingheaders':
      incoming_headers_only = True
    elif myarg == 'outgoingheaders':
      outgoing_headers_only = True
    elif myarg == 'nochats':
      chats = False
    elif myarg == 'nodrafts':
      drafts = False
    elif myarg == 'chatheaders':
      chats_headers_only = True
    elif myarg == 'draftheaders':
      drafts_headers_only = True
    else:
      unknownArgumentExit()
  try:
    request = callGData(auditObject, 'createEmailMonitor',
                        throwErrors=[GDATA.INVALID_VALUE, GDATA.INVALID_INPUT, GDATA.DOES_NOT_EXIST, GDATA.INVALID_DOMAIN],
                        source_user=parameters['auditUserName'], destination_user=parameters['auditDestUserName'], end_date=end_date, begin_date=begin_date,
                        incoming_headers_only=incoming_headers_only, outgoing_headers_only=outgoing_headers_only,
                        drafts=drafts, drafts_headers_only=drafts_headers_only, chats=chats, chats_headers_only=chats_headers_only)
    entityActionPerformed([Ent.USER, parameters['auditUser'], Ent.AUDIT_MONITOR_REQUEST, None])
    Ind.Increment()
    _showMailboxMonitorRequestStatus(request)
    Ind.Decrement()
  except (GDATA.invalidValue, GDATA.invalidInput) as e:
    entityActionFailedWarning([Ent.USER, parameters['auditUser'], Ent.AUDIT_MONITOR_REQUEST, None], str(e))
  except (GDATA.doesNotExist, GDATA.invalidDomain) as e:
    if str(e).find(parameters['auditUser']) != -1:
      entityUnknownWarning(Ent.USER, parameters['auditUser'])
    else:
      entityActionFailedWarning([Ent.USER, parameters['auditUser'], Ent.AUDIT_MONITOR_REQUEST, None], str(e))

# gam audit monitor delete <EmailAddress> <DestEmailAddress>
def doDeleteMonitor():
  auditObject, parameters = getAuditParameters(emailAddressRequired=True, requestIdRequired=False, destUserRequired=True)
  checkForExtraneousArguments()
  try:
    callGData(auditObject, 'deleteEmailMonitor',
              throwErrors=[GDATA.INVALID_INPUT, GDATA.DOES_NOT_EXIST, GDATA.INVALID_DOMAIN],
              source_user=parameters['auditUserName'], destination_user=parameters['auditDestUserName'])
    entityActionPerformed([Ent.USER, parameters['auditUser'], Ent.AUDIT_MONITOR_REQUEST, parameters['auditDestUser']])
  except GDATA.invalidInput as e:
    entityActionFailedWarning([Ent.USER, parameters['auditUser'], Ent.AUDIT_MONITOR_REQUEST, None], str(e))
  except (GDATA.doesNotExist, GDATA.invalidDomain) as e:
    if str(e).find(parameters['auditUser']) != -1:
      entityUnknownWarning(Ent.USER, parameters['auditUser'])
    else:
      entityActionFailedWarning([Ent.USER, parameters['auditUser'], Ent.AUDIT_MONITOR_REQUEST, None], str(e))

# gam audit monitor list <EmailAddress>
def doShowMonitors():
  auditObject, parameters = getAuditParameters(emailAddressRequired=True, requestIdRequired=False, destUserRequired=False)
  checkForExtraneousArguments()
  try:
    results = callGData(auditObject, 'getEmailMonitors',
                        throwErrors=[GDATA.DOES_NOT_EXIST, GDATA.INVALID_DOMAIN],
                        user=parameters['auditUserName'])
    jcount = len(results) if (results) else 0
    entityPerformActionNumItems([Ent.USER, parameters['auditUser']], jcount, Ent.AUDIT_MONITOR_REQUEST)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      return
    Ind.Increment()
    j = 0
    for request in results:
      j += 1
      _showMailboxMonitorRequestStatus(request, j, jcount)
    Ind.Decrement()
  except (GDATA.doesNotExist, GDATA.invalidDomain):
    entityUnknownWarning(Ent.USER, parameters['auditUser'])

# gam whatis <EmailItem> [noinfo] [noinvitablecheck]
def doWhatIs():
  def _showPrimaryType(entityType, email):
    printEntity([entityType, email])

  def _showAliasType(entityType, email, primaryEntityType, primaryEmail):
    printEntity([entityType, email, primaryEntityType, primaryEmail])

  cd = buildGAPIObject(API.DIRECTORY)
  email = getEmailAddress()
  showInfo = invitableCheck = True
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'noinfo':
      showInfo = False
    elif myarg == 'noinvitablecheck':
      invitableCheck = False
    else:
      unknownArgumentExit()
  try:
    result = callGAPI(cd.users(), 'get',
                      throwReasons=GAPI.USER_GET_THROW_REASONS,
                      userKey=email, fields='id,primaryEmail')
    if (result['primaryEmail'].lower() == email) or (result['id'] == email):
      if showInfo:
        infoUsers(entityList=[email])
      else:
        _showPrimaryType(Ent.USER, email)
      setSysExitRC(ENTITY_IS_A_USER_RC)
    else:
      if showInfo:
        infoAliases(entityList=[email])
      else:
        _showAliasType(Ent.USER_ALIAS, email, Ent.USER, result['primaryEmail'])
      setSysExitRC(ENTITY_IS_A_USER_ALIAS_RC)
    return
  except (GAPI.userNotFound, GAPI.badRequest):
    pass
  except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.backendError, GAPI.systemError):
    entityUnknownWarning(Ent.EMAIL, email)
    setSysExitRC(ENTITY_IS_UKNOWN_RC)
    return
  try:
    result = callGAPI(cd.groups(), 'get',
                      throwReasons=GAPI.GROUP_GET_THROW_REASONS,
                      groupKey=email, fields='id,email')
    if (result['email'].lower() == email) or (result['id'] == email):
      if showInfo:
        infoGroups([email])
      else:
        _showPrimaryType(Ent.GROUP, email)
      setSysExitRC(ENTITY_IS_A_GROUP_RC)
    else:
      if showInfo:
        infoAliases(entityList=[email])
      else:
        _showAliasType(Ent.GROUP_ALIAS, email, Ent.GROUP, result['email'])
      setSysExitRC(ENTITY_IS_A_GROUP_ALIAS_RC)
    return
  except (GAPI.groupNotFound, GAPI.forbidden):
    pass
  except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.badRequest):
    entityUnknownWarning(Ent.EMAIL, email)
    setSysExitRC(ENTITY_IS_UKNOWN_RC)
    return
  if not invitableCheck or not getSvcAcctCredentials(API.CLOUDIDENTITY_USERINVITATIONS, _getAdminEmail(), softErrors=True):
    isInvitableUser = False
  else:
    isInvitableUser, ci = _getIsInvitableUser(None, email)
  if isInvitableUser:
    if showInfo:
      name, user, ci = _getCIUserInvitationsEntity(ci, email)
      infoCIUserInvitations(name, user, ci, None)
    else:
      _showPrimaryType(Ent.USER_INVITATION, email)
    setSysExitRC(ENTITY_IS_AN_UNMANAGED_ACCOUNT_RC)
  else:
    entityUnknownWarning(Ent.EMAIL, email)
    setSysExitRC(ENTITY_IS_UKNOWN_RC)

def _adjustTryDate(errMsg, numDateChanges, limitDateChanges, prevTryDate):
  match_date = re.match('Data for dates later than (.*) is not yet available. Please check back later', errMsg)
  if match_date:
    tryDate = match_date.group(1)
  else:
    match_date = re.match('Start date can not be later than (.*)', errMsg)
    if match_date:
      tryDate = match_date.group(1)
    else:
      match_date = re.match('End date greater than LastReportedDate.', errMsg)
      if match_date:
        tryDateTime = datetime.datetime.strptime(prevTryDate, YYYYMMDD_FORMAT)-datetime.timedelta(days=1)
        tryDate = tryDateTime.strftime(YYYYMMDD_FORMAT)
  if (not match_date) or (numDateChanges > limitDateChanges >= 0):
    printWarningMessage(DATA_NOT_AVALIABLE_RC, errMsg)
    return None
  return tryDate

def _checkDataRequiredServices(result, tryDate, dataRequiredServices, parameterServices=None, checkUserEmail=False):
# -1: Data not available:
#  0: Backup to earlier date
#  1: Data available
  oneDay = datetime.timedelta(days=1)
  dataWarnings = result.get('warnings', [])
  usageReports = result.get('usageReports', [])
  # move to day before if we don't have at least one usageReport with parameters
  if not usageReports or not usageReports[0].get('parameters', []):
    tryDateTime = datetime.datetime.strptime(tryDate, YYYYMMDD_FORMAT)-oneDay
    return (0, tryDateTime.strftime(YYYYMMDD_FORMAT), None)
  for warning in dataWarnings:
    if warning['code'] == 'PARTIAL_DATA_AVAILABLE':
      for app in warning['data']:
        if app['key'] == 'application' and app['value'] != 'docs' and app['value'] in dataRequiredServices:
          tryDateTime = datetime.datetime.strptime(tryDate, YYYYMMDD_FORMAT)-oneDay
          return (0, tryDateTime.strftime(YYYYMMDD_FORMAT), None)
    elif warning['code'] == 'DATA_NOT_AVAILABLE':
      for app in warning['data']:
        if app['key'] == 'application' and app['value'] != 'docs' and app['value'] in dataRequiredServices:
          return (-1, tryDate, None)
  if parameterServices:
    requiredServices = parameterServices.copy()
    for item in usageReports[0].get('parameters', []):
      if 'name' not in item:
        continue
      service, _ = item['name'].split(':', 1)
      if service in requiredServices:
        requiredServices.remove(service)
        if not requiredServices:
          break
    else:
      tryDateTime = datetime.datetime.strptime(tryDate, YYYYMMDD_FORMAT)-oneDay
      return (0, tryDateTime.strftime(YYYYMMDD_FORMAT), None)
  if checkUserEmail:
    if 'entity' not in usageReports[0] or 'userEmail' not in usageReports[0]['entity']:
      tryDateTime = datetime.datetime.strptime(tryDate, YYYYMMDD_FORMAT)-oneDay
      return (0, tryDateTime.strftime(YYYYMMDD_FORMAT), None)
  return (1, tryDate, usageReports)

CUSTOMER_REPORT_SERVICES = {
  'accounts',
  'app_maker',
  'apps_scripts',
  'calendar',
  'chat',
  'classroom',
  'cros',
  'device_management',
  'docs',
  'drive',
  'gmail',
  'gplus',
  'meet',
  'sites',
  }

USER_REPORT_SERVICES = {
  'accounts',
  'chat',
  'classroom',
  'docs',
  'drive',
  'gmail',
  'gplus',
  }

CUSTOMER_USER_CHOICES = {'customer', 'user'}

# gam report usageparameters customer|user [todrive <ToDriveAttribute>*]
def doReportUsageParameters():
  report = getChoice(CUSTOMER_USER_CHOICES)
  csvPF = CSVPrintFile(['parameter'], 'sortall')
  getTodriveOnly(csvPF)
  rep = buildGAPIObject(API.REPORTS)
  if report == 'customer':
    service = rep.customerUsageReports()
    dataRequiredServices = CUSTOMER_REPORT_SERVICES
    kwargs = {}
  else: # 'user'
    service = rep.userUsageReport()
    dataRequiredServices = USER_REPORT_SERVICES
    kwargs = {'userKey': _getAdminEmail()}
  customerId = GC.Values[GC.CUSTOMER_ID]
  if customerId == GC.MY_CUSTOMER:
    customerId = None
  tryDate = todaysDate().strftime(YYYYMMDD_FORMAT)
  allParameters = set()
  while True:
    try:
      result = callGAPI(service, 'get',
                        throwReasons=[GAPI.INVALID, GAPI.BAD_REQUEST],
                        date=tryDate, customerId=customerId, fields='warnings,usageReports(parameters(name))', **kwargs)
      fullData, tryDate, usageReports = _checkDataRequiredServices(result, tryDate, dataRequiredServices)
      if fullData < 0:
        printWarningMessage(DATA_NOT_AVALIABLE_RC, Msg.NO_USAGE_PARAMETERS_DATA_AVAILABLE)
        return
      if usageReports:
        for parameter in usageReports[0]['parameters']:
          name = parameter.get('name')
          if name:
            allParameters.add(name)
      if fullData == 1:
        break
    except GAPI.badRequest:
      printErrorMessage(BAD_REQUEST_RC, Msg.BAD_REQUEST)
      return
    except GAPI.invalid as e:
      tryDate = _adjustTryDate(str(e), 0, -1, tryDate)
      if not tryDate:
        break
  for parameter in sorted(allParameters):
    csvPF.WriteRow({'parameter': parameter})
  csvPF.writeCSVfile(f'{report.capitalize()} Report Usage Parameters')

def getUserOrgUnits(cd, orgUnit, orgUnitId):
  try:
    if orgUnit == orgUnitId:
      orgUnit = callGAPI(cd.orgunits(), 'get',
                         throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                         customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=orgUnit, fields='orgUnitPath')['orgUnitPath']
    printGettingAllEntityItemsForWhom(Ent.USER, orgUnit, qualifier=Msg.IN_THE.format(Ent.Singular(Ent.ORGANIZATIONAL_UNIT)),
                                      entityType=Ent.ORGANIZATIONAL_UNIT)
    result = callGAPIpages(cd.users(), 'list', 'users',
                           pageMessage=getPageMessageForWhom(),
                           throwReasons=[GAPI.INVALID_ORGUNIT, GAPI.ORGUNIT_NOT_FOUND,
                                         GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                           customer=GC.Values[GC.CUSTOMER_ID], query=orgUnitPathQuery(orgUnit, None), orderBy='email',
                           fields='nextPageToken,users(primaryEmail,orgUnitPath)', maxResults=GC.Values[GC.USER_MAX_RESULTS])
    userOrgUnits = {}
    for user in result:
      userOrgUnits[user['primaryEmail']] = user['orgUnitPath']
    return userOrgUnits
  except (GAPI.badRequest, GAPI.invalidInput, GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError,
          GAPI.invalidCustomerId, GAPI.loginRequired, GAPI.resourceNotFound, GAPI.forbidden):
    checkEntityDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, orgUnit)

# Convert report mb item to gb
def convertReportMBtoGB(name, item):
  if item is not None:
    item['intValue'] = f"{int(item['intValue'])/1024:.2f}"
  return name.replace('_in_mb', '_in_gb')

REPORTS_PARAMETERS_SIMPLE_TYPES = ['intValue', 'boolValue', 'datetimeValue', 'stringValue']

# gam report usage user [todrive <ToDriveAttribute>*]
#	[(user all|<UserItem>)|(orgunit|org|ou <OrgUnitPath> [showorgunit])|(select <UserTypeEntity>)]
#	[([start|startdate <Date>] [end|enddate <Date>])|(range <Date> <Date>)|
#	 thismonth|(previousmonths <Integer>)]
#	[fields|parameters <String>)]
#	[convertmbtogb]
# gam report usage customer [todrive <ToDriveAttribute>*]
#	[([start|startdate <Date>] [end|enddate <Date>])|(range <Date> <Date>)|
#	 thismonth|(previousmonths <Integer>)]
#	[fields|parameters <String>)]
#	[convertmbtogb]
def doReportUsage():
  def usageEntitySelectors():
    selectorChoices = Cmd.USER_ENTITY_SELECTORS+Cmd.USER_CSVDATA_ENTITY_SELECTORS
    if GC.Values[GC.USER_SERVICE_ACCOUNT_ACCESS_ONLY]:
      selectorChoices += Cmd.SERVICE_ACCOUNT_ONLY_ENTITY_SELECTORS[:]+[Cmd.ENTITY_USER, Cmd.ENTITY_USERS]
    else:
      selectorChoices += Cmd.BASE_ENTITY_SELECTORS[:]+Cmd.USER_ENTITIES[:]
    return selectorChoices

  def validateYYYYMMDD(argstr):
    if argstr in TODAY_NOW or argstr[0] in PLUS_MINUS:
      if argstr == 'NOW':
        argstr = 'TODAY'
      deltaDate = getDelta(argstr, DELTA_DATE_PATTERN)
      if deltaDate is None:
        Cmd.Backup()
        invalidArgumentExit(DELTA_DATE_FORMAT_REQUIRED)
      return deltaDate
    try:
      argDate = datetime.datetime.strptime(argstr, YYYYMMDD_FORMAT)
      return datetime.datetime(argDate.year, argDate.month, argDate.day, tzinfo=GC.Values[GC.TIMEZONE])
    except ValueError:
      Cmd.Backup()
      invalidArgumentExit(YYYYMMDD_FORMAT_REQUIRED)

  report = getChoice(CUSTOMER_USER_CHOICES)
  rep = buildGAPIObject(API.REPORTS)
  titles = ['date']
  if report == 'customer':
    fullDataServices = CUSTOMER_REPORT_SERVICES
    userReports = False
    service = rep.customerUsageReports()
    kwargs = [{}]
  else: # 'user'
    fullDataServices = USER_REPORT_SERVICES
    userReports = True
    service = rep.userUsageReport()
    kwargs = [{'userKey': 'all'}]
    titles.append('user')
  csvPF = CSVPrintFile()
  customerId = GC.Values[GC.CUSTOMER_ID]
  if customerId == GC.MY_CUSTOMER:
    customerId = None
  parameters = set()
  convertMbToGb = select = showOrgUnit = False
  userKey = 'all'
  cd = orgUnit = orgUnitId = None
  userOrgUnits = {}
  startEndTime = StartEndTime('startdate', 'enddate', 'date')
  skipDayNumbers = []
  skipDates = set()
  oneDay = datetime.timedelta(days=1)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'start', 'startdate', 'end', 'enddate', 'range', 'thismonth', 'previousmonths'}:
      startEndTime.Get(myarg)
    elif userReports and myarg in {'ou', 'org', 'orgunit'}:
      if cd is None:
        cd = buildGAPIObject(API.DIRECTORY)
      orgUnit, orgUnitId = getOrgUnitId(cd)
      select = False
    elif userReports and myarg == 'showorgunit':
      showOrgUnit = True
    elif myarg in {'fields', 'parameters'}:
      for field in getString(Cmd.OB_STRING).replace(',', ' ').split():
        if ':' in field:
          repsvc, _ = field.split(':', 1)
          if repsvc in fullDataServices:
            parameters.add(field)
          else:
            invalidChoiceExit(repsvc, fullDataServices, True)
        else:
          Cmd.Backup()
          invalidArgumentExit('service:parameter')
    elif myarg == 'skipdates':
      for skip in getString(Cmd.OB_STRING).upper().split(','):
        if skip.find(':') == -1:
          skipDates.add(validateYYYYMMDD(skip))
        else:
          skipStart, skipEnd = skip.split(':', 1)
          skipStartDate = validateYYYYMMDD(skipStart)
          skipEndDate = validateYYYYMMDD(skipEnd)
          if skipEndDate < skipStartDate:
            Cmd.Backup()
            usageErrorExit(Msg.INVALID_DATE_TIME_RANGE.format(myarg, skipEnd, myarg, skipStart))
          while skipStartDate <= skipEndDate:
            skipDates.add(skipStartDate)
            skipStartDate += oneDay
    elif myarg == 'skipdaysofweek':
      skipdaynames = getString(Cmd.OB_STRING).split(',')
      dow = [d.lower() for d in calendarlib.day_abbr]
      skipDayNumbers = [dow.index(d) for d in skipdaynames if d in dow]
    elif userReports and myarg == 'user':
      userKey = getString(Cmd.OB_EMAIL_ADDRESS)
      orgUnit = orgUnitId = None
      select = False
    elif userReports and (myarg == 'select' or myarg in usageEntitySelectors()):
      if myarg != 'select':
        Cmd.Backup()
      _, users = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)
      orgUnit = orgUnitId = None
      select = True
    elif myarg == 'convertmbtogb':
      convertMbToGb = True
    else:
      unknownArgumentExit()
  if startEndTime.endDateTime is None:
    startEndTime.endDateTime = todaysDate()
  if startEndTime.startDateTime is None:
    startEndTime.startDateTime = startEndTime.endDateTime+datetime.timedelta(days=-30)
  startDateTime = startEndTime.startDateTime
  startDate = startDateTime.strftime(YYYYMMDD_FORMAT)
  endDateTime = startEndTime.endDateTime
  endDate = endDateTime.strftime(YYYYMMDD_FORMAT)
  startUseDate = endUseDate = None
  if not orgUnitId:
    showOrgUnit = False
  if userReports:
    if select:
      Ent.SetGetting(Ent.REPORT)
      kwargs = [{'userKey': normalizeEmailAddressOrUID(user)} for user in users]
    elif userKey == 'all':
      if orgUnitId:
        kwargs[0]['orgUnitID'] = orgUnitId
        userOrgUnits = getUserOrgUnits(cd, orgUnit, orgUnitId)
        forWhom = f'users in orgUnit {orgUnit}'
      else:
        forWhom = 'all users'
      printGettingEntityItemForWhom(Ent.REPORT, forWhom)
    else:
      Ent.SetGetting(Ent.REPORT)
      kwargs = [{'userKey': normalizeEmailAddressOrUID(userKey)}]
      printGettingEntityItemForWhom(Ent.REPORT, kwargs[0]['userKey'])
    if showOrgUnit:
      titles.append('orgUnitPath')
  else:
    pageMessage = None
  csvPF.SetTitles(titles)
  csvPF.SetSortAllTitles()
  parameters = ','.join(parameters) if parameters else None
  while startDateTime <= endDateTime:
    if startDateTime.weekday() in skipDayNumbers or startDateTime in skipDates:
      startDateTime += oneDay
      continue
    useDate = startDateTime.strftime(YYYYMMDD_FORMAT)
    startDateTime += oneDay
    try:
      for kwarg in kwargs:
        if userReports:
          if not select and userKey == 'all':
            pageMessage = getPageMessageForWhom(forWhom, showDate=useDate)
          else:
            pageMessage = getPageMessageForWhom(kwarg['userKey'], showDate=useDate)
        try:
          usage = callGAPIpages(service, 'get', 'usageReports',
                                pageMessage=pageMessage,
                                throwReasons=[GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.FORBIDDEN],
                                retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                customerId=customerId, date=useDate,
                                parameters=parameters, **kwarg)
        except GAPI.badRequest:
          continue
        for entity in usage:
          row = {'date': useDate}
          if userReports:
            if 'userEmail' in entity['entity']:
              row['user'] = entity['entity']['userEmail']
              if showOrgUnit:
                row['orgUnitPath'] = userOrgUnits.get(row['user'], UNKNOWN)
            else:
              row['user'] = UNKNOWN
          for item in entity.get('parameters', []):
            if 'name' not in item:
              continue
            name = item['name']
            if name == 'cros:device_version_distribution':
              versions = {}
              for version in item['msgValue']:
                versions[version['version_number']] = version['num_devices']
              for k, v in sorted(iter(versions.items()), reverse=True):
                title = f'cros:num_devices_chrome_{k}'
                row[title] = v
            else:
              for ptype in REPORTS_PARAMETERS_SIMPLE_TYPES:
                if ptype in item:
                  if ptype != 'datetimeValue':
                    if convertMbToGb and name.endswith('_in_mb'):
                      name = convertReportMBtoGB(name, item)
                    row[name] = item[ptype]
                  else:
                    row[name] = formatLocalTime(item[ptype])
                  break
              else:
                row[name] = ''
          if not startUseDate:
            startUseDate = useDate
          endUseDate = useDate
          csvPF.WriteRowTitles(row)
    except GAPI.invalid as e:
      stderrWarningMsg(str(e))
      break
    except GAPI.invalidInput as e:
      systemErrorExit(GOOGLE_API_ERROR_RC, str(e))
    except GAPI.forbidden as e:
      accessErrorExit(None, str(e))
  if startUseDate:
    reportName = f'{report.capitalize()} Usage Report - {startUseDate}:{endUseDate}'
  else:
    reportName = f'{report.capitalize()} Usage Report - {startDate}:{endDate} - No Data'
  csvPF.writeCSVfile(reportName)

NL_SPACES_PATTERN = re.compile(r'\n +')
DISABLED_REASON_TIME_PATTERN = re.compile(r'.*(\d{4}/\d{2}/\d{2}-\d{2}:\d{2}:\d{2})')

REPORT_CHOICE_MAP = {
  'access': 'access_transparency',
  'accesstransparency': 'access_transparency',
  'admin': 'admin',
  'calendar': 'calendar',
  'calendars': 'calendar',
  'chat': 'chat',
  'chrome': 'chrome',
  'contextawareaccess': 'context_aware_access',
  'customer': 'customer',
  'customers': 'customer',
  'datastudio': 'data_studio',
  'devices': 'mobile',
  'doc': 'drive',
  'docs': 'drive',
  'domain': 'customer',
  'drive': 'drive',
  'enterprisegroups': 'groups_enterprise',
  'gcp': 'gcp',
  'gemini': 'gemini_for_workspace',
  'geminiforworkspace': 'gemini_for_workspace',
  'gplus': 'gplus',
  'google+': 'gplus',
  'group': 'groups',
  'groups': 'groups',
  'groupsenterprise': 'groups_enterprise',
  'hangoutsmeet': 'meet',
  'jamboard': 'jamboard',
  'keep': 'keep',
  'login': 'login',
  'logins': 'login',
  'lookerstudio': 'data_studio',
  'meet': 'meet',
  'mobile': 'mobile',
  'oauthtoken': 'token',
  'rules': 'rules',
  'saml': 'saml',
  'token': 'token',
  'tokens': 'token',
  'usage': 'usage',
  'usageparameters': 'usageparameters',
  'user': 'user',
  'users': 'user',
  'useraccounts': 'user_accounts',
  'vault': 'vault',
  }

REPORT_ACTIVITIES_UPPERCASE_EVENTS = {
  'access_transparency',
  'admin',
  'chrome',
  'context_aware_access',
  'data_studio',
  'gcp',
  'jamboard',
  'mobile'
  }

REPORT_ACTIVITIES_TIME_OBJECTS = {'time'}

# gam report <ActivityApplictionName> [todrive <ToDriveAttribute>*]
#	[(user all|<UserItem>)|(orgunit|org|ou <OrgUnitPath> [showorgunit])|(select <UserTypeEntity>)]
#	[([start <Time>] [end <Time>])|(range <Time> <Time>)|
#	 yesterday|today|thismonth|(previousmonths <Integer>)]
#	[filtertime.* <Time>] [filter|filters <String>]
#	[event|events <EventNameList>] [ip <String>]
#	[groupidfilter <String>]
#	[maxactivities <Number>] [maxevents <Number>] [maxresults <Number>]
#	[countsonly [bydate|summary] [eventrowfilter]]
#	(addcsvdata <FieldName> <String>)* [shownoactivities]
# gam report users|user [todrive <ToDriveAttribute>*]
#	[(user all|<UserItem>)|(orgunit|org|ou <OrgUnitPath> [showorgunit])|(select <UserTypeEntity>)]
#	[allverifyuser <UserItem>]
#	[(date <Date>)|(range <Date> <Date>)|
#	 yesterday|today|thismonth|(previousmonths <Integer>)]
#	[nodatechange | (fulldatarequired all|<UserServiceNameList>)]
#	[filtertime.* <Time>] [filter|filters <String>]
#	[(fields|parameters <String>)|(services <UserServiceNameList>)]
#	[aggregatebydate|aggregatebyuser [Boolean]]
#	[maxresults <Number>]
#	[convertmbtogb]
# gam report customers|customer|domain [todrive <ToDriveAttribute>*]
#	[(date <Date>)|(range <Date> <Date>)|
#	 yesterday|today|thismonth|(previousmonths <Integer>)]
#	[nodatechange | (fulldatarequired all|<CustomerServiceNameList>)]
#	[(fields|parameters <String>)|(services <CustomerServiceNameList>)] [noauthorizedapps]
#	[convertmbtogb]
def doReport():
  def processUserUsage(usage, lastDate):
    if not usage:
      return (True, lastDate)
    if lastDate == usage[0]['date']:
      return (False, lastDate)
    lastDate = usage[0]['date']
    for user_report in usage:
      if 'entity' not in user_report:
        continue
      row = {'date': user_report['date']}
      if 'userEmail' in user_report['entity']:
        row['email'] = user_report['entity']['userEmail']
        if showOrgUnit:
          row['orgUnitPath'] = userOrgUnits.get(row['email'], UNKNOWN)
      else:
        row['email'] = UNKNOWN
      for item in user_report.get('parameters', []):
        if 'name' not in item:
          continue
        name = item['name']
        repsvc, _ = name.split(':', 1)
        if repsvc not in includeServices:
          continue
        if name == 'accounts:disabled_reason' and 'stringValue' in item:
          mg = DISABLED_REASON_TIME_PATTERN.match(item['stringValue'])
          if mg:
            try:
              disabledTime = formatLocalTime(datetime.datetime.strptime(mg.group(1), '%Y/%m/%d-%H:%M:%S').replace(tzinfo=iso8601.UTC).strftime(YYYYMMDDTHHMMSSZ_FORMAT))
              row['accounts:disabled_time'] = disabledTime
              csvPF.AddTitles('accounts:disabled_time')
            except ValueError:
              pass
        elif convertMbToGb and name.endswith('_in_mb'):
          name = convertReportMBtoGB(name, item)
        csvPF.AddTitles(name)
        for ptype in REPORTS_PARAMETERS_SIMPLE_TYPES:
          if ptype in item:
            if ptype != 'datetimeValue':
              row[name] = item[ptype]
            else:
              row[name] = formatLocalTime(item[ptype])
            break
        else:
          row[name] = ''
      csvPF.WriteRow(row)
    return (True, lastDate)

  def processAggregateUserUsageByUser(usage, lastDate):
    if not usage:
      return (True, lastDate)
    if lastDate == usage[0]['date']:
      return (False, lastDate)
    lastDate = usage[0]['date']
    for user_report in usage:
      if 'entity' not in user_report:
        continue
      if 'userEmail' not in user_report['entity']:
        continue
      email = user_report['entity']['userEmail']
      for item in user_report.get('parameters', []):
        if 'name' not in item:
          continue
        name = item['name']
        repsvc, _ = name.split(':', 1)
        if repsvc not in includeServices:
          continue
        if 'intValue' in item:
          if convertMbToGb and name.endswith('_in_mb'):
            name = convertReportMBtoGB(name, None)
          csvPF.AddTitles(name)
          eventCounts.setdefault(email, {})
          eventCounts[email].setdefault(name, 0)
          eventCounts[email][name] += int(item['intValue'])
    return (True, lastDate)

  def processAggregateUserUsageByDate(usage, lastDate):
    if not usage:
      return (True, lastDate)
    if lastDate == usage[0]['date']:
      return (False, lastDate)
    lastDate = usage[0]['date']
    for user_report in usage:
      if 'entity' not in user_report:
        continue
      for item in user_report.get('parameters', []):
        if 'name' not in item:
          continue
        name = item['name']
        repsvc, _ = name.split(':', 1)
        if repsvc not in includeServices:
          continue
        if 'intValue' in item:
          if convertMbToGb and name.endswith('_in_mb'):
            name = convertReportMBtoGB(name, None)
          csvPF.AddTitles(name)
          eventCounts.setdefault(lastDate, {})
          eventCounts[lastDate].setdefault(name, 0)
          eventCounts[lastDate][name] += int(item['intValue'])
    return (True, lastDate)

  def processCustomerUsageOneRow(usage, lastDate):
    if not usage:
      return (True, lastDate)
    if lastDate == usage[0]['date']:
      return (False, lastDate)
    lastDate = usage[0]['date']
    row = {'date': lastDate}
    for item in usage[0].get('parameters', []):
      if 'name' not in item:
        continue
      name = item['name']
      repsvc, _ = name.split(':', 1)
      if repsvc not in includeServices:
        continue
      for ptype in REPORTS_PARAMETERS_SIMPLE_TYPES:
        if ptype in item:
          if convertMbToGb and name.endswith('_in_mb'):
            name = convertReportMBtoGB(name, item)
          csvPF.AddTitles(name)
          if ptype != 'datetimeValue':
            row[name] = item[ptype]
          else:
            row[name] = formatLocalTime(item[ptype])
          break
      else:
        if 'msgValue' in item:
          if name == 'accounts:authorized_apps':
            if noAuthorizedApps:
              continue
            for app in item['msgValue']:
              appName = f'App: {escapeCRsNLs(app["client_name"])}'
              for key in ['num_users', 'client_id']:
                title = f'{appName}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{key}'
                csvPF.AddTitles(title)
                row[title] = app[key]
          elif name == 'cros:device_version_distribution':
            versions = {}
            for version in item['msgValue']:
              versions[version['version_number']] = version['num_devices']
            for k, v in sorted(iter(versions.items()), reverse=True):
              title = f'cros:device_version{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{k}'
              csvPF.AddTitles(title)
              row[title] = v
          else:
            values = []
            for subitem in item['msgValue']:
              if 'count' in subitem:
                mycount = myvalue = None
                for key, value in iter(subitem.items()):
                  if key == 'count':
                    mycount = value
                  else:
                    myvalue = value
                  if mycount and myvalue:
                    values.append(f'{myvalue}:{mycount}')
                value = ' '.join(values)
              elif 'version_number' in subitem and 'num_devices' in subitem:
                values.append(f'{subitem["version_number"]}:{subitem["num_devices"]}')
              else:
                continue
              value = ' '.join(sorted(values, reverse=True))
            csvPF.AddTitles(name)
            row['name'] = value
    csvPF.WriteRow(row)
    return (True, lastDate)

  def processCustomerUsage(usage, lastDate):
    if not usage:
      return (True, lastDate)
    if lastDate == usage[0]['date']:
      return (False, lastDate)
    lastDate = usage[0]['date']
    for item in usage[0].get('parameters', []):
      if 'name' not in item:
        continue
      name = item['name']
      repsvc, _ = name.split(':', 1)
      if repsvc not in includeServices:
        continue
      for ptype in REPORTS_PARAMETERS_SIMPLE_TYPES:
        if ptype in item:
          if ptype != 'datetimeValue':
            if convertMbToGb and name.endswith('_in_mb'):
              name = convertReportMBtoGB(name, item)
            csvPF.WriteRow({'date': lastDate, 'name': name, 'value': item[ptype]})
          else:
            csvPF.WriteRow({'date': lastDate, 'name': name, 'value': formatLocalTime(item[ptype])})
          break
      else:
        if 'msgValue' in item:
          if name == 'accounts:authorized_apps':
            if noAuthorizedApps:
              continue
            for subitem in item['msgValue']:
              app = {'date': lastDate}
              for an_item in subitem:
                if an_item == 'client_name':
                  app['name'] = f'App: {escapeCRsNLs(subitem[an_item])}'
                elif an_item == 'num_users':
                  app['value'] = f'{subitem[an_item]} users'
                elif an_item == 'client_id':
                  app['client_id'] = subitem[an_item]
              authorizedApps.append(app)
          elif name == 'cros:device_version_distribution':
            values = []
            for subitem in item['msgValue']:
              values.append(f'{subitem["version_number"]}:{subitem["num_devices"]}')
            csvPF.WriteRow({'date': lastDate, 'name': name, 'value': ' '.join(sorted(values, reverse=True))})
          else:
            values = []
            for subitem in item['msgValue']:
              if 'count' in subitem:
                mycount = myvalue = None
                for key, value in iter(subitem.items()):
                  if key == 'count':
                    mycount = value
                  else:
                    myvalue = value
                  if mycount and myvalue:
                    values.append(f'{myvalue}:{mycount}')
              else:
                continue
            csvPF.WriteRow({'date': lastDate, 'name': name, 'value': ' '.join(sorted(values, reverse=True))})
    csvPF.SortRowsTwoTitles('date', 'name', False)
    if authorizedApps:
      csvPF.AddTitle('client_id')
      for row in sorted(authorizedApps, key=lambda k: (k['date'], k['name'].lower())):
        csvPF.WriteRow(row)
    return (True, lastDate)

  # dynamically extend our choices with other reports Google dynamically adds
  rep = buildGAPIObject(API.REPORTS)
  dyn_choices = rep._rootDesc \
          .get('resources', {}) \
          .get('activities', {}) \
          .get('methods', {}) \
          .get('list', {}) \
          .get('parameters', {}) \
          .get('applicationName', {}) \
          .get('enum', [])
  for dyn_choice in dyn_choices:
    if dyn_choice.replace('_', '') not in REPORT_CHOICE_MAP and \
       dyn_choice not in REPORT_CHOICE_MAP.values():
      REPORT_CHOICE_MAP[dyn_choice.replace('_', '')] = dyn_choice
  report = getChoice(REPORT_CHOICE_MAP, mapChoice=True)
  if report == 'usage':
    doReportUsage()
    return
  if report == 'usageparameters':
    doReportUsageParameters()
    return
  customerId = GC.Values[GC.CUSTOMER_ID]
  if customerId == GC.MY_CUSTOMER:
    customerId = None
  csvPF = CSVPrintFile()
  filters = actorIpAddress = groupIdFilter = orgUnit = orgUnitId = None
  showOrgUnit = False
  parameters = set()
  parameterServices = set()
  eventCounts = {}
  eventNames = []
  startEndTime = StartEndTime('start', 'end')
  oneDay = datetime.timedelta(days=1)
  filterTimes = {}
  maxActivities = maxEvents = 0
  maxResults = 1000
  aggregateByDate = aggregateByUser = convertMbToGb = countsOnly = countsByDate = countsSummary = \
    eventRowFilter = exitUserLoop = noAuthorizedApps = normalizeUsers = select = userCustomerRange = False
  limitDateChanges = -1
  allVerifyUser = userKey = 'all'
  cd = orgUnit = orgUnitId = None
  userOrgUnits = {}
  customerReports = userReports = False
  if report == 'customer':
    customerReports = True
    service = rep.customerUsageReports()
    fullDataServices = CUSTOMER_REPORT_SERVICES
  elif report == 'user':
    userReports = True
    service = rep.userUsageReport()
    fullDataServices = USER_REPORT_SERVICES
  else:
    service = rep.activities()
  usageReports = customerReports or userReports
  activityReports = not usageReports
  dataRequiredServices = set()
  addCSVData = {}
  showNoActivities = False
  if usageReports:
    includeServices = set()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'range', 'thismonth', 'previousmonths'}:
      startEndTime.Get(myarg)
      userCustomerRange = True
    elif myarg in {'ou', 'org', 'orgunit'}:
      if cd is None:
        cd = buildGAPIObject(API.DIRECTORY)
      orgUnit, orgUnitId = getOrgUnitId(cd)
      select = False
    elif myarg == 'showorgunit':
      showOrgUnit = True
    elif usageReports and myarg in {'date', 'yesterday', 'today'}:
      startEndTime.Get('start' if myarg == 'date' else myarg)
      startEndTime.endDateTime = startEndTime.startDateTime
      userCustomerRange = False
    elif usageReports and myarg in {'nodatechange', 'limitdatechanges'}:
      if myarg == 'nodatechange':
        limitDateChanges = 0
      else:
        limitDateChanges = getInteger(minVal=-1)
      if (limitDateChanges == 0) and (startEndTime.startDateTime is not None) and (startEndTime.endDateTime == startEndTime.startDateTime):
        userCustomerRange = True
    elif usageReports and myarg in {'fields', 'parameters'}:
      for field in getString(Cmd.OB_STRING).replace(',', ' ').split():
        if ':' in field:
          repsvc, _ = field.split(':', 1)
          if repsvc in fullDataServices:
            parameters.add(field)
            parameterServices.add(repsvc)
            includeServices.add(repsvc)
          else:
            invalidChoiceExit(repsvc, fullDataServices, True)
        else:
          Cmd.Backup()
          invalidArgumentExit('service:parameter')
    elif usageReports and myarg == 'fulldatarequired':
      fdr = getString(Cmd.OB_SERVICE_NAME_LIST, minLen=0).lower()
      if fdr:
        if fdr != 'all':
          for repsvc in fdr.replace(',', ' ').split():
            if repsvc in fullDataServices:
              dataRequiredServices.add(repsvc)
            else:
              invalidChoiceExit(repsvc, fullDataServices, True)
        else:
          dataRequiredServices = fullDataServices
    elif usageReports and myarg in {'service', 'services'}:
      for repsvc in getString(Cmd.OB_SERVICE_NAME_LIST).lower().replace(',', ' ').split():
        if repsvc in fullDataServices:
          parameterServices.add(repsvc)
          includeServices.add(repsvc)
        else:
          invalidChoiceExit(repsvc, fullDataServices, True)
    elif usageReports and myarg == 'convertmbtogb':
      convertMbToGb = True
    elif customerReports and myarg == 'noauthorizedapps':
      noAuthorizedApps = True
    elif activityReports and myarg == 'maxactivities':
      maxActivities = getInteger(minVal=0)
    elif activityReports and myarg == 'maxevents':
      maxEvents = getInteger(minVal=0)
    elif activityReports and myarg in {'start', 'starttime', 'end', 'endtime', 'yesterday', 'today'}:
      startEndTime.Get(myarg)
    elif activityReports and myarg in {'event', 'events'}:
      for event in getString(Cmd.OB_EVENT_NAME_LIST).replace(',', ' ').split():
        event = event.lower() if report not in REPORT_ACTIVITIES_UPPERCASE_EVENTS else event.upper()
        if event not in eventNames:
          eventNames.append(event)
    elif activityReports and myarg == 'ip':
      actorIpAddress = getString(Cmd.OB_STRING)
    elif activityReports and myarg == 'countsonly':
      countsOnly = True
    elif activityReports and myarg == 'bydate':
      countsByDate = True
    elif activityReports and myarg == 'summary':
      countsSummary = True
    elif activityReports and myarg == 'eventrowfilter':
      eventRowFilter = True
    elif activityReports and myarg == 'groupidfilter':
      groupIdFilter = getString(Cmd.OB_STRING)
    elif activityReports and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    elif activityReports and myarg == 'shownoactivities':
      showNoActivities = True
    elif not customerReports and myarg.startswith('filtertime'):
      filterTimes[myarg] = getTimeOrDeltaFromNow()
    elif not customerReports and myarg in {'filter', 'filters'}:
      filters = getString(Cmd.OB_STRING)
    elif not customerReports and myarg == 'maxresults':
      maxResults = getInteger(minVal=1, maxVal=1000)
    elif not customerReports and myarg == 'user':
      userKey = getString(Cmd.OB_EMAIL_ADDRESS)
      orgUnit = orgUnitId = None
      select = False
    elif not customerReports and myarg == 'select':
      _, users = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)
      orgUnit = orgUnitId = None
      select = True
    elif userReports and myarg == 'aggregatebydate':
      aggregateByDate = getBoolean()
    elif userReports and myarg == 'aggregatebyuser':
      aggregateByUser = getBoolean()
    elif userReports and myarg == 'allverifyuser':
      allVerifyUser = getEmailAddress()
    else:
      unknownArgumentExit()
  if aggregateByDate and aggregateByUser:
    usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format('aggregateByDate', 'aggregateByUser'))
  if countsOnly and countsByDate and countsSummary:
    usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format('bydate', 'summary'))
  parameters = ','.join(parameters) if parameters else None
  if usageReports and not includeServices:
    includeServices = set(fullDataServices)
  if filterTimes and filters is not None:
    for filterTimeName, filterTimeValue in iter(filterTimes.items()):
      filters = filters.replace(f'#{filterTimeName}#', filterTimeValue)
  if not orgUnitId:
    showOrgUnit = False
  if userReports:
    if startEndTime.startDateTime is None:
      startEndTime.startDateTime = startEndTime.endDateTime = todaysDate()
    if select:
      normalizeUsers = True
      orgUnitId = None
      Ent.SetGetting(Ent.REPORT)
    elif userKey == 'all':
      if orgUnitId:
        if showOrgUnit:
          userOrgUnits = getUserOrgUnits(cd, orgUnit, orgUnitId)
        forWhom = f'users in orgUnit {orgUnit}'
      else:
        forWhom = 'all users'
      printGettingEntityItemForWhom(Ent.REPORT, forWhom)
      users = ['all']
    else:
      Ent.SetGetting(Ent.REPORT)
      users = [normalizeEmailAddressOrUID(userKey)]
      orgUnitId = None
    if aggregateByDate:
      titles = ['date']
    elif aggregateByUser:
      titles = ['email'] if not showOrgUnit else ['email', 'orgUnitPath']
    else:
      titles = ['email', 'date'] if not showOrgUnit else ['email', 'orgUnitPath', 'date']
    csvPF.SetTitles(titles)
    csvPF.SetSortAllTitles()
    i = 0
    count = len(users)
    for user in users:
      i += 1
      if normalizeUsers:
        user = normalizeEmailAddressOrUID(user)
      if user != 'all':
        printGettingEntityItemForWhom(Ent.REPORT, user, i, count)
        verifyUser = user
      else:
        verifyUser = allVerifyUser
      startDateTime = startEndTime.startDateTime
      endDateTime = startEndTime.endDateTime
      lastDate = None
      numDateChanges = 0
      while startDateTime <= endDateTime:
        tryDate = startDateTime.strftime(YYYYMMDD_FORMAT)
        try:
          if not userCustomerRange:
            result = callGAPI(service, 'get',
                              throwReasons=[GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.FORBIDDEN],
                              userKey=verifyUser, date=tryDate, customerId=customerId,
                              orgUnitID=orgUnitId, parameters=parameters,
                              fields='warnings,usageReports', maxResults=1)
            prevTryDate = tryDate
            fullData, tryDate, usageReports = _checkDataRequiredServices(result, tryDate,
                                                                         dataRequiredServices, parameterServices, True)
            if fullData < 0:
              printWarningMessage(DATA_NOT_AVALIABLE_RC, Msg.NO_REPORT_AVAILABLE.format(report))
              break
            numDateChanges += 1
            if fullData == 0:
              if numDateChanges > limitDateChanges >= 0:
                break
              startDateTime = endDateTime = datetime.datetime.strptime(tryDate, YYYYMMDD_FORMAT)
              continue
          if not select and userKey == 'all':
            pageMessage = getPageMessageForWhom(forWhom, showDate=tryDate)
          else:
            pageMessage = getPageMessageForWhom(user, showDate=tryDate)
          prevTryDate = tryDate
          usage = callGAPIpages(service, 'get', 'usageReports',
                                pageMessage=pageMessage,
                                throwReasons=[GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.FORBIDDEN],
                                retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                userKey=user, date=tryDate, customerId=customerId,
                                orgUnitID=orgUnitId, filters=filters, parameters=parameters,
                                maxResults=maxResults)
          if aggregateByDate:
            status, lastDate = processAggregateUserUsageByDate(usage, lastDate)
          elif aggregateByUser:
            status, lastDate = processAggregateUserUsageByUser(usage, lastDate)
          else:
            status, lastDate = processUserUsage(usage, lastDate)
          if not status:
            break
        except GAPI.invalid as e:
          if userCustomerRange:
            break
          numDateChanges += 1
          tryDate = _adjustTryDate(str(e), numDateChanges, limitDateChanges, tryDate)
          if not tryDate:
            break
          startDateTime = endDateTime = datetime.datetime.strptime(tryDate, YYYYMMDD_FORMAT)
          continue
        except GAPI.invalidInput as e:
          systemErrorExit(GOOGLE_API_ERROR_RC, str(e))
        except GAPI.badRequest:
          if user != 'all':
            entityUnknownWarning(Ent.USER, user, i, count)
          else:
            printErrorMessage(BAD_REQUEST_RC, Msg.BAD_REQUEST)
            exitUserLoop = True
          break
        except GAPI.forbidden as e:
          accessErrorExit(None, str(e))
        startDateTime += oneDay
      if exitUserLoop:
        break
      if user != 'all' and lastDate is None and GC.Values[GC.CSV_OUTPUT_USERS_AUDIT]:
        csvPF.WriteRowNoFilter({'date': prevTryDate, 'email': user})
    if aggregateByDate:
      for usageDate, events in iter(eventCounts.items()):
        row = {'date': usageDate}
        for event, count in iter(events.items()):
          if convertMbToGb and event.endswith('_in_gb'):
            count = f'{count/1024:.2f}'
          row[event] = count
        csvPF.WriteRow(row)
      csvPF.SortRows('date', False)
      csvPF.writeCSVfile(f'User Reports Aggregate - {tryDate}')
    elif aggregateByUser:
      for email, events in iter(eventCounts.items()):
        row = {'email': email}
        if showOrgUnit:
          row['orgUnitPath'] = userOrgUnits.get(email, UNKNOWN)
        for event, count in iter(events.items()):
          if convertMbToGb and event.endswith('_in_gb'):
            count = f'{count/1024:.2f}'
          row[event] = count
        csvPF.WriteRow(row)
      csvPF.SortRows('email', False)
      csvPF.writeCSVfile('User Reports Aggregate - User')
    else:
      csvPF.SortRowsTwoTitles('email', 'date', False)
      csvPF.writeCSVfile(f'User Reports - {tryDate}')
  elif customerReports:
    if startEndTime.startDateTime is None:
      startEndTime.startDateTime = startEndTime.endDateTime = todaysDate()
    csvPF.SetTitles('date')
    if not userCustomerRange or (startEndTime.startDateTime == startEndTime.endDateTime):
      csvPF.AddTitles(['name', 'value'])
    authorizedApps = []
    startDateTime = startEndTime.startDateTime
    endDateTime = startEndTime.endDateTime
    lastDate = None
    numDateChanges = 0
    while startDateTime <= endDateTime:
      tryDate = startDateTime.strftime(YYYYMMDD_FORMAT)
      try:
        if not userCustomerRange:
          result = callGAPI(service, 'get',
                            throwReasons=[GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.FORBIDDEN],
                            date=tryDate, customerId=customerId, parameters=parameters, fields='warnings,usageReports')
          fullData, tryDate, usageReports = _checkDataRequiredServices(result, tryDate,
                                                                       dataRequiredServices, parameterServices)
          if fullData < 0:
            printWarningMessage(DATA_NOT_AVALIABLE_RC, Msg.NO_REPORT_AVAILABLE.format(report))
            break
          numDateChanges += 1
          if fullData == 0:
            if numDateChanges > limitDateChanges >= 0:
              break
            startDateTime = endDateTime = datetime.datetime.strptime(tryDate, YYYYMMDD_FORMAT)
            continue
        usage = callGAPIpages(service, 'get', 'usageReports',
                              throwReasons=[GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.FORBIDDEN],
                              date=tryDate, customerId=customerId, parameters=parameters)
        if not userCustomerRange or (startEndTime.startDateTime == startEndTime.endDateTime):
          status, lastDate = processCustomerUsage(usage, lastDate)
        else:
          status, lastDate = processCustomerUsageOneRow(usage, lastDate)
        if not status:
          break
      except GAPI.invalid as e:
        if userCustomerRange:
          break
        numDateChanges += 1
        tryDate = _adjustTryDate(str(e), numDateChanges, limitDateChanges, tryDate)
        if not tryDate:
          break
        startDateTime = endDateTime = datetime.datetime.strptime(tryDate, YYYYMMDD_FORMAT)
        continue
      except GAPI.invalidInput as e:
        systemErrorExit(GOOGLE_API_ERROR_RC, str(e))
      except GAPI.forbidden as e:
        accessErrorExit(None, str(e))
      startDateTime += oneDay
    csvPF.writeCSVfile(f'Customer Report - {tryDate}')
  else: # activityReports
    csvPF.SetTitles('name')
    if addCSVData:
      csvPF.AddTitles(sorted(addCSVData.keys()))
    if select:
      pageMessage = None
      normalizeUsers = True
      orgUnitId = None
    elif userKey == 'all':
      if orgUnitId:
        if showOrgUnit:
          userOrgUnits = getUserOrgUnits(cd, orgUnit, orgUnitId)
        printGettingEntityItemForWhom(Ent.REPORT, f'users in orgUnit {orgUnit}')
      else:
        printGettingEntityItemForWhom(Ent.REPORT, 'all users')
      pageMessage = getPageMessage()
      users = ['all']
    else:
      Ent.SetGetting(Ent.ACTIVITY)
      pageMessage = getPageMessage()
      users = [normalizeEmailAddressOrUID(userKey)]
      orgUnitId = None
    zeroEventCounts = {}
    if not eventNames:
      eventNames.append(None)
    else:
      for eventName in eventNames:
        zeroEventCounts[eventName] = 0
    i = 0
    count = len(users)
    for user in users:
      i += 1
      if normalizeUsers:
        user = normalizeEmailAddressOrUID(user)
      if select or user != 'all':
        printGettingEntityItemForWhom(Ent.ACTIVITY, user, i, count)
      for eventName in eventNames:
        try:
          feed = callGAPIpages(service, 'list', 'items',
                               pageMessage=pageMessage, maxItems=maxActivities,
                               throwReasons=[GAPI.BAD_REQUEST, GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.AUTH_ERROR, GAPI.SERVICE_NOT_AVAILABLE],
                               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                               applicationName=report, userKey=user, customerId=customerId,
                               actorIpAddress=actorIpAddress, orgUnitID=orgUnitId,
                               startTime=startEndTime.startTime, endTime=startEndTime.endTime,
                               eventName=eventName, filters=filters, groupIdFilter=groupIdFilter,
                               maxResults=maxResults)
        except GAPI.badRequest:
          if user != 'all':
            entityUnknownWarning(Ent.USER, user, i, count)
            continue
          printErrorMessage(BAD_REQUEST_RC, Msg.BAD_REQUEST)
          break
        except (GAPI.invalid, GAPI.invalidInput, GAPI.serviceNotAvailable) as e:
          systemErrorExit(GOOGLE_API_ERROR_RC, str(e))
        except GAPI.authError:
          accessErrorExit(None)
        for activity in feed:
          events = activity.pop('events')
          actor = activity['actor'].get('email')
          if not actor:
            actor = 'id:'+activity['actor'].get('profileId', UNKNOWN)
          if showOrgUnit:
            activity['actor']['orgUnitPath'] = userOrgUnits.get(actor, UNKNOWN)
          if countsOnly and countsByDate:
            eventTime = activity.get('id', {}).get('time', UNKNOWN)
            if eventTime != UNKNOWN:
              try:
                eventTime, _ = iso8601.parse_date(eventTime)
              except (iso8601.ParseError, OverflowError):
                eventTime = UNKNOWN
            if eventTime != UNKNOWN:
              eventDate = eventTime.strftime(YYYYMMDD_FORMAT)
            else:
              eventDate = UNKNOWN
          if not countsOnly or eventRowFilter:
            activity_row = flattenJSON(activity, timeObjects=REPORT_ACTIVITIES_TIME_OBJECTS)
            purge_parameters = True
            numEvents = 0
            for event in events:
              numEvents += 1
              for item in event.get('parameters', []):
                itemSet = set(item)
                if not itemSet.symmetric_difference({'name'}):
                  event[item['name']] = ''
                elif not itemSet.symmetric_difference({'value', 'name'}):
                  event[item['name']] = NL_SPACES_PATTERN.sub('', item['value'])
                elif not itemSet.symmetric_difference({'intValue', 'name'}):
                  if item['name'] in {'start_time', 'end_time'}:
                    val = item.get('intValue')
                    if val is not None:
                      val = int(val)
                      if val >= 62135683200:
                        event[item['name']] = ISOformatTimeStamp(datetime.datetime.fromtimestamp(val-62135683200, GC.Values[GC.TIMEZONE]))
                      else:
                        event[item['name']] = val
                  else:
                    event[item['name']] = item['intValue']
                elif not itemSet.symmetric_difference({'boolValue', 'name'}):
                  event[item['name']] = item['boolValue']
                elif not itemSet.symmetric_difference({'multiValue', 'name'}):
                  event[item['name']] = ' '.join(item['multiValue'])
                elif item['name'] == 'scope_data':
                  parts = {}
                  for message in item['multiMessageValue']:
                    for mess in message['parameter']:
                      value = mess.get('value', ' '.join(mess.get('multiValue', [])))
                      parts[mess['name']] = parts.get(mess['name'], [])+[value]
                  for part, v in iter(parts.items()):
                    if part == 'scope_name':
                      part = 'scope'
                    event[part] = ' '.join(v)
                else:
                  purge_parameters = False
              if purge_parameters:
                event.pop('parameters', None)
              row = flattenJSON(event)
              row.update(activity_row)
              if not countsOnly:
                if addCSVData:
                  row.update(addCSVData)
                csvPF.WriteRowTitles(row)
                if numEvents >= maxEvents > 0:
                  break
              elif csvPF.CheckRowTitles(row):
                eventName = event['name']
                if not countsSummary:
                  eventCounts.setdefault(actor, {})
                  if not countsByDate:
                    eventCounts[actor].setdefault(eventName, 0)
                    eventCounts[actor][eventName] += 1
                  else:
                    eventCounts[actor].setdefault(eventDate, {})
                    eventCounts[actor][eventDate].setdefault(eventName, 0)
                    eventCounts[actor][eventDate][eventName] += 1
                else:
                  eventCounts.setdefault(eventName, 0)
                  eventCounts[eventName] += 1
          elif not countsSummary:
            eventCounts.setdefault(actor, {})
            if not countsByDate:
              for event in events:
                eventName = event['name']
                eventCounts[actor].setdefault(eventName, 0)
                eventCounts[actor][eventName] += 1
            else:
              for event in events:
                eventName = event['name']
                eventCounts[actor].setdefault(eventDate, {})
                eventCounts[actor][eventDate].setdefault(eventName, 0)
                eventCounts[actor][eventDate][eventName] += 1
          else:
            for event in events:
              eventCounts.setdefault(event['name'], 0)
              eventCounts[event['name']] += 1
    if not countsOnly:
      if not csvPF.rows and showNoActivities:
        row = {'name': 'NoActivities'}
        if addCSVData:
          row.update(addCSVData)
        csvPF.WriteRowTitles(row)
    else:
      if eventRowFilter:
        csvPF.SetRowFilter([], GC.Values[GC.CSV_OUTPUT_ROW_FILTER_MODE])
        csvPF.SetRowDropFilter([], GC.Values[GC.CSV_OUTPUT_ROW_DROP_FILTER_MODE])
      if not countsSummary:
        titles = ['emailAddress']
        if countsOnly and countsByDate:
          titles.append('date')
        csvPF.SetTitles(titles)
        csvPF.SetSortTitles(titles)
        if addCSVData:
          csvPF.AddTitles(sorted(addCSVData.keys()))
        if eventCounts:
          if not countsByDate:
            for actor, events in iter(eventCounts.items()):
              row = {'emailAddress': actor}
              row.update(zeroEventCounts)
              for event, count in iter(events.items()):
                row[event] = count
              if addCSVData:
                row.update(addCSVData)
              csvPF.WriteRowTitles(row)
          else:
            for actor, eventDates in iter(eventCounts.items()):
              for eventDate, events in iter(eventDates.items()):
                row = {'emailAddress': actor, 'date': eventDate}
                row.update(zeroEventCounts)
                for event, count in iter(events.items()):
                  row[event] = count
                if addCSVData:
                  row.update(addCSVData)
                csvPF.WriteRowTitles(row)
        elif showNoActivities:
          row = {'emailAddress': 'NoActivities'}
          if addCSVData:
            row.update(addCSVData)
            csvPF.WriteRow(row)
      else:
        csvPF.SetTitles(['event', 'count'])
        if addCSVData:
          csvPF.AddTitles(sorted(addCSVData.keys()))
        if eventCounts:
          for event, count in sorted(iter(eventCounts.items())):
            row = {'event': event, 'count': count}
            if addCSVData:
              row.update(addCSVData)
            csvPF.WriteRow(row)
        elif showNoActivities:
          row = {'event': 'NoActivities', 'count': 0}
          if addCSVData:
            row.update(addCSVData)
          csvPF.WriteRow(row)
    csvPF.writeCSVfile(f'{report.capitalize()} Activity Report', eventRowFilter)

# Substitute for #user#, #email#, #usernamne#
def _substituteForUser(field, user, userName):
  if field.find('#') == -1:
    return field
  return field.replace('#user#', user).replace('#email#', user).replace('#username#', userName)

# Tag utilities
TAG_ADDRESS_ARGUMENT_TO_FIELD_MAP = {
  'country': 'country',
  'countrycode': 'countryCode',
  'customtype': 'customType',
  'extendedaddress': 'extendedAddress',
  'formatted': 'formatted',
  'locality': 'locality',
  'pobox': 'poBox',
  'postalcode': 'postalCode',
  'primary': 'primary',
  'region': 'region',
  'streetaddress': 'streetAddress',
  'type': 'type',
  }

TAG_EMAIL_ARGUMENT_TO_FIELD_MAP = {
  'domain': 'domain',
  'primaryemail': 'primaryEmail',
  'username': 'username',
  }

TAG_EXTERNALID_ARGUMENT_TO_FIELD_MAP = {
  'customtype': 'customType',
  'type': 'type',
  'value': 'value',
  }

TAG_GENDER_ARGUMENT_TO_FIELD_MAP = {
  'addressmeas': 'addressMeAs',
  'customgender': 'customGender',
  'type': 'type',
  }

TAG_IM_ARGUMENT_TO_FIELD_MAP = {
  'customprotocol': 'customProtocol',
  'customtype': 'customType',
  'im': 'im',
  'protocol': 'protocol',
  'primary': 'primary',
  'type': 'type',
  }

TAG_KEYWORD_ARGUMENT_TO_FIELD_MAP = {
  'customtype': 'customType',
  'type': 'type',
  'value': 'value',
  }

TAG_LOCATION_ARGUMENT_TO_FIELD_MAP = {
  'area': 'area',
  'buildingid': 'buildingId',
  'buildingname': 'buildingName',
  'customtype': 'customType',
  'deskcode': 'deskCode',
  'floorname': 'floorName',
  'floorsection': 'floorSection',
  'type': 'type',
  }

TAG_NAME_ARGUMENT_TO_FIELD_MAP = {
  'familyname': 'familyName',
  'fullname': 'fullName',
  'givenname': 'givenName',
  }

TAG_ORGANIZATION_ARGUMENT_TO_FIELD_MAP = {
  'costcenter': 'costCenter',
  'costcentre': 'costCenter',
  'customtype': 'customType',
  'department': 'department',
  'description': 'description',
  'domain': 'domain',
  'fulltimeequivalent': 'fullTimeEquivalent',
  'location': 'location',
  'name': 'name',
  'primary': 'primary',
  'symbol': 'symbol',
  'title': 'title',
  'type': 'type',
  }

TAG_OTHEREMAIL_ARGUMENT_TO_FIELD_MAP = {
  'address': 'address',
  'customtype': 'customType',
  'primary': 'primary',
  'type': 'type',
  }

TAG_PHONE_ARGUMENT_TO_FIELD_MAP = {
  'customtype': 'customType',
  'primary': 'primary',
  'type': 'type',
  'value': 'value',
  }

TAG_POSIXACCOUNT_ARGUMENT_TO_FIELD_MAP = {
  'accountid': 'accountId',
  'gecos': 'gecos',
  'gid': 'gid',
  'homedirectory': 'homeDirectory',
  'operatingsystemtype': 'operatingSystemType',
  'primary': 'primary',
  'shell': 'shell',
  'systemid': 'systemId',
  'uid': 'uid',
  'username': 'username',
  }

TAG_RELATION_ARGUMENT_TO_FIELD_MAP = {
  'customtype': 'customType',
  'type': 'type',
  'value': 'value',
  }

TAG_SSHPUBLICKEY_ARGUMENT_TO_FIELD_MAP = {
  'expirationtimeusec': 'expirationTimeUsec',
  'fingerprint': 'fingerprint',
  'key': 'key',
  }

TAG_WEBSITE_ARGUMENT_TO_FIELD_MAP = {
  'customtype': 'customType',
  'primary': 'primary',
  'type': 'type',
  'value': 'value',
  }

TAG_FIELD_SUBFIELD_CHOICE_MAP = {
  'address': ('addresses', TAG_ADDRESS_ARGUMENT_TO_FIELD_MAP),
  'addresses': ('addresses', TAG_ADDRESS_ARGUMENT_TO_FIELD_MAP),
  'email': ('primaryEmail', TAG_EMAIL_ARGUMENT_TO_FIELD_MAP),
  'externalid': ('externalIds', TAG_EXTERNALID_ARGUMENT_TO_FIELD_MAP),
  'externalids': ('externalIds', TAG_EXTERNALID_ARGUMENT_TO_FIELD_MAP),
  'gender': ('gender', TAG_GENDER_ARGUMENT_TO_FIELD_MAP),
  'im': ('ims', TAG_IM_ARGUMENT_TO_FIELD_MAP),
  'ims': ('ims', TAG_IM_ARGUMENT_TO_FIELD_MAP),
  'keyword': ('keywords', TAG_KEYWORD_ARGUMENT_TO_FIELD_MAP),
  'keywords': ('keywords', TAG_KEYWORD_ARGUMENT_TO_FIELD_MAP),
  'location': ('locations', TAG_LOCATION_ARGUMENT_TO_FIELD_MAP),
  'locations': ('locations', TAG_LOCATION_ARGUMENT_TO_FIELD_MAP),
  'name': ('name', TAG_NAME_ARGUMENT_TO_FIELD_MAP),
  'organization': ('organizations', TAG_ORGANIZATION_ARGUMENT_TO_FIELD_MAP),
  'organizations': ('organizations', TAG_ORGANIZATION_ARGUMENT_TO_FIELD_MAP),
  'organisation': ('organizations', TAG_ORGANIZATION_ARGUMENT_TO_FIELD_MAP),
  'organisations': ('organizations', TAG_ORGANIZATION_ARGUMENT_TO_FIELD_MAP),
  'otheremail': ('emails', TAG_OTHEREMAIL_ARGUMENT_TO_FIELD_MAP),
  'otheremails': ('emails', TAG_OTHEREMAIL_ARGUMENT_TO_FIELD_MAP),
  'phone': ('phones', TAG_PHONE_ARGUMENT_TO_FIELD_MAP),
  'phones': ('phones', TAG_PHONE_ARGUMENT_TO_FIELD_MAP),
  'photourl': ('thumbnailPhotoUrl', {'': 'thumbnailPhotoUrl'}),
  'posix': ('posixAccounts', TAG_POSIXACCOUNT_ARGUMENT_TO_FIELD_MAP),
  'posixaccounts': ('posixAccounts', TAG_POSIXACCOUNT_ARGUMENT_TO_FIELD_MAP),
  'relation': ('relations', TAG_RELATION_ARGUMENT_TO_FIELD_MAP),
  'relations': ('relations', TAG_RELATION_ARGUMENT_TO_FIELD_MAP),
  'sshkeys': ('sshPublicKeys', TAG_SSHPUBLICKEY_ARGUMENT_TO_FIELD_MAP),
  'sshpublickeys': ('sshPublicKeys', TAG_SSHPUBLICKEY_ARGUMENT_TO_FIELD_MAP),
  'website': ('websites', TAG_WEBSITE_ARGUMENT_TO_FIELD_MAP),
  'websites': ('websites', TAG_WEBSITE_ARGUMENT_TO_FIELD_MAP),
  }

def _initTagReplacements():
  return {'cd': None, 'tags': {}, 'subs': False,
          'fieldsSet': set(), 'fields': '',
          'schemasSet': set(), 'customFieldMask': None}

def _getTagReplacement(myarg, tagReplacements, allowSubs):
  if myarg == 'replace':
    trregex = None
  elif myarg == 'replaceregex':
    trregex = getREPatternSubstitution(re.IGNORECASE)
  else:
    return False
  matchTag = getString(Cmd.OB_TAG)
  matchReplacement = getString(Cmd.OB_STRING, minLen=0)
  if matchReplacement.startswith('field:'):
    if not allowSubs:
      usageErrorExit(Msg.USER_SUBS_NOT_ALLOWED_TAG_REPLACEMENT)
    tagReplacements['subs'] = True
    field = matchReplacement[6:].strip().lower()
    if field.find('.') != -1:
      args = field.split('.', 3)
      field = args[0]
      subfield = args[1]
      if len(args) == 2:
        matchfield = matchvalue = ''
      elif len(args) == 4:
        matchfield = args[2]
        matchvalue = args[3]
        if matchfield == 'primary':
          matchvalue = matchvalue.lower()
          if matchvalue == TRUE:
            matchvalue = True
          elif matchvalue == FALSE:
            matchvalue = ''
          else:
            invalidChoiceExit(matchvalue, TRUE_FALSE, True)
      else:
        Cmd.Backup()
        usageErrorExit(Msg.INVALID_TAG_SPECIFICATION)
    elif field == 'photourl':
      subfield = matchfield = matchvalue = ''
    else:
      field = ''
    if not field or field not in TAG_FIELD_SUBFIELD_CHOICE_MAP:
      invalidChoiceExit(field, TAG_FIELD_SUBFIELD_CHOICE_MAP, True)
    field, subfieldsChoiceMap = TAG_FIELD_SUBFIELD_CHOICE_MAP[field]
    if subfield not in subfieldsChoiceMap:
      invalidChoiceExit(subfield, subfieldsChoiceMap, True)
    subfield = subfieldsChoiceMap[subfield]
    if matchfield:
      if matchfield not in subfieldsChoiceMap:
        invalidChoiceExit(matchfield, subfieldsChoiceMap, True)
      matchfield = subfieldsChoiceMap[matchfield]
    tagReplacements['fieldsSet'].add(field)
    tagReplacements['fields'] = ','.join(tagReplacements['fieldsSet'])
    tagReplacements['tags'][matchTag] = {'field': field, 'subfield': subfield,
                                         'matchfield': matchfield, 'matchvalue': matchvalue, 'value': '',
                                         'trregex': trregex}
    if field == 'locations' and subfield == 'buildingName':
      _makeBuildingIdNameMap()
  elif matchReplacement.startswith('schema:'):
    if not allowSubs:
      usageErrorExit(Msg.USER_SUBS_NOT_ALLOWED_TAG_REPLACEMENT)
    tagReplacements['subs'] = True
    matchReplacement = matchReplacement[7:].strip()
    if matchReplacement.find('.') != -1:
      schemaName, schemaField = matchReplacement.split('.', 1)
    else:
      schemaName = ''
    if not schemaName or not schemaField:
      invalidArgumentExit(Cmd.OB_SCHEMA_NAME_FIELD_NAME)
    tagReplacements['fieldsSet'].add('customSchemas')
    tagReplacements['fields'] = ','.join(tagReplacements['fieldsSet'])
    tagReplacements['schemasSet'].add(schemaName)
    tagReplacements['customFieldMask'] = ','.join(tagReplacements['schemasSet'])
    tagReplacements['tags'][matchTag] = {'schema': schemaName, 'schemafield': schemaField, 'value': '',
                                         'trregex': trregex}
  elif ((matchReplacement.find('#') >= 0) and
        (matchReplacement.find('#user#') >= 0) or (matchReplacement.find('#email#') >= 0) or (matchReplacement.find('#username#') >= 0)):
    if not allowSubs:
      usageErrorExit(Msg.USER_SUBS_NOT_ALLOWED_TAG_REPLACEMENT)
    tagReplacements['subs'] = True
    tagReplacements['tags'][matchTag] = {'template': matchReplacement, 'value': '',
                                         'trregex': trregex}
  else:
    if trregex is None:
      tagReplacements['tags'][matchTag] = {'value': matchReplacement}
    else:
      tagReplacements['tags'][matchTag] = {'value': re.sub(trregex[0], trregex[1], matchReplacement)}
  return True

def _getTagReplacementFieldValues(user, i, count, tagReplacements, results=None):
  if results is None:
    if tagReplacements['fields'] and tagReplacements['fields'] != 'primaryEmail':
      if not tagReplacements['cd']:
        tagReplacements['cd'] = buildGAPIObject(API.DIRECTORY)
      try:
        results = callGAPI(tagReplacements['cd'].users(), 'get',
                           throwReasons=GAPI.USER_GET_THROW_REASONS,
                           userKey=user, projection='custom', customFieldMask=tagReplacements['customFieldMask'], fields=tagReplacements['fields'])
      except (GAPI.userNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest, GAPI.backendError, GAPI.systemError):
        entityUnknownWarning(Ent.USER, user, i, count)
        return
    else:
      results = {'primaryEmail': user}
  userName, domain = splitEmailAddress(user)
  for _, tag in iter(tagReplacements['tags'].items()):
    if tag.get('field'):
      field = tag['field']
      if field == 'primaryEmail':
        subfield = tag['subfield']
        if subfield == 'username':
          tag['value'] = userName
        elif subfield == 'domain':
          tag['value'] = domain
        else:
          tag['value'] = user
      else:
        if field in ['addresses', 'emails', 'ims', 'organizations', 'phones', 'posixAccounts', 'websites']:
          items = results.get(field, [])
          if not tag['matchfield']:
            for data in items:
              if data.get('primary'):
                break
            else:
              if items:
                data = items[0]
              else:
                data = {}
          else:
            for data in items:
              if data.get(tag['matchfield'], '') == tag['matchvalue']:
                break
            else:
              data = {}
        elif field in ['externalIds', 'relations', 'sshPublicKeys']:
          items = results.get(field, [])
          if not tag['matchfield']:
            if items:
              data = items[0]
            else:
              data = {}
          else:
            for data in items:
              if data.get(tag['matchfield'], '') == tag['matchvalue']:
                break
            else:
              data = {}
        elif field in ['keywords', 'locations']:
          items = results.get(field, [])
          if not tag['matchfield']:
            if items:
              data = items[0]
              data['buildingName'] = GM.Globals[GM.MAP_BUILDING_ID_TO_NAME].get(data.get('buildingId', ''), '')
            else:
              data = {}
          else:
            for data in items:
              if data.get(tag['matchfield'], '') == tag['matchvalue']:
                break
            else:
              data = {}
        elif field == 'thumbnailPhotoUrl':
          data = results
        else:
          data = results.get(field, {})
        tag['value'] = str(data.get(tag['subfield'], ''))
    elif tag.get('schema'):
      tag['value'] = str(results.get('customSchemas', {}).get(tag['schema'], {}).get(tag['schemafield'], ''))
    elif tag.get('template'):
      tag['value'] = _substituteForUser(tag['template'], user, userName)
    trregex = tag.get('trregex', None)
    if trregex is not None:
      tag['value'] = re.sub(trregex[0], trregex[1], tag['value'])

RTL_PATTERN = re.compile(r'(?s){RTL}.*?{/RTL}')
RT_PATTERN = re.compile(r'(?s){RT}.*?{/RT}')
TAG_REPLACE_PATTERN = re.compile(r'{(.+?)}')
RT_MARKERS = {'RT', '/RT', 'RTL', '/RTL'}
PC_PATTERN = re.compile(r'(?s){PC}.*?{/PC}')
UC_PATTERN = re.compile(r'(?s){UC}.*?{/UC}')
LC_PATTERN = re.compile(r'(?s){LC}.*?{/LC}')
CASE_MARKERS = {'PC', '/PC', 'UC', '/UC', 'LC', '/LC'}
SKIP_PATTERNS = [re.compile(r'<head>.*?</head>', flags=re.IGNORECASE),
                 re.compile(r'<script>.*?</script>', flags=re.IGNORECASE)]

def _processTagReplacements(tagReplacements, message):
  def pcase(trstring):
    new = ''
    # state = True: Upshift 1st letter found
    # state = False: Downshift subsequent letters
    state = True
    for c in trstring:
      if state:
        if c.isalpha():
          new += c.upper()
          state = False
        else:
          new += c
      else:
        if c.isalpha():
          new += c.lower()
        else:
          state = True
          new += c
    return new

  def ucase(trstring):
    return trstring.upper()

  def lcase(trstring):
    return trstring.lower()

  def _processCase(message, casePattern, caseFunc):
# Find all {xC}.*?{/xC} sequences
    pos = 0
    while True:
      match = casePattern.search(message, pos)
      if not match:
        return message
      start, end = match.span()
      for skipArea in skipAreas:
        if start >= skipArea[0] and end <= skipArea[1]:
          break
      else:
        message = message[:start]+caseFunc(message[start+4:end-5])+message[end:]
      pos = end

# Identify areas of message to avoid replacements
  skipAreas = []
  for pattern in SKIP_PATTERNS:
    pos = 0
    while True:
      match = pattern.search(message, pos)
      if not match:
        break
      skipAreas.append(match.span())
      pos = match.end()
  skipTags = set()
# Find all {tag}, note replacement value and starting location; note tags in skipAreas
  tagFields = []
  tagSubs = {}
  pos = 0
  while True:
    match = TAG_REPLACE_PATTERN.search(message, pos)
    if not match:
      break
    start, end = match.span()
    tag = match.group(1)
    if tag in CASE_MARKERS:
      pass
    elif tag not in RT_MARKERS:
      for skipArea in skipAreas:
        if start >= skipArea[0] and end <= skipArea[1]:
          skipTags.add(tag)
          break
      else:
        tagSubs.setdefault(tag, tagReplacements['tags'].get(tag, {'value': ''})['value'])
        tagFields.append((tagSubs[tag], start))
    pos = end
# Find all {RT}.*?{/RT} sequences
# If any non-empty {tag} replacement value falls between them, then mark {RT} and {/RT} to be stripped
# Otherwise, mark the entire {RT}.*?{/RT} sequence to be stripped
  rtStrips = []
  pos = 0
  while True:
    match = RT_PATTERN.search(message, pos)
    if not match:
      break
    start, end = match.span()
    stripEntireRT = True
    hasTags = False
    for tagField in tagFields:
      if tagField[1] >= end:
        break
      if tagField[1] >= start:
        hasTags = True
        if tagField[0]:
          rtStrips.append((False, start, start+4))
          rtStrips.append((False, end-5, end))
          stripEntireRT = False
          break
    if stripEntireRT:
      if hasTags or start+4 == end-5:
        rtStrips.append((True, start, end))
      else:
        rtStrips.append((False, start, start+4))
        rtStrips.append((False, end-5, end))
    pos = end
# Find all {RTL}.*?{/RTL} sequences
# If any non-empty {RT}...{tag}... {/RT} falls between them, then mark {RTL} and {/RTL} to be stripped
# Otherwise, mark the entire {RTL}.*{/RTL} sequence to be stripped
  rtlStrips = []
  pos = 0
  while True:
    match = RTL_PATTERN.search(message, pos)
    if not match:
      break
    start, end = match.span()
    stripEntireRTL = True
    hasTags = False
    for tagField in tagFields:
      if tagField[1] >= end:
        break
      if tagField[1] >= start:
        hasTags = True
        if tagField[0]:
          rtlStrips.append((False, start, start+5, end-6, end))
          stripEntireRTL = False
          break
    if stripEntireRTL:
      for rtStrip in rtStrips:
        if rtStrip[1] >= end:
          break
        if rtStrip[1] >= start:
          hasTags = True
          if not rtStrip[0]:
            rtlStrips.append((False, start, start+5, end-6, end))
            stripEntireRTL = False
            break
    if stripEntireRTL:
      if hasTags or start+5 == end-6:
        rtlStrips.append((True, start, end))
      else:
        rtlStrips.append((False, start, start+5, end-6, end))
    pos = end
  if rtlStrips:
    allStrips = []
    i = 0
    l = len(rtStrips)
    for rtlStrip in rtlStrips:
      while i < l and rtStrips[i][1] < rtlStrip[1]:
        allStrips.append(rtStrips[i])
        i += 1
      allStrips.append((False, rtlStrip[1], rtlStrip[2]))
      if not rtlStrip[0]:
        while i < l and rtStrips[i][1] < rtlStrip[3]:
          allStrips.append(rtStrips[i])
          i += 1
        allStrips.append((False, rtlStrip[3], rtlStrip[4]))
      else:
        while i < l and rtStrips[i][1] < rtlStrip[2]:
          i += 1
    while i < l:
      allStrips.append(rtStrips[i])
      i += 1
  else:
    allStrips = rtStrips
# Strip {RTL} {/RTL}, {RT} {/RT}, {RTL}.*?{/RTL}, {RT}.*?{/RT} sequences
  for rtStrip in allStrips[::-1]:
    message = message[:rtStrip[1]]+message[rtStrip[2]:]
# Strip {RTL} {/RTL}, {RT} {/RT}, {RTL}.*?{/RTL}, {RT}.*?{/RT} sequences
# Make {tag} replacements; ignore tags in skipAreas
  pos = 0
  while True:
    match = TAG_REPLACE_PATTERN.search(message, pos)
    if not match:
      break
    start, end = match.span()
    tag = match.group(1)
    if tag in CASE_MARKERS:
      pos = end
    elif tag not in RT_MARKERS:
      if tag not in skipTags:
        message = re.sub(match.group(0), tagSubs[tag], message)
        pos = start+1
      else:
        pos = end
    else:
# Replace invalid RT tags with ERROR(RT)
      message = re.sub(match.group(0), f'ERROR({tag})', message)
      pos = start+1
# Process case changes
  message = _processCase(message, PC_PATTERN, pcase)
  message = _processCase(message, UC_PATTERN, ucase)
  message = _processCase(message, LC_PATTERN, lcase)
  return message

def sendCreateUpdateUserNotification(body, basenotify, tagReplacements, i=0, count=0, msgFrom=None, createMessage=True):
  def _makeSubstitutions(field):
    notify[field] = _substituteForUser(notify[field], body['primaryEmail'], userName)
    notify[field] = notify[field].replace('#domain#', domain)
    notify[field] = notify[field].replace('#givenname#', body['name'].get('givenName', ''))
    notify[field] = notify[field].replace('#familyname#', body['name'].get('familyName', ''))

  def _makePasswordSubstitutions(field, html):
    if not html:
      notify[field] = notify[field].replace('#password#', notify['password'])
    else:
      notify[field] = notify[field].replace('#password#', notify['password'].replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;'))

  userName, domain = splitEmailAddress(body['primaryEmail'])
  notify = basenotify.copy()
  if not notify['subject']:
    notify['subject'] = Msg.CREATE_USER_NOTIFY_SUBJECT if createMessage else Msg.UPDATE_USER_PASSWORD_CHANGE_NOTIFY_SUBJECT
  _makeSubstitutions('subject')
  if not notify['message']:
    notify['message'] = Msg.CREATE_USER_NOTIFY_MESSAGE if createMessage else Msg.UPDATE_USER_PASSWORD_CHANGE_NOTIFY_MESSAGE
  elif notify['html']:
    notify['message'] = notify['message'].replace('\r', '').replace('\\n', '<br/>')
  else:
    notify['message'] = notify['message'].replace('\r', '').replace('\\n', '\n')
  _makeSubstitutions('message')
  if tagReplacements['subs']:
    _getTagReplacementFieldValues(body['primaryEmail'], i, count, tagReplacements, body if createMessage else None)
  notify['subject'] = _processTagReplacements(tagReplacements, notify['subject'])
  notify['message'] = _processTagReplacements(tagReplacements, notify['message'])
  _makePasswordSubstitutions('subject', False)
  _makePasswordSubstitutions('message', notify['html'])
  if 'from' in notify:
    msgFrom = notify['from']
  msgReplyTo = notify.get('replyto', None)
  mailBox = notify.get('mailbox', None)
  for recipient in notify['recipients']:
    send_email(notify['subject'], notify['message'], recipient, i, count,
               msgFrom=msgFrom, msgReplyTo=msgReplyTo, html=notify['html'], charset=notify['charset'], mailBox=mailBox)

def getRecipients():
  if checkArgumentPresent('select'):
    _, recipients = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)
    return [normalizeEmailAddressOrUID(emailAddress, noUid=True, noLower=True) for emailAddress in recipients]
  return getNormalizedEmailAddressEntity(shlexSplit=True, noLower=True)

# gam sendemail [recipient|to] <RecipientEntity> [from <EmailAddress>] [mailbox <EmailAddress>] [replyto <EmailAddress>]
#	[cc <RecipientEntity>] [bcc <RecipientEntity>] [singlemessage]
#	[subject <String>]
#	[<MessageContent>]
#	(replace <Tag> <String>)*
#	(replaceregex <REMatchPattern> <RESubstitution>  <Tag> <String>)*
#	[html [<Boolean>]] (attach <FileName> [charset <CharSet>])*
#	(embedimage <FileName> <String>)*
#	[newuser <EmailAddress> firstname|givenname <String> lastname|familyname <string> password <Password>]
#	(<SMTPDateHeader> <Time>)* (<SMTPHeader> <String>)* (header <String> <String>)*
# gam <UserTypeEntity> sendemail recipient <RecipientEntity> [replyto <EmailAddress>]
#	[cc <RecipientEntity>] [bcc <RecipientEntity>] [singlemessage]
#	[subject <String>]
#	[<MessageContent>]
#	(replace <Tag> <String>)*
#	(replaceregex <REMatchPattern> <RESubstitution>  <Tag> <String>)*
#	[html [<Boolean>]] (attach <FileName> [charset <CharSet>])*
#	(embedimage <FileName> <String>)*
#	[newuser <EmailAddress> firstname|givenname <String> lastname|familyname <string> password <Password>]
#	(<SMTPDateHeader> <Time>)* (<SMTPHeader> <String>)* (header <String> <String>)*
# gam <UserTypeEntity> sendemail from <EmailAddress> [replyto <EmailAddress>]
#	[cc <RecipientEntity>] [bcc <RecipientEntity>] [singlemessage]
#	[subject <String>]
#	[<MessageContent>]
#	(replace <Tag> <String>)*
#	(replaceregex <REMatchPattern> <RESubstitution>  <Tag> <String>)*
#	[html [<Boolean>]] (attach <FileName> [charset <CharSet>])*
#	(embedimage <FileName> <String>)*
#	[newuser <EmailAddress> firstname|givenname <String> lastname|familyname <string> password <Password>]
#	(<SMTPDateHeader> <Time>)* (<SMTPHeader> <String>)* (header <String> <String>)*
def doSendEmail(users=None):
  body = {}
  notify = {'subject': '', 'message': '', 'html': False, 'charset': UTF8, 'password': ''}
  msgFroms = [_getAdminEmail()]
  count = 1
  if users is None:
    checkArgumentPresent({'recipient', 'recipients', 'to'})
    recipients = getRecipients()
  else:
    _, count, entityList = getEntityArgument(users)
    if checkArgumentPresent({'recipient', 'recipients', 'to'}):
      msgFroms = [normalizeEmailAddressOrUID(entity) for entity in entityList]
      recipients = getRecipients()
    else:
      if checkArgumentPresent({'from'}):
        msgFroms = [getString(Cmd.OB_EMAIL_ADDRESS)]
        count = 1
      recipients = [normalizeEmailAddressOrUID(entity) for entity in entityList]
  msgHeaders = {}
  ccRecipients = []
  bccRecipients = []
  mailBox = None
  msgReplyTo = None
  singleMessage = False
  tagReplacements = _initTagReplacements()
  attachments = []
  embeddedImages = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if users is None and myarg == 'from':
      msgFroms = [getString(Cmd.OB_EMAIL_ADDRESS)]
      count = 1
    elif myarg == 'replyto':
      msgReplyTo = getString(Cmd.OB_EMAIL_ADDRESS)
    elif myarg == 'subject':
      notify['subject'] = getString(Cmd.OB_STRING)
    elif myarg in SORF_MSG_FILE_ARGUMENTS:
      notify['message'], notify['charset'], notify['html'] = getStringOrFile(myarg)
    elif myarg == 'cc':
      ccRecipients = getRecipients()
    elif myarg == 'bcc':
      bccRecipients = getRecipients()
    elif myarg == 'mailbox':
      mailBox = getString(Cmd.OB_EMAIL_ADDRESS)
    elif myarg == 'singlemessage':
      singleMessage = True
    elif myarg == 'html':
      notify['html'] = getBoolean()
    elif myarg == 'newuser':
      body['primaryEmail'] = getEmailAddress()
    elif myarg in {'firstname', 'givenname'}:
      body.setdefault('name', {})
      body['name']['givenName'] = getString(Cmd.OB_STRING, minLen=0, maxLen=60)
    elif myarg in {'lastname', 'familyname'}:
      body.setdefault('name', {})
      body['name']['familyName'] = getString(Cmd.OB_STRING, minLen=0, maxLen=60)
    elif myarg in {'password', 'notifypassword'}:
      body['password'] = notify['password'] = getString(Cmd.OB_PASSWORD, maxLen=100)
    elif _getTagReplacement(myarg, tagReplacements, False):
      pass
    elif myarg == 'attach':
      attachments.append((getFilename(), getCharSet()))
    elif myarg == 'embedimage':
      embeddedImages.append((getFilename(), getString(Cmd.OB_STRING)))
    elif myarg in SMTP_HEADERS_MAP:
      if myarg in SMTP_DATE_HEADERS:
        msgDate, _, _ = getTimeOrDeltaFromNow(True)
        msgHeaders[SMTP_HEADERS_MAP[myarg]] = formatdate(time.mktime(msgDate.timetuple()) + msgDate.microsecond/1E6, True)
      else:
        msgHeaders[SMTP_HEADERS_MAP[myarg]] = getString(Cmd.OB_STRING)
    elif myarg == 'header':
      header = getString(Cmd.OB_STRING, minLen=1)
      msgHeaders[SMTP_HEADERS_MAP.get(header.lower(), header)] = getString(Cmd.OB_STRING)
    else:
      unknownArgumentExit()
  notify['message'] = notify['message'].replace('\r', '').replace('\\n', '\n')
  if tagReplacements['tags']:
    notify['message'] = _processTagReplacements(tagReplacements, notify['message'])
  if tagReplacements['tags']:
    notify['subject'] = _processTagReplacements(tagReplacements, notify['subject'])
  jcount = len(recipients)
  if body.get('primaryEmail'):
    if (recipients and ('password' in body) and ('name' in body) and ('givenName' in body['name']) and ('familyName' in body['name'])):
      notify['recipients'] = recipients
      sendCreateUpdateUserNotification(body, notify, tagReplacements, msgFrom=msgFroms[0])
    else:
      usageErrorExit(Msg.NEWUSER_REQUIREMENTS, True)
    return
  if ccRecipients or bccRecipients:
    singleMessage = True
  i = 0
  for msgFrom in msgFroms:
    i += 1
    if singleMessage:
      entityPerformActionModifierNumItems([Ent.USER, msgFrom],
                                          Act.MODIFIER_TO, jcount+len(ccRecipients)+len(bccRecipients), Ent.RECIPIENT, i, count)
      send_email(notify['subject'], notify['message'], ','.join(recipients), i, count,
                 msgFrom=msgFrom, msgReplyTo=msgReplyTo, html=notify['html'], charset=notify['charset'],
                 attachments=attachments, embeddedImages=embeddedImages, msgHeaders=msgHeaders,
                 ccRecipients=','.join(ccRecipients), bccRecipients=','.join(bccRecipients),
                 mailBox=mailBox)
    else:
      entityPerformActionModifierNumItems([Ent.USER, msgFrom], Act.MODIFIER_TO, jcount, Ent.RECIPIENT, i, count)
      Ind.Increment()
      j = 0
      for recipient in recipients:
        j += 1
        send_email(notify['subject'], notify['message'], recipient, j, jcount,
                   msgFrom=msgFrom, msgReplyTo=msgReplyTo, html=notify['html'], charset=notify['charset'],
                   attachments=attachments, embeddedImages=embeddedImages, msgHeaders=msgHeaders, mailBox=mailBox)
      Ind.Decrement()

ADDRESS_FIELDS_PRINT_ORDER = ['contactName', 'organizationName', 'addressLine1', 'addressLine2', 'addressLine3', 'locality', 'region', 'postalCode', 'countryCode']

def _showCustomerAddressPhoneNumber(customerInfo):
  if 'postalAddress' in customerInfo:
    printKeyValueList(['Address', None])
    Ind.Increment()
    for field in ADDRESS_FIELDS_PRINT_ORDER:
      if field in customerInfo['postalAddress']:
        printKeyValueList([field, customerInfo['postalAddress'][field]])
    Ind.Decrement()
  if 'phoneNumber' in customerInfo:
    printKeyValueList(['Phone', customerInfo['phoneNumber']])

ADDRESS_FIELDS_ARGUMENT_MAP = {
  'contact': 'contactName', 'contactname': 'contactName',
  'name': 'organizationName', 'organizationname': 'organizationName', 'organisationname': 'organizationName',
  'address': 'addressLine1', 'address1': 'addressLine1', 'addressline1': 'addressLine1',
  'address2': 'addressLine2', 'addressline2': 'addressLine2',
  'address3': 'addressLine3', 'addressline3': 'addressLine3',
  'city': 'locality', 'locality': 'locality',
  'state': 'region', 'region': 'region',
  'zipcode': 'postalCode', 'postal': 'postalCode', 'postalcode': 'postalCode',
  'country': 'countryCode', 'countrycode': 'countryCode',
  }

def _getResoldCustomerAttr():
  body = {}
  customerAuthToken = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in ADDRESS_FIELDS_ARGUMENT_MAP:
      body.setdefault('postalAddress', {})
      body['postalAddress'][ADDRESS_FIELDS_ARGUMENT_MAP[myarg]] = getString(Cmd.OB_STRING, minLen=0, maxLen=255)
    elif myarg in {'email', 'alternateemail'}:
      body['alternateEmail'] = getEmailAddress(noUid=True)
    elif myarg in {'phone', 'phonenumber'}:
      body['phoneNumber'] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg in {'customerauthtoken', 'transfertoken'}:
      customerAuthToken = getString(Cmd.OB_STRING)
    else:
      unknownArgumentExit()
  return customerAuthToken, body

# gam create resoldcustomer <CustomerDomain> (customer_auth_token <String>) <ResoldCustomerAttribute>+
def doCreateResoldCustomer():
  res = buildGAPIObject(API.RESELLER)
  customerDomain = getString('customerDomain')
  customerAuthToken, body = _getResoldCustomerAttr()
  body['customerDomain'] = customerDomain
  try:
    result = callGAPI(res.customers(), 'insert',
                      throwReasons=GAPI.RESELLER_THROW_REASONS,
                      body=body, customerAuthToken=customerAuthToken, fields='customerId')
    entityActionPerformed([Ent.CUSTOMER_DOMAIN, body['customerDomain'], Ent.CUSTOMER_ID, result['customerId']])
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden, GAPI.invalid) as e:
    entityActionFailedWarning([Ent.CUSTOMER_DOMAIN, body['customerDomain']], str(e))

# gam update resoldcustomer <CustomerID> <ResoldCustomerAttribute>+
def doUpdateResoldCustomer():
  res = buildGAPIObject(API.RESELLER)
  customerId = getString(Cmd.OB_CUSTOMER_ID)
  _, body = _getResoldCustomerAttr()
  try:
    callGAPI(res.customers(), 'patch',
             throwReasons=GAPI.RESELLER_THROW_REASONS,
             customerId=customerId, body=body, fields='')
    entityActionPerformed([Ent.CUSTOMER_ID, customerId])
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden, GAPI.invalid) as e:
    entityActionFailedWarning([Ent.CUSTOMER_ID, customerId], str(e))

# gam info resoldcustomer <CustomerID> [formatjson]
def doInfoResoldCustomer():
  res = buildGAPIObject(API.RESELLER)
  customerId = getString(Cmd.OB_CUSTOMER_ID)
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    FJQC.GetFormatJSON(myarg)
  try:
    customerInfo = callGAPI(res.customers(), 'get',
                            throwReasons=GAPI.RESELLER_THROW_REASONS,
                            customerId=customerId)
    if not FJQC.formatJSON:
      printKeyValueList(['Customer ID', customerInfo['customerId']])
      printKeyValueList(['Customer Type', customerInfo['customerType']])
      printKeyValueList(['Customer Domain', customerInfo['customerDomain']])
      if 'customerDomainVerified' in customerInfo:
        printKeyValueList(['Customer Domain Verified', customerInfo['customerDomainVerified']])
      _showCustomerAddressPhoneNumber(customerInfo)
      primaryEmail = customerInfo.get('primaryAdmin', {}).get('primaryEmail')
      if primaryEmail:
        printKeyValueList(['Customer Primary Email', primaryEmail])
      if 'alternateEmail' in customerInfo:
        printKeyValueList(['Customer Alternate Email', customerInfo['alternateEmail']])
      printKeyValueList(['Customer Admin Console URL', customerInfo['resourceUiUrl']])
    else:
      printLine(json.dumps(cleanJSON(customerInfo), ensure_ascii=False, sort_keys=False))
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden, GAPI.invalid) as e:
    entityActionFailedWarning([Ent.CUSTOMER_ID, customerId], str(e))

def getCustomerSubscription(res):
  customerId = getString(Cmd.OB_CUSTOMER_ID)
  productId, skuId = SKU.getProductAndSKU(getString(Cmd.OB_SKU_ID))
  if not productId:
    invalidChoiceExit(skuId, SKU.getSortedSKUList(), True)
  try:
    subscriptions = callGAPIpages(res.subscriptions(), 'list', 'subscriptions',
                                  throwReasons=GAPI.RESELLER_THROW_REASONS,
                                  customerId=customerId, fields='nextPageToken,subscriptions(skuId,subscriptionId,plan(planName))')
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden, GAPI.invalid) as e:
    entityActionFailedWarning([Ent.SUBSCRIPTION, None], str(e))
    sys.exit(GM.Globals[GM.SYSEXITRC])
  for subscription in subscriptions:
    if skuId == subscription['skuId']:
      return (customerId, skuId, subscription['subscriptionId'], subscription['plan']['planName'])
  Cmd.Backup()
  usageErrorExit(f'{Ent.FormatEntityValueList([Ent.CUSTOMER_ID, customerId, Ent.SKU, skuId])}, {Msg.SUBSCRIPTION_NOT_FOUND}')

PLAN_NAME_MAP = {
  'annualmonthlypay': 'ANNUAL_MONTHLY_PAY',
  'annualyearlypay': 'ANNUAL_YEARLY_PAY',
  'flexible': 'FLEXIBLE',
  'trial': 'TRIAL',
  }

def _getResoldSubscriptionAttr(customerId):
  body = {'customerId': customerId,
          'plan': {},
          'seats': {},
          'skuId': None,
         }
  customerAuthToken = None
  seats1 = seats2 = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'deal', 'dealcode'}:
      body['dealCode'] = getString('dealCode')
    elif myarg in {'plan', 'planname'}:
      body['plan']['planName'] = getChoice(PLAN_NAME_MAP, mapChoice=True)
    elif myarg in {'purchaseorderid', 'po'}:
      body['purchaseOrderId'] = getString('purchaseOrderId')
    elif myarg == 'seats':
      seats1 = getInteger(minVal=0)
      if Cmd.ArgumentsRemaining() and Cmd.Current().isdigit():
        seats2 = getInteger(minVal=0)
    elif myarg in {'sku', 'skuid'}:
      productId, body['skuId'] = SKU.getProductAndSKU(getString(Cmd.OB_SKU_ID))
      if not productId:
        invalidChoiceExit(body['skuId'], SKU.getSortedSKUList(), True)
    elif myarg in {'customerauthtoken', 'transfertoken'}:
      customerAuthToken = getString('customer_auth_token')
    else:
      unknownArgumentExit()
  for field in ['plan', 'skuId']:
    if not body[field]:
      missingArgumentExit(field.lower())
  if seats1 is None:
    missingArgumentExit('seats')
  if body['plan']['planName'].startswith('ANNUAL'):
    body['seats']['numberOfSeats'] = seats1
  else:
    body['seats']['maximumNumberOfSeats'] = seats1 if seats2 is None else seats2
  return customerAuthToken, body

SUBSCRIPTION_SKIP_OBJECTS = {'customerId', 'skuId', 'subscriptionId'}
SUBSCRIPTION_TIME_OBJECTS = {'creationTime', 'startTime', 'endTime', 'trialEndTime', 'transferabilityExpirationTime'}

def _showSubscription(subscription, FJQC=None):
  if FJQC is not None and FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(subscription, timeObjects=SUBSCRIPTION_TIME_OBJECTS), ensure_ascii=False, sort_keys=False))
    return
  Ind.Increment()
  printEntity([Ent.SUBSCRIPTION, subscription['subscriptionId']])
  showJSON(None, subscription, SUBSCRIPTION_SKIP_OBJECTS, SUBSCRIPTION_TIME_OBJECTS)
  Ind.Decrement()

# gam create resoldsubscription <CustomerID> (sku <SKUID>)
#	 (plan annual_monthly_pay|annual_yearly_pay|flexible|trial) (seats <Number>)
#	 [customer_auth_token <String>] [deal <String>] [purchaseorderid <String>]
def doCreateResoldSubscription():
  res = buildGAPIObject(API.RESELLER)
  customerId = getString(Cmd.OB_CUSTOMER_ID)
  customerAuthToken, body = _getResoldSubscriptionAttr(customerId)
  try:
    subscription = callGAPI(res.subscriptions(), 'insert',
                            throwReasons=GAPI.RESELLER_THROW_REASONS,
                            customerId=customerId, customerAuthToken=customerAuthToken, body=body)
    entityActionPerformed([Ent.CUSTOMER_ID, customerId, Ent.SKU, subscription['skuId']])
    _showSubscription(subscription)
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden, GAPI.invalid) as e:
    entityActionFailedWarning([Ent.CUSTOMER_ID, customerId], str(e))

RENEWAL_TYPE_MAP = {
  'autorenewmonthlypay': 'AUTO_RENEW_MONTHLY_PAY',
  'autorenewyearlypay': 'AUTO_RENEW_YEARLY_PAY',
  'cancel': 'CANCEL',
  'renewcurrentusersmonthlypay': 'RENEW_CURRENT_USERS_MONTHLY_PAY',
  'renewcurrentusersyearlypay': 'RENEW_CURRENT_USERS_YEARLY_PAY',
  'switchtopayasyougo': 'SWITCH_TO_PAY_AS_YOU_GO',
  }

# gam update resoldsubscription <CustomerID> <SKUID>
#	activate|suspend|startpaidservice|
#	(renewal auto_renew_monthly_pay|auto_renew_yearly_pay|cancel|renew_current_users_monthly_pay|renew_current_users_yearly_pay|switch_to_pay_as_you_go)|
#	(seats <Number>)|
#	(plan annual_monthly_pay|annual_yearly_pay|flexible|trial [deal <String>] [purchaseorderid <String>] [seats <Number>])
def doUpdateResoldSubscription():
  def _getSeats():
    seats1 = getInteger(minVal=0)
    if Cmd.ArgumentsRemaining() and Cmd.Current().isdigit():
      seats2 = getInteger(minVal=0)
    else:
      seats2 = None
    if planName.startswith('ANNUAL'):
      return {'numberOfSeats': seats1}
    return {'maximumNumberOfSeats': seats1 if seats2 is None else seats2}

  res = buildGAPIObject(API.RESELLER)
  function = None
  customerId, skuId, subscriptionId, planName = getCustomerSubscription(res)
  kwargs = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'activate':
      function = 'activate'
    elif myarg == 'suspend':
      function = 'suspend'
    elif myarg == 'startpaidservice':
      function = 'startPaidService'
    elif myarg in {'renewal', 'renewaltype'}:
      function = 'changeRenewalSettings'
      kwargs['body'] = {'renewalType': getChoice(RENEWAL_TYPE_MAP, mapChoice=True)}
    elif myarg == 'seats':
      function = 'changeSeats'
      kwargs['body'] =  _getSeats()
    elif myarg == 'plan':
      function = 'changePlan'
      planName = getChoice(PLAN_NAME_MAP, mapChoice=True)
      kwargs['body'] = {'planName': planName}
      while Cmd.ArgumentsRemaining():
        planarg = getArgument()
        if planarg == 'seats':
          kwargs['body']['seats'] = _getSeats()
        elif planarg in {'purchaseorderid', 'po'}:
          kwargs['body']['purchaseOrderId'] = getString('purchaseOrderId')
        elif planarg in {'dealcode', 'deal'}:
          kwargs['body']['dealCode'] = getString('dealCode')
        else:
          unknownArgumentExit()
    else:
      unknownArgumentExit()
  try:
    subscription = callGAPI(res.subscriptions(), function,
                            throwReasons=GAPI.RESELLER_THROW_REASONS,
                            customerId=customerId, subscriptionId=subscriptionId, **kwargs)
    entityActionPerformed([Ent.CUSTOMER_ID, customerId, Ent.SKU, skuId])
    if subscription:
      _showSubscription(subscription)
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden, GAPI.invalid) as e:
    entityActionFailedWarning([Ent.CUSTOMER_ID, customerId], str(e))

DELETION_TYPE_MAP = {
  'cancel': 'cancel',
  'downgrade': 'downgrade',
  'transfertodirect': 'transfer_to_direct',
  }

# gam delete resoldsubscription <CustomerID> <SKUID> cancel|downgrade|transfer_to_direct
def doDeleteResoldSubscription():
  res = buildGAPIObject(API.RESELLER)
  customerId, skuId, subscriptionId, _ = getCustomerSubscription(res)
  deletionType = getChoice(DELETION_TYPE_MAP, mapChoice=True)
  checkForExtraneousArguments()
  try:
    callGAPI(res.subscriptions(), 'delete',
             throwReasons=GAPI.RESELLER_THROW_REASONS,
             customerId=customerId, subscriptionId=subscriptionId, deletionType=deletionType)
    entityActionPerformed([Ent.CUSTOMER_ID, customerId, Ent.SKU, skuId])
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden, GAPI.invalid) as e:
    entityActionFailedWarning([Ent.CUSTOMER_ID, customerId, Ent.SKU, skuId], str(e))

# gam info resoldsubscription <CustomerID> <SKUID>
def doInfoResoldSubscription():
  res = buildGAPIObject(API.RESELLER)
  customerId, skuId, subscriptionId, _ = getCustomerSubscription(res)
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    FJQC.GetFormatJSON(myarg)
  try:
    subscription = callGAPI(res.subscriptions(), 'get',
                            throwReasons=GAPI.RESELLER_THROW_REASONS,
                            customerId=customerId, subscriptionId=subscriptionId)
    if not FJQC.formatJSON:
      printEntity([Ent.CUSTOMER_ID, customerId, Ent.SKU, skuId])
    _showSubscription(subscription, FJQC)
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden, GAPI.invalid) as e:
    entityActionFailedWarning([Ent.CUSTOMER_ID, customerId, Ent.SKU, skuId], str(e))

PRINT_RESOLD_SUBSCRIPTIONS_TITLES = ['customerId', 'skuId', 'subscriptionId']

# gam print resoldsubscriptions [todrive <ToDriveAttribute>*]
#	[customerid <CustomerID>] [customer_auth_token <String>] [customer_prefix <String>]
#	[maxresults <Number>]
#	[formatjson [quotechar <Character>]]
# gam show resoldsubscriptions
#	[customerid <CustomerID>] [customer_auth_token <String>] [customer_prefix <String>]
#	[maxresults <Number>]
#	[formatjson]
def doPrintShowResoldSubscriptions():
  res = buildGAPIObject(API.RESELLER)
  kwargs = {'maxResults': 100}
  csvPF = CSVPrintFile(PRINT_RESOLD_SUBSCRIPTIONS_TITLES, 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'customerid':
      kwargs['customerId'] = getString(Cmd.OB_CUSTOMER_ID)
    elif myarg in {'customerauthtoken', 'transfertoken'}:
      kwargs['customerAuthToken'] = getString(Cmd.OB_CUSTOMER_AUTH_TOKEN)
    elif myarg == 'customerprefix':
      kwargs['customerNamePrefix'] = getString(Cmd.OB_STRING)
    elif myarg == 'maxresults':
      kwargs['maxResults'] = getInteger(minVal=1, maxVal=100)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  try:
    subscriptions = callGAPIpages(res.subscriptions(), 'list', 'subscriptions',
                                  throwReasons=GAPI.RESELLER_THROW_REASONS,
                                  fields='nextPageToken,subscriptions', **kwargs)
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden, GAPI.invalid) as e:
    entityActionFailedWarning([Ent.SUBSCRIPTION, None], str(e))
    return
  jcount = len(subscriptions)
  if not csvPF:
    if not FJQC.formatJSON:
      performActionNumItems(jcount, Ent.SUBSCRIPTION)
    Ind.Increment()
    j = 0
    for subscription in subscriptions:
      j += 1
      if not FJQC.formatJSON:
        printEntity([Ent.CUSTOMER_ID, subscription['customerId'], Ent.SKU, subscription['skuId']], j, jcount)
      _showSubscription(subscription, FJQC)
    Ind.Decrement()
  else:
    for subscription in subscriptions:
      row = flattenJSON(subscription, timeObjects=SUBSCRIPTION_TIME_OBJECTS)
      if not FJQC.formatJSON:
        csvPF.WriteRowTitles(row)
      elif csvPF.CheckRowTitles(row):
        csvPF.WriteRowNoFilter({'customerId': subscription['customerId'],
                                'skuId': subscription['skuId'],
                                'subscriptionId': subscription['subscriptionId'],
                                'JSON': json.dumps(cleanJSON(subscription, timeObjects=SUBSCRIPTION_TIME_OBJECTS), ensure_ascii=False, sort_keys=True)})
    csvPF.writeCSVfile('Resold Subscriptions')

def normalizeChannelResellerID(resellerId):
  if resellerId.startswith('accounts/'):
    return resellerId
  return f'accounts/{resellerId}'

def normalizeChannelCustomerID(customerId):
  if customerId.startswith('customers/'):
    return customerId
  return f'customers/{customerId}'

def normalizeChannelProductID(productId):
  if productId.startswith('products/'):
    return productId
  return f'products/{productId}'

CHANNEL_ENTITY_MAP = {
  Ent.CHANNEL_CUSTOMER:
    {'JSONtitles': ['name', 'domain', 'JSON'],
     'timeObjects': ['createTime', 'updateTime'],
     'items': 'customers',
     'pageSize': 50,
     'maxPageSize': 50,
     'fields': {
        'name': 'name',
        'orgdisplayname': 'orgDisplayName',
        'orgpostaladdress': 'orgPostalAddress',
        'primarycontactinfo': 'primaryContactInfo',
        'alternateemail': 'alternateEmail',
        'domain': 'domain',
        'createtime': 'createTime',
        'updatetime': 'updateTime',
        'cloudidentityid': 'cloudIdentityId',
        'languagecode': 'languageCode',
        'cloudidentityinfo': 'cloudIdentityInfo',
        'channelpartnerid': 'channelPartnerId',
        }
     },
  Ent.CHANNEL_CUSTOMER_ENTITLEMENT:
    {'JSONtitles': ['name', 'offer', 'JSON'],
     'timeObjects': ['createTime', 'updateTime', 'startTime', 'endTime'],
     'items': 'entitlements',
     'pageSize': 100,
     'maxPageSize': 100,
     'fields': {
        'name': 'name',
        'createtime': 'createTime',
        'updatetime': 'updateTime',
        'offer': 'offer',
        'commitmentsettings': 'commitmentSettings',
        'provisioningstate': 'provisioningState',
        'provisionedservice': 'provisionedService',
        'suspensionreasons': 'suspensionReasons',
        'purchaseorderid': 'purchaseOrderId',
        'trialsettings': 'trialSettings',
        'associationinfo': 'associationInfo',
        'parameters': 'parameters',
        }
     },
  Ent.CHANNEL_OFFER:
    {'JSONtitles': ['name', 'sku', 'JSON'],
     'timeObjects': ['startTime', 'endTime'],
     'items': 'offers',
     'pageSize': 1000,
     'maxPageSize': 1000,
     'fields': {
        'name': 'name',
        'marketinginfo': 'marketingInfo',
        'sku': 'sku',
        'plan': 'plan',
        'constraints': 'constraints',
        'pricebyresources': 'priceByResources',
        'starttime': 'startTime',
        'endtime': 'endTime',
        'parameterdefinitions': 'parameterDefinitions',
        }
     },
  Ent.CHANNEL_PRODUCT:
    {'JSONtitles': ['name', 'JSON'],
     'timeObjects': None,
     'items': 'products',
     'pageSize': 1000,
     'maxPageSize': 1000,
     'fields': {
        'name': 'name',
        'marketinginfo': 'marketingInfo',
        }
     },
  Ent.CHANNEL_SKU:
    {'JSOBtitles': ['name', 'JSON'],
     'timeObjects': None,
     'items': 'skus',
     'pageSize': 1000,
     'maxPageSize': 1000,
     'fields': {
        'name': 'name',
        'marketinginfo': 'marketingInfo',
        'product': 'product',
        }
     }
  }

def doPrintShowChannelItems(entityType):
  cchan = buildGAPIObject(API.CLOUDCHANNEL)
  if entityType == Ent.CHANNEL_CUSTOMER:
    service = cchan.accounts().customers()
  elif entityType == Ent.CHANNEL_CUSTOMER_ENTITLEMENT:
    service = cchan.accounts().customers().entitlements()
  elif entityType == Ent.CHANNEL_OFFER:
    service = cchan.accounts().offers()
  elif entityType == Ent.CHANNEL_PRODUCT:
    service = cchan.products()
  else: #Ent.CHANNEL_SKU
    service = cchan.products().skus()
  channelEntityMap = CHANNEL_ENTITY_MAP[entityType]
#  csvPF = CSVPrintFile(channelEntityMap['titles'], 'sortall') if Act.csvFormat() else None
  csvPF = CSVPrintFile(['name'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  fieldsList = []
  resellerId = normalizeChannelResellerID(GC.Values[GC.RESELLER_ID] if GC.Values[GC.RESELLER_ID] else GC.Values[GC.CUSTOMER_ID])
  customerId = normalizeChannelCustomerID(GC.Values[GC.CHANNEL_CUSTOMER_ID])
  name = None
  productId = 'products/-'
  kwargs = {'pageSize': channelEntityMap['pageSize']}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'resellerid':
      resellerId = normalizeChannelResellerID(getString(Cmd.OB_RESELLER_ID))
    elif (entityType == Ent.CHANNEL_CUSTOMER_ENTITLEMENT) and myarg in {'customerid', 'channelcustomerid'}:
      customerId = normalizeChannelCustomerID(getString(Cmd.OB_CHANNEL_CUSTOMER_ID))
    elif (entityType == Ent.CHANNEL_CUSTOMER_ENTITLEMENT) and myarg == 'name':
      name = getString(Cmd.OB_STRING)
      parent = name.split('/')
      if (len(parent) != 4) or (parent[0] != 'accounts') or (not parent[1]) or (parent[2] != 'customers') or (not parent[3]):
        Cmd.Backup()
        usageErrorExit(Msg.INVALID_RESELLER_CUSTOMER_NAME)
    elif (entityType in {Ent.CHANNEL_OFFER, Ent.CHANNEL_PRODUCT, Ent.CHANNEL_SKU}) and myarg == 'language':
      kwargs['languageCode'] = getLanguageCode(LANGUAGE_CODES_MAP)
    elif (entityType in {Ent.CHANNEL_CUSTOMER, Ent.CHANNEL_OFFER}) and myarg == 'filter':
      kwargs['filter'] = getString(Cmd.OB_STRING)
    elif (entityType == Ent.CHANNEL_SKU) and myarg == 'productid':
      productId = normalizeChannelProductID(getString(Cmd.OB_PRODUCT_ID))
    elif myarg == 'fields':
      if not fieldsList:
        fieldsList.append('name')
      for field in _getFieldsList():
        if field in channelEntityMap['fields']:
          fieldsList.append(channelEntityMap['fields'][field])
        else:
          invalidChoiceExit(field, list(channelEntityMap['fields']), True)
    elif myarg == 'maxresults':
      kwargs['pageSize'] = getInteger(minVal=1, maxVal=channelEntityMap['maxPageSize'])
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if entityType != Ent.CHANNEL_CUSTOMER_ENTITLEMENT:
    entityName = resellerId
    if entityType in {Ent.CHANNEL_CUSTOMER, Ent.CHANNEL_OFFER}:
      kwargs['parent'] = resellerId
    else:
      kwargs['account'] = resellerId
      if entityType == Ent.CHANNEL_SKU:
        kwargs['parent'] = productId
  else:
    if not name and customerId == 'customers/':
      missingArgumentExit('channelcustomerid')
    entityName = kwargs['parent'] = name if name else f'{resellerId}/{customerId}'
  fields = getItemFieldsFromFieldsList(channelEntityMap['items'], fieldsList)
#  if csvPF and FJQC.formatJSON and not fieldsList:
#    csvPF.SetJSONTitles(channelEntityMap['JSONtitles'])
  try:
    results = callGAPIpages(service, 'list', channelEntityMap['items'],
                            bailOnInternalError=True,
                            throwReasons=[GAPI.PERMISSION_DENIED, GAPI.INVALID_ARGUMENT, GAPI.BAD_REQUEST, GAPI.INTERNAL_ERROR, GAPI.NOT_FOUND],
                            fields=fields, **kwargs)
  except (GAPI.permissionDenied, GAPI.invalidArgument, GAPI.badRequest, GAPI.internalError, GAPI.notFound) as e:
    entityActionFailedWarning([entityType, entityName], str(e))
    return
  jcount = len(results)
  if not csvPF:
    if not FJQC.formatJSON:
      performActionNumItems(jcount, entityType)
    Ind.Increment()
    j = 0
    for item in results:
      j += 1
      if not FJQC.formatJSON:
        printEntity([entityType, item['name']], j, jcount)
        Ind.Increment()
        showJSON(None, item, timeObjects=channelEntityMap['timeObjects'])
        Ind.Decrement()
      else:
        printLine(json.dumps(cleanJSON(item, timeObjects=channelEntityMap['timeObjects']),
                             ensure_ascii=False, sort_keys=False))
    Ind.Decrement()
  else:
    for item in results:
      row = flattenJSON(item, timeObjects=channelEntityMap['timeObjects'])
      if not FJQC.formatJSON:
        csvPF.WriteRowTitles(row)
      elif csvPF.CheckRowTitles(row):
        row = {'name': item['name'],
               'JSON': json.dumps(cleanJSON(item, timeObjects=channelEntityMap['timeObjects']),
                                  ensure_ascii=False, sort_keys=True)}
#        if not fieldsList:
#          if entityType == Ent.CHANNEL_CUSTOMER:
#            row.update({'domain': item['domain']})
#          elif entityType == Ent.CHANNEL_CUSTOMER_ENTITLEMENT:
#            row.update({'offer': item['offer']})
#          elif entityType == Ent.CHANNEL_OFFER:
#            row.update({'sku': item['sku']})
        csvPF.WriteRowNoFilter(row)
    csvPF.writeCSVfile(Ent.Plural(entityType))

# gam print channelcustomers [todrive <ToDriveAttribute>*]
#	[resellerid <ResellerID>] [filter <String>]
#	[fields <ChannelCustomerFieldList>]
#	[maxresults <Integer>]
#	[formatjson [quotechar <Character>]]
# gam show channelcustomers
#	[resellerid <ResellerID>] [filter <String>]
#	[fields <ChannelCustomerFieldList>]
#	[maxresults <Integer>]
#	[formatjson]
def doPrintShowChannelCustomers():
  doPrintShowChannelItems(Ent.CHANNEL_CUSTOMER)

# gam print channelcustomercentitlements [todrive <ToDriveAttribute>*]
#	([resellerid <ResellerID>] [customerid <ChannelCustomerID>])|
#	(name accounts/<ResellerID>/customers/<ChannelCustomerID>)
#	[fields <ChannelCustomerEntitlementFieldList>]
#	[maxresults <Integer>]
#	[formatjson [quotechar <Character>]]
# gam show channelcustomerentitlements
#	([resellerid <ResellerID>] [customerid <ChannelCustomerID>])|
#	(name accounts/<ResellerID>/customers/<ChannelCustomerID>)
#	[fields <ChannelCustomerEntitlementFieldList>]
#	[maxresults <Integer>]
#	[formatjson]
def doPrintShowChannelCustomerEntitlements():
  doPrintShowChannelItems(Ent.CHANNEL_CUSTOMER_ENTITLEMENT)

# gam print channeloffers [todrive <ToDriveAttribute>*]
#	[resellerid <ResellerID>] [filter <String>] [language <LanguageCode]
#	[fields <ChannelOfferFieldList>]
#	[maxresults <Integer>]
#	[formatjson [quotechar <Character>]]
# gam show channeloffers
#	[resellerid <ResellerID>] [filter <String>] [language <LanguageCode]
#	[fields <ChannelOfferFieldList>]
#	[maxresults <Integer>]
#	[formatjson]
def doPrintShowChannelOffers():
  doPrintShowChannelItems(Ent.CHANNEL_OFFER)

# gam print channelproducts [todrive <ToDriveAttribute>*]
#	[resellerid <ResellerID>] [language <LanguageCode]
#	[fields <ChannelProductFieldList>]
#	[maxresults <Integer>]
#	[formatjson [quotechar <Character>]]
# gam show channelproducts
#	[resellerid <ResellerID>] [language <LanguageCode]
#	[fields <ChannelProductFieldList>]
#	[maxresults <Integer>]
#	[formatjson]
def doPrintShowChannelProducts():
  doPrintShowChannelItems(Ent.CHANNEL_PRODUCT)

# gam print channelskus [todrive <ToDriveAttribute>*]
#	[resellerid <ResellerID>] [language <LanguageCode] [productid <ProductID>]
#	[fields <ChannelSKUFieldList>]
#	[maxresults <Integer>]
#	[formatjson [quotechar <Character>]]
# gam show channelskus
#	[resellerid <ResellerID>] [language <LanguageCode] [productid <ProductID>]
#	[fields <ChannelSKUFieldList>]
#	[maxresults <Integer>]
#	[formatjson]
def doPrintShowChannelSKUs():
  doPrintShowChannelItems(Ent.CHANNEL_SKU)

ANALYTIC_ENTITY_MAP = {
  Ent.ANALYTIC_ACCOUNT:
    {'titles': ['User', 'name', 'displayName', 'createTime', 'updateTime', 'regionCode', 'deleted'],
     'JSONtitles': ['User', 'name', 'displayName', 'JSON'],
     'timeObjects': ['createTime', 'updateTime'],
     'items': 'accounts',
     'pageSize': 50,
     'maxPageSize': 200,
     },
  Ent.ANALYTIC_ACCOUNT_SUMMARY:
    {'titles': ['User', 'name', 'displayName', 'account'],
     'JSONtitles': ['User', 'name', 'displayName', 'account', 'JSON'],
     'timeObjects': ['createTime', 'updateTime', 'deleteTime', 'expireTime'],
     'items': 'accountSummaries',
     'pageSize': 50,
     'maxPageSize': 200,
     },
  Ent.ANALYTIC_DATASTREAM:
    {'titles': ['User', 'name', 'displayName', 'type', 'createTime', 'updateTime'],
     'JSONtitles': ['User', 'name', 'displayName', 'type', 'JSON'],
     'timeObjects': ['createTime', 'updateTime'],
     'items': 'dataStreams',
     'pageSize': 50,
     'maxPageSize': 200,
     },
  Ent.ANALYTIC_PROPERTY:
    {'titles': ['User', 'name', 'displayName', 'createTime', 'updateTime', 'propertyType', 'parent'],
     'JSONtitles': ['User', 'name', 'displayName', 'propertyType', 'parent', 'JSON'],
     'timeObjects': ['createTime', 'updateTime', 'deleteTime', 'expireTime'],
     'items': 'properties',
     'pageSize': 50,
     'maxPageSize': 200,
     },
  }

def printShowAnalyticItems(users, entityType):
  analyticEntityMap = ANALYTIC_ENTITY_MAP[entityType]
  csvPF = CSVPrintFile(analyticEntityMap['titles'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  kwargs = {'pageSize': analyticEntityMap['pageSize']}
  if entityType in {Ent.ANALYTIC_ACCOUNT, Ent.ANALYTIC_PROPERTY}:
    kwargs['showDeleted'] = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'maxresults':
      kwargs['pageSize'] = getInteger(minVal=1, maxVal=analyticEntityMap['maxPageSize'])
    elif entityType in {Ent.ANALYTIC_ACCOUNT, Ent.ANALYTIC_PROPERTY} and myarg == 'showdeleted':
      kwargs['showDeleted'] = getBoolean()
    elif entityType == Ent.ANALYTIC_PROPERTY and myarg == 'filter':
      kwargs['filter'] = getString(Cmd.OB_STRING)
    elif entityType == Ent.ANALYTIC_DATASTREAM and myarg == 'parent':
      kwargs['parent'] = getString(Cmd.OB_STRING)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if entityType == Ent.ANALYTIC_PROPERTY and 'filter' not in kwargs:
    missingArgumentExit('filter')
  if entityType == Ent.ANALYTIC_DATASTREAM and 'parent' not in kwargs:
    missingArgumentExit('parent')
  if csvPF and FJQC.formatJSON:
    csvPF.SetJSONTitles(analyticEntityMap['JSONtitles'])
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, analytics = buildGAPIServiceObject(API.ANALYTICS_ADMIN, user, i, count)
    if not analytics:
      continue
    if entityType == Ent.ANALYTIC_ACCOUNT:
      service = analytics.accounts()
    elif entityType == Ent.ANALYTIC_ACCOUNT_SUMMARY:
      service = analytics.accountSummaries()
    elif entityType == Ent.ANALYTIC_DATASTREAM:
      service = analytics.properties().dataStreams()
    else: #  entityType == Ent.ANALYTIC_PROPERTY:
      service = analytics.properties()
    if csvPF:
      printGettingAllEntityItemsForWhom(entityType, user, i, count)
      pageMessage = getPageMessageForWhom()
    else:
      pageMessage = None
    try:
      results = callGAPIpages(service, 'list', analyticEntityMap['items'],
                              pageMessage=pageMessage,
                              throwReasons=[GAPI.PERMISSION_DENIED, GAPI.INVALID_ARGUMENT, GAPI.BAD_REQUEST, GAPI.INTERNAL_ERROR,
                                            GAPI.SERVICE_NOT_AVAILABLE],
                              **kwargs)
    except (GAPI.permissionDenied, GAPI.invalidArgument, GAPI.badRequest, GAPI.internalError) as e:
      entityActionFailedWarning([Ent.USER, user, entityType, None], str(e), i, count)
      continue
    except GAPI.serviceNotAvailable:
      userAnalyticsServiceNotEnabledWarning(user, i, count)
      continue
    jcount = len(results)
    if not csvPF:
      if not FJQC.formatJSON:
        entityPerformActionNumItems([Ent.USER, user], jcount, entityType)
      Ind.Increment()
      j = 0
      for item in results:
        j += 1
        if not FJQC.formatJSON:
          printEntity([entityType, item['name']], j, jcount)
          Ind.Increment()
          showJSON(None, item, timeObjects=analyticEntityMap['timeObjects'])
          Ind.Decrement()
        else:
          printLine(json.dumps(cleanJSON(item, timeObjects=analyticEntityMap['timeObjects']),
                               ensure_ascii=False, sort_keys=False))
      Ind.Decrement()
    else:
      for item in results:
        row = flattenJSON(item, flattened={'User': user}, timeObjects=analyticEntityMap['timeObjects'])
        if not FJQC.formatJSON:
          csvPF.WriteRowTitles(row)
        elif csvPF.CheckRowTitles(row):
          row = {'User': user, 'name': item['name'], 'displayName': item['displayName']}
          for field in analyticEntityMap['JSONtitles'][2:-1]:
            row[field] = item[field]
          row['JSON'] = json.dumps(cleanJSON(item, timeObjects=analyticEntityMap['timeObjects']),
                                   ensure_ascii=False, sort_keys=True)
          csvPF.WriteRowNoFilter(row)
  if csvPF:
    csvPF.writeCSVfile(Ent.Plural(entityType))

# gam <UserTypeEntity> print analyticaccounts [todrive <ToDriveAttribute>*]
#	[maxresults <Integer>] [showdeleted [<Boolean>]]
#	[formatjson [quotechar <Character>]]
# gam <UserTypeEntity> show analyticaccounts
#	[maxresults <Integer>] [showdeleted [<Boolean>]]
#	[formatjson]
def printShowAnalyticAccounts(users):
  printShowAnalyticItems(users, Ent.ANALYTIC_ACCOUNT)

# gam <UserTypeEntity> print analyticaccountsummaries [todrive <ToDriveAttribute>*]
#	[maxresults <Integer>]
#	[formatjson [quotechar <Character>]]
# gam <UserTypeEntity> show analyticaccountsummaries
#	[maxresults <Integer>]
#	[formatjson]
def printShowAnalyticAccountSummaries(users):
  printShowAnalyticItems(users, Ent.ANALYTIC_ACCOUNT_SUMMARY)

# gam <UserTypeEntity> print analyticproperties [todrive <ToDriveAttribute>*]
#	filter <String>
#	[maxresults <Integer>] [showdeleted [<Boolean>]]
#	[formatjson [quotechar <Character>]]
# gam <UserTypeEntity> show analyticproperties
#	filter <String>
#	[maxresults <Integer>] [showdeleted [<Boolean>]]
#	[formatjson]
def printShowAnalyticProperties(users):
  printShowAnalyticItems(users, Ent.ANALYTIC_PROPERTY)

# gam <UserTypeEntity> print analyticdatastreams [todrive <ToDriveAttribute>*]
#	parent <String>
#	[maxresults <Integer>]
#	[formatjson [quotechar <Character>]]
# gam <UserTypeEntity> show analyticdatastreams
#	parent <String>
#	[maxresults <Integer>]
#	[formatjson]
def printShowAnalyticDatastreams(users):
  printShowAnalyticItems(users, Ent.ANALYTIC_DATASTREAM)

# gam create domainalias|aliasdomain <DomainAlias> <DomainName>
def doCreateDomainAlias():
  cd = buildGAPIObject(API.DIRECTORY)
  body = {'domainAliasName': getString(Cmd.OB_DOMAIN_ALIAS)}
  body['parentDomainName'] = getString(Cmd.OB_DOMAIN_NAME)
  checkForExtraneousArguments()
  try:
    callGAPI(cd.domainAliases(), 'insert',
             throwReasons=[GAPI.DOMAIN_NOT_FOUND, GAPI.DUPLICATE, GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.NOT_FOUND,
                           GAPI.FORBIDDEN, GAPI.CONFLICT],
             customer=GC.Values[GC.CUSTOMER_ID], body=body, fields='')
    entityActionPerformed([Ent.DOMAIN, body['parentDomainName'], Ent.DOMAIN_ALIAS, body['domainAliasName']])
  except GAPI.domainNotFound:
    entityActionFailedWarning([Ent.DOMAIN, body['parentDomainName']], Msg.DOES_NOT_EXIST)
  except GAPI.duplicate:
    entityActionFailedWarning([Ent.DOMAIN, body['parentDomainName'], Ent.DOMAIN_ALIAS, body['domainAliasName']], Msg.DUPLICATE)
  except (GAPI.invalid, GAPI.conflict) as e:
    entityActionFailedWarning([Ent.DOMAIN, body['parentDomainName'], Ent.DOMAIN_ALIAS, body['domainAliasName']], str(e))
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden) as e:
    accessErrorExit(cd, str(e))

# gam delete domainalias|aliasdomain <DomainAlias>
def doDeleteDomainAlias():
  cd = buildGAPIObject(API.DIRECTORY)
  domainAliasName = getString(Cmd.OB_DOMAIN_ALIAS)
  checkForExtraneousArguments()
  try:
    callGAPI(cd.domainAliases(), 'delete',
             throwReasons=[GAPI.DOMAIN_ALIAS_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
             customer=GC.Values[GC.CUSTOMER_ID], domainAliasName=domainAliasName)
    entityActionPerformed([Ent.DOMAIN_ALIAS, domainAliasName])
  except GAPI.domainAliasNotFound:
    entityActionFailedWarning([Ent.DOMAIN_ALIAS, domainAliasName], Msg.DOES_NOT_EXIST)
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden) as e:
    accessErrorExit(cd, str(e))

DOMAIN_TIME_OBJECTS = {'creationTime'}
DOMAIN_ALIAS_PRINT_ORDER = ['parentDomainName', 'creationTime', 'verified']
DOMAIN_ALIAS_SKIP_OBJECTS = {'domainAliasName'}

def _showDomainAlias(alias, FJQC, aliasSkipObjects, i=0, count=0):
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(alias, timeObjects=DOMAIN_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.DOMAIN_ALIAS, alias['domainAliasName']], i, count)
  Ind.Increment()
  if 'creationTime' in alias:
    alias['creationTime'] = formatLocalTimestamp(alias['creationTime'])
  for field in DOMAIN_ALIAS_PRINT_ORDER:
    if field in alias:
      printKeyValueList([field, alias[field]])
      aliasSkipObjects.add(field)
  showJSON(None, alias, aliasSkipObjects)
  Ind.Decrement()

# gam info domainalias|aliasdomain <DomainAlias> [formatjson]
def doInfoDomainAlias():
  cd = buildGAPIObject(API.DIRECTORY)
  domainAliasName = getString(Cmd.OB_DOMAIN_ALIAS)
  FJQC = FormatJSONQuoteChar(formatJSONOnly=True)
  try:
    result = callGAPI(cd.domainAliases(), 'get',
                      throwReasons=[GAPI.DOMAIN_ALIAS_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                      customer=GC.Values[GC.CUSTOMER_ID], domainAliasName=domainAliasName)
    aliasSkipObjects = DOMAIN_ALIAS_SKIP_OBJECTS
    _showDomainAlias(result, FJQC, aliasSkipObjects)
  except GAPI.domainAliasNotFound:
    entityActionFailedWarning([Ent.DOMAIN_ALIAS, domainAliasName], Msg.DOES_NOT_EXIST)
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden) as e:
    accessErrorExit(cd, str(e))

def _printDomain(domain, csvPF):
  row = {}
  for attr in domain:
    if attr not in DEFAULT_SKIP_OBJECTS:
      if attr in DOMAIN_TIME_OBJECTS:
        row[attr] = formatLocalTimestamp(domain[attr])
      else:
        row[attr] = domain[attr]
      csvPF.AddTitles(attr)
  csvPF.WriteRow(row)

DOMAIN_ALIAS_SORT_TITLES = ['domainAliasName', 'parentDomainName', 'creationTime', 'verified']

# gam print domainaliases [todrive <ToDriveAttribute>*]
#	[formatjson [quotechar <Character>]]
#	[showitemcountonly]
# gam show domainaliases
#	[formatjson]
#	[showitemcountonly]
def doPrintShowDomainAliases():
  cd = buildGAPIObject(API.DIRECTORY)
  csvPF = CSVPrintFile(['domainAliasName'], DOMAIN_ALIAS_SORT_TITLES) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  showItemCountOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'showitemcountonly':
      showItemCountOnly = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  try:
    domainAliases = callGAPIitems(cd.domainAliases(), 'list', 'domainAliases',
                                  throwReasons=[GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                                  customer=GC.Values[GC.CUSTOMER_ID])
    count = len(domainAliases)
    if showItemCountOnly:
      writeStdout(f'{count}\n')
      return
    i = 0
    for domainAlias in domainAliases:
      i += 1
      if not csvPF:
        aliasSkipObjects = DOMAIN_ALIAS_SKIP_OBJECTS
        _showDomainAlias(domainAlias, FJQC, aliasSkipObjects, i, count)
      elif not FJQC.formatJSON:
        _printDomain(domainAlias, csvPF)
      else:
        csvPF.WriteRowNoFilter({'domainAliasName': domainAlias['domainAliasName'],
                                'JSON': json.dumps(cleanJSON(domainAlias, timeObjects=DOMAIN_TIME_OBJECTS),
                                                   ensure_ascii=False, sort_keys=True)})
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden) as e:
    accessErrorExit(cd, str(e))
  if csvPF:
    csvPF.writeCSVfile('Domain Aliases')

# gam create domain <DomainName>
def doCreateDomain():
  cd = buildGAPIObject(API.DIRECTORY)
  body = {'domainName': getString(Cmd.OB_DOMAIN_NAME)}
  checkForExtraneousArguments()
  try:
    callGAPI(cd.domains(), 'insert',
             throwReasons=[GAPI.DUPLICATE, GAPI.DOMAIN_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.CONFLICT],
             customer=GC.Values[GC.CUSTOMER_ID], body=body, fields='')
    entityActionPerformed([Ent.DOMAIN, body['domainName']])
  except GAPI.duplicate:
    entityDuplicateWarning([Ent.DOMAIN, body['domainName']])
  except GAPI.conflict as e:
    entityActionFailedWarning([Ent.DOMAIN, body['domainName']], str(e))
  except (GAPI.domainNotFound, GAPI.badRequest, GAPI.notFound, GAPI.forbidden) as e:
    accessErrorExit(cd, str(e))

# gam update domain <DomainName> primary
def doUpdateDomain():
  cd = buildGAPIObject(API.DIRECTORY)
  domainName = getString(Cmd.OB_DOMAIN_NAME)
  body = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'primary':
      body['customerDomain'] = domainName
    else:
      unknownArgumentExit()
  if not body:
    missingArgumentExit('primary')
  try:
    callGAPI(cd.customers(), 'update',
             throwReasons=[GAPI.DOMAIN_NOT_VERIFIED_SECONDARY, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID_INPUT],
             customerKey=GC.Values[GC.CUSTOMER_ID], body=body, fields='')
    entityActionPerformedMessage([Ent.DOMAIN, domainName], Msg.NOW_THE_PRIMARY_DOMAIN)
  except GAPI.domainNotVerifiedSecondary:
    entityActionFailedWarning([Ent.DOMAIN, domainName], Msg.DOMAIN_NOT_VERIFIED_SECONDARY)
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden, GAPI.invalidInput) as e:
    accessErrorExit(cd, str(e))

# gam delete domain <DomainName>
def doDeleteDomain():
  cd = buildGAPIObject(API.DIRECTORY)
  domainName = getString(Cmd.OB_DOMAIN_NAME)
  checkForExtraneousArguments()
  try:
    callGAPI(cd.domains(), 'delete',
             throwReasons=[GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
             customer=GC.Values[GC.CUSTOMER_ID], domainName=domainName)
    entityActionPerformed([Ent.DOMAIN, domainName])
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden) as e:
    accessErrorExit(cd, str(e))

CUSTOMER_LICENSE_MAP = {
  'accounts:num_users': 'Total Users',
  'accounts:gsuite_basic_total_licenses': 'G Suite Basic Licenses',
  'accounts:gsuite_basic_used_licenses': 'G Suite Basic Users',
  'accounts:gsuite_enterprise_total_licenses': 'Workspace Enterprise Plus Licenses',
  'accounts:gsuite_enterprise_used_licenses': 'Workspace Enterprise Plus Users',
  'accounts:gsuite_unlimited_total_licenses': 'G Suite Business Licenses',
  'accounts:gsuite_unlimited_used_licenses': 'G Suite Business Users',
  'accounts:vault_total_licenses': 'Google Vault Licenses',
  }

def _showCustomerLicenseInfo(customerInfo, FJQC):
  def numUsersAvailable(result):
    usageReports = result.get('usageReports', [])
    if usageReports:
      for item in usageReports[0].get('parameters', []):
        if item['name'] == 'accounts:num_users':
          return usageReports
    return None

  rep = buildGAPIObject(API.REPORTS)
  parameters = ','.join(CUSTOMER_LICENSE_MAP)
  tryDate = todaysDate().strftime(YYYYMMDD_FORMAT)
  dataRequiredServices = {'accounts'}
  while True:
    try:
      result = callGAPI(rep.customerUsageReports(), 'get',
                        throwReasons=[GAPI.INVALID, GAPI.FORBIDDEN],
                        date=tryDate, customerId=customerInfo['id'],
                        fields='warnings,usageReports', parameters=parameters)
      usageReports = numUsersAvailable(result)
      if usageReports:
        break
      fullData, tryDate, usageReports = _checkDataRequiredServices(result, tryDate, dataRequiredServices)
      if fullData < 0:
        printWarningMessage(DATA_NOT_AVALIABLE_RC, Msg.NO_USER_COUNTS_DATA_AVAILABLE)
        return
      if fullData == 0:
        continue
      break
    except GAPI.invalid as e:
      tryDate = _adjustTryDate(str(e), 0, -1, tryDate)
      if not tryDate:
        return
      continue
    except GAPI.forbidden:
      return
  if not FJQC.formatJSON:
    printKeyValueList([f'User counts as of {tryDate}:'])
    Ind.Increment()
  for item in usageReports[0]['parameters']:
    api_name = CUSTOMER_LICENSE_MAP.get(item['name'])
    api_value = int(item.get('intValue', '0'))
    if api_name and api_value:
      if not FJQC.formatJSON:
        printKeyValueList([api_name, f'{api_value:,}'])
      else:
        customerInfo[item['name']] = api_value
  if not FJQC.formatJSON:
    Ind.Decrement()

def setTrueCustomerId(cd=None):
  if GC.Values[GC.CUSTOMER_ID] == GC.MY_CUSTOMER:
    if not cd:
      cd = buildGAPIObject(API.DIRECTORY)
    try:
      customerInfo = callGAPI(cd.customers(), 'get',
                              throwReasons=[GAPI.BAD_REQUEST, GAPI.INVALID_INPUT, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                              customerKey=GC.MY_CUSTOMER,
                              fields='id')
      GC.Values[GC.CUSTOMER_ID] = customerInfo['id']
    except (GAPI.badRequest, GAPI.invalidInput, GAPI.resourceNotFound, GAPI.forbidden):
      pass

def _getCustomerId():
  customerId = GC.Values[GC.CUSTOMER_ID]
  if customerId != GC.MY_CUSTOMER and customerId[0] != 'C':
    customerId = 'C' + customerId
  return customerId

def _getCustomerIdNoC():
  customerId = GC.Values[GC.CUSTOMER_ID]
  if customerId[0] == 'C':
    return customerId[1:]
  return customerId

def _getCustomersCustomerIdNoC():
  customerId = GC.Values[GC.CUSTOMER_ID]
  if customerId.startswith('C'):
    customerId = customerId[1:]
  return f'customers/{customerId}'

def _getCustomersCustomerIdWithC():
  customerId = GC.Values[GC.CUSTOMER_ID]
  if customerId != GC.MY_CUSTOMER and customerId[0] != 'C':
    customerId = 'C' + customerId
  return f'customers/{customerId}'

# gam info customer [formatjson]
def doInfoCustomer(returnCustomerInfo=None, FJQC=None):
  cd = buildGAPIObject(API.DIRECTORY)
  customerId = _getCustomerId()
  if FJQC is None:
    FJQC = FormatJSONQuoteChar(formatJSONOnly=True)
  try:
    customerInfo = callGAPI(cd.customers(), 'get',
                            throwReasons=[GAPI.BAD_REQUEST, GAPI.INVALID_INPUT, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                            customerKey=customerId)
    if 'customerCreationTime' in customerInfo:
      customerInfo['customerCreationTime'] = formatLocalTime(customerInfo['customerCreationTime'])
    else:
      customerInfo['customerCreationTime'] =  UNKNOWN
    primaryDomain = {'domainName': UNKNOWN, 'verified': UNKNOWN}
    try:
      domains = callGAPIitems(cd.domains(), 'list', 'domains',
                              throwReasons=[GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                              customer=customerInfo['id'], fields='domains(creationTime,domainName,isPrimary,verified)')
      for domain in domains:
        if domain.get('isPrimary'):
          primaryDomain = domain
          break
      # From Jay Lee
      # If customer has changed primary domain, customerCreationTime is date of current primary being added, not customer create date.
      # We should get all domains and use oldest date
      customerCreationTime = UNKNOWN
      for domain in domains:
        domainCreationTime = formatLocalTimestampUTC(domain['creationTime'])
        if customerCreationTime == UNKNOWN or domainCreationTime < customerCreationTime:
          customerCreationTime = domainCreationTime
      customerInfo['customerCreationTime'] = formatLocalTime(customerCreationTime)
    except (GAPI.badRequest, GAPI.notFound):
      pass
    customerInfo['customerDomain'] = primaryDomain['domainName']
    customerInfo['verified'] = primaryDomain['verified']
    if FJQC.formatJSON:
      _showCustomerLicenseInfo(customerInfo, FJQC)
      if returnCustomerInfo is not None:
        returnCustomerInfo.update(customerInfo)
        return
      printLine(json.dumps(cleanJSON(customerInfo), ensure_ascii=False, sort_keys=True))
      return
    printKeyValueList(['Customer ID', customerInfo['id']])
    printKeyValueList(['Primary Domain', customerInfo['customerDomain']])
    printKeyValueList(['Primary Domain Verified', customerInfo['verified']])
    printKeyValueList(['Customer Creation Time', customerInfo['customerCreationTime']])
    printKeyValueList(['Default Language', customerInfo.get('language', 'Unset or Unknown (defaults to en)')])
    _showCustomerAddressPhoneNumber(customerInfo)
    printKeyValueList(['Admin Secondary Email', customerInfo.get('alternateEmail', UNKNOWN)])
    _showCustomerLicenseInfo(customerInfo, FJQC)
  except (GAPI.badRequest, GAPI.invalidInput, GAPI.domainNotFound, GAPI.notFound, GAPI.resourceNotFound):
    accessErrorExit(cd)
  except GAPI.forbidden as e:
    entityActionFailedExit([Ent.CUSTOMER_ID, customerId], str(e))

# gam update customer [primary <DomainName>] [adminsecondaryemail|alternateemail <EmailAddress>] [language <LanguageCode] [phone|phonenumber <String>]
#	[contact|contactname <String>] [name|organizationname <String>]
#	[address1|addressline1 <String>] [address2|addressline2 <String>] [address3|addressline3 <String>]
#	[locality <String>] [region <String>] [postalcode <String>] [country|countrycode <String>]
def doUpdateCustomer():
  cd = buildGAPIObject(API.DIRECTORY)
  customerId = _getCustomerId()
  body = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in ADDRESS_FIELDS_ARGUMENT_MAP:
      body.setdefault('postalAddress', {})
      body['postalAddress'][ADDRESS_FIELDS_ARGUMENT_MAP[myarg]] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg == 'primary':
      body['customerDomain'] = getString(Cmd.OB_DOMAIN_NAME)
    elif myarg in {'adminsecondaryemail', 'alternateemail'}:
      body['alternateEmail'] = getEmailAddress(noUid=True)
    elif myarg in {'phone', 'phonenumber'}:
      body['phoneNumber'] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg == 'language':
      body['language'] = getLanguageCode(LANGUAGE_CODES_MAP)
    else:
      unknownArgumentExit()
  if body:
    try:
      callGAPI(cd.customers(), 'patch',
               throwReasons=[GAPI.DOMAIN_NOT_VERIFIED_SECONDARY, GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
               customerKey=customerId, body=body, fields='')
      entityActionPerformed([Ent.CUSTOMER_ID, GC.Values[GC.CUSTOMER_ID]])
    except GAPI.domainNotVerifiedSecondary:
      entityActionFailedWarning([Ent.CUSTOMER_ID, GC.Values[GC.CUSTOMER_ID], Ent.DOMAIN, body['customerDomain']], Msg.DOMAIN_NOT_VERIFIED_SECONDARY)
    except (GAPI.invalid, GAPI.invalidInput) as e:
      entityActionFailedWarning([Ent.CUSTOMER_ID, GC.Values[GC.CUSTOMER_ID]], str(e))
    except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
      accessErrorExit(cd)

# gam info instance [formatjson]
def doInfoInstance():
  FJQC = FormatJSONQuoteChar(formatJSONOnly=True)
  customerInfo = None if not FJQC.formatJSON else {}
  doInfoCustomer(customerInfo, FJQC)
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(customerInfo), ensure_ascii=False, sort_keys=True))

DOMAIN_PRINT_ORDER = ['customerDomain', 'creationTime', 'isPrimary', 'verified']
DOMAIN_SKIP_OBJECTS = {'domainName', 'domainAliases'}

def _showDomain(result, FJQC, i=0, count=0):
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(result, timeObjects=DOMAIN_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  skipObjects = DOMAIN_SKIP_OBJECTS
  printEntity([Ent.DOMAIN, result['domainName']], i, count)
  Ind.Increment()
  if 'creationTime' in result:
    result['creationTime'] = formatLocalTimestamp(result['creationTime'])
  for field in DOMAIN_PRINT_ORDER:
    if field in result:
      printKeyValueList([field, result[field]])
      skipObjects.add(field)
  field = 'domainAliases'
  aliases = result.get(field)
  if aliases:
    skipObjects.add(field)
    aliasSkipObjects = DOMAIN_ALIAS_SKIP_OBJECTS
    for alias in aliases:
      _showDomainAlias(alias, FJQC, aliasSkipObjects)
      showJSON(None, alias, aliasSkipObjects)
  showJSON(None, result, skipObjects)
  Ind.Decrement()

# gam info domain [<DomainName>] [formatjson]
def doInfoDomain():
  if (not Cmd.ArgumentsRemaining()) or (Cmd.Current().lower() == 'formatjson'):
    doInfoInstance()
    return
  cd = buildGAPIObject(API.DIRECTORY)
  domainName = getString(Cmd.OB_DOMAIN_NAME)
  FJQC = FormatJSONQuoteChar(formatJSONOnly=True)
  try:
    result = callGAPI(cd.domains(), 'get',
                      throwReasons=[GAPI.DOMAIN_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                      customer=GC.Values[GC.CUSTOMER_ID], domainName=domainName)
    _showDomain(result, FJQC)
  except GAPI.domainNotFound:
    entityActionFailedWarning([Ent.DOMAIN, domainName], Msg.DOES_NOT_EXIST)
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden):
    accessErrorExit(cd)

DOMAIN_SORT_TITLES = ['domainName', 'parentDomainName', 'creationTime', 'type', 'verified']

# gam print domains [todrive <ToDriveAttribute>*]
#	[formatjson [quotechar <Character>]]
#	[showitemcountonly]
# gam show domains
#	[formatjson]
#	[showitemcountonly]
def doPrintShowDomains():
  cd = buildGAPIObject(API.DIRECTORY)
  csvPF = CSVPrintFile(['domainName'], DOMAIN_SORT_TITLES) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  showItemCountOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'showitemcountonly':
      showItemCountOnly = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  try:
    domains = callGAPIitems(cd.domains(), 'list', 'domains',
                            throwReasons=[GAPI.DOMAIN_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                            customer=GC.Values[GC.CUSTOMER_ID])
    count = len(domains)
    if showItemCountOnly:
      writeStdout(f'{count}\n')
      return
    i = 0
    for domain in domains:
      i += 1
      if not csvPF:
        _showDomain(domain, FJQC, i, count)
      elif not FJQC.formatJSON:
        domain['type'] = 'primary' if domain.pop('isPrimary') else 'secondary'
        domainAliases = domain.pop('domainAliases', [])
        _printDomain(domain, csvPF)
        for domainAlias in domainAliases:
          domainAlias['type'] = 'alias'
          domainAlias['domainName'] = domainAlias.pop('domainAliasName')
          _printDomain(domainAlias, csvPF)
      else:
        csvPF.WriteRowNoFilter({'domainName': domain['domainName'],
                                'JSON': json.dumps(cleanJSON(domain, timeObjects=DOMAIN_TIME_OBJECTS),
                                                   ensure_ascii=False, sort_keys=True)})
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden, GAPI.domainNotFound) as e:
    accessErrorExit(cd, str(e))
  if csvPF:
    csvPF.writeCSVfile('Domains')

PRINT_PRIVILEGES_FIELDS = ['serviceId', 'serviceName', 'privilegeName', 'isOuScopable', 'childPrivileges']

def _listPrivileges(cd):
  fields = f'items({",".join(PRINT_PRIVILEGES_FIELDS)})'
  try:
    return callGAPIitems(cd.privileges(), 'list', 'items',
                         throwReasons=[GAPI.BAD_REQUEST, GAPI.CUSTOMER_NOT_FOUND, GAPI.FORBIDDEN],
                         customer=GC.Values[GC.CUSTOMER_ID], fields=fields)
  except (GAPI.badRequest, GAPI.customerNotFound, GAPI.forbidden):
    accessErrorExit(cd)

# gam print privileges [todrive <ToDriveAttribute>*]
# gam show privileges
def doPrintShowPrivileges():
  def _showPrivilege(privilege, i, count):
    printEntity([Ent.PRIVILEGE, privilege['privilegeName']], i, count)
    Ind.Increment()
    printKeyValueList(['serviceId', privilege['serviceId']])
    printKeyValueList(['serviceName', privilege.get('serviceName', UNKNOWN)])
    printKeyValueList(['isOuScopable', privilege['isOuScopable']])
    jcount = len(privilege.get('childPrivileges', []))
    if jcount > 0:
      printKeyValueList(['childPrivileges', jcount])
      Ind.Increment()
      j = 0
      for childPrivilege in privilege['childPrivileges']:
        j += 1
        _showPrivilege(childPrivilege, j, jcount)
      Ind.Decrement()
    Ind.Decrement()

  cd = buildGAPIObject(API.DIRECTORY)
  csvPF = CSVPrintFile(PRINT_PRIVILEGES_FIELDS, 'sortall') if Act.csvFormat() else None
  getTodriveOnly(csvPF)
  privileges = _listPrivileges(cd)
  if not csvPF:
    count = len(privileges)
    performActionNumItems(count, Ent.PRIVILEGE)
    Ind.Increment()
    i = 0
    for privilege in privileges:
      i += 1
      _showPrivilege(privilege, i, count)
    Ind.Decrement()
  else:
    for privilege in privileges:
      csvPF.WriteRowTitles(flattenJSON(privilege))
  if csvPF:
    csvPF.writeCSVfile('Privileges')

def makeRoleIdNameMap():
  GM.Globals[GM.MAKE_ROLE_ID_NAME_MAP] = False
  cd = buildGAPIObject(API.DIRECTORY)
  try:
    result = callGAPIpages(cd.roles(), 'list', 'items',
                           throwReasons=[GAPI.BAD_REQUEST, GAPI.CUSTOMER_NOT_FOUND, GAPI.FORBIDDEN],
                           customer=GC.Values[GC.CUSTOMER_ID],
                           fields='nextPageToken,items(roleId,roleName)',
                           maxResults=100)
  except (GAPI.badRequest, GAPI.customerNotFound, GAPI.forbidden):
    accessErrorExit(cd)
  for role in result:
    GM.Globals[GM.MAP_ROLE_ID_TO_NAME][role['roleId']] = role['roleName']
    GM.Globals[GM.MAP_ROLE_NAME_TO_ID][role['roleName'].lower()] = role['roleId']

def role_from_roleid(roleid):
  if GM.Globals[GM.MAKE_ROLE_ID_NAME_MAP]:
    makeRoleIdNameMap()
  return GM.Globals[GM.MAP_ROLE_ID_TO_NAME].get(roleid, roleid)

def roleid_from_role(role):
  if GM.Globals[GM.MAKE_ROLE_ID_NAME_MAP]:
    makeRoleIdNameMap()
  return GM.Globals[GM.MAP_ROLE_NAME_TO_ID].get(role.lower(), None)

def getRoleId():
  role = getString(Cmd.OB_ROLE_ITEM)
  cg = UID_PATTERN.match(role)
  if cg:
    roleId = cg.group(1)
  else:
    roleId = roleid_from_role(role)
    if not roleId:
      invalidChoiceExit(role, GM.Globals[GM.MAP_ROLE_NAME_TO_ID], True)
  return (role, roleId)

# gam create adminrole <String> [description <String>]
#	privileges all|all_ou|<PrivilegesList>|(select <FileSelector>|<CSVFileSelector>)
# gam update adminrole <RoleItem> [name <String>] [description <String>]
#	[privileges all|all_ou|<PrivilegesList>|(select <FileSelector>|<CSVFileSelector>)]
def doCreateUpdateAdminRoles():
  def expandChildPrivileges(privilege):
    for childPrivilege in privilege.get('childPrivileges', []):
      childPrivileges[childPrivilege['privilegeName']] = childPrivilege['serviceId']
      expandChildPrivileges(childPrivilege)

  cd = buildGAPIObject(API.DIRECTORY)
  updateCmd = Act.Get() == Act.UPDATE
  if not updateCmd:
    body = {'roleName': getString(Cmd.OB_STRING)}
  else:
    body = {}
    _, roleId = getRoleId()
  allPrivileges = {}
  ouPrivileges = {}
  childPrivileges = {}
  for privilege in _listPrivileges(cd):
    allPrivileges[privilege['privilegeName']] = privilege['serviceId']
    if privilege['isOuScopable']:
      ouPrivileges[privilege['privilegeName']] = privilege['serviceId']
    expandChildPrivileges(privilege)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'privileges':
      privs = getString(Cmd.OB_PRIVILEGE_LIST).upper()
      if privs == 'ALL':
        body['rolePrivileges'] = [{'privilegeName': p, 'serviceId': v} for p, v in allPrivileges.items()]
      elif privs == 'ALL_OU':
        body['rolePrivileges'] = [{'privilegeName': p, 'serviceId': v} for p, v in ouPrivileges.items()]
      else:
        if privs == 'SELECT':
          privsList = [p.upper() for p in getEntityList(Cmd.OB_PRIVILEGE_LIST)]
        else:
          privsList = privs.replace(',', ' ').split()
        body.setdefault('rolePrivileges', [])
        for p in privsList:
          if p in allPrivileges:
            body['rolePrivileges'].append({'privilegeName': p, 'serviceId': allPrivileges[p]})
          elif p in ouPrivileges:
            body['rolePrivileges'].append({'privilegeName': p, 'serviceId': ouPrivileges[p]})
          elif p in childPrivileges:
            body['rolePrivileges'].append({'privilegeName': p, 'serviceId': childPrivileges[p]})
          elif ':' in p:
            priv, serv = p.split(':')
            body['rolePrivileges'].append({'privilegeName': priv, 'serviceId': serv.lower()})
          elif p == 'SUPPORT':
            pass
          else:
            invalidChoiceExit(p, list(allPrivileges.keys())+list(ouPrivileges.keys())+list(childPrivileges.keys()), True)
    elif myarg == 'description':
      body['roleDescription'] = getString(Cmd.OB_STRING)
    elif myarg == 'name':
      body['roleName'] = getString(Cmd.OB_STRING)
    else:
      unknownArgumentExit()
  if not updateCmd and not body.get('rolePrivileges'):
    missingArgumentExit('privileges')
  try:
    if not updateCmd:
      result = callGAPI(cd.roles(), 'insert',
                        throwReasons=[GAPI.BAD_REQUEST, GAPI.CUSTOMER_NOT_FOUND, GAPI.FORBIDDEN]+[GAPI.DUPLICATE],
                        customer=GC.Values[GC.CUSTOMER_ID], body=body, fields='roleId,roleName')
    else:
      result = callGAPI(cd.roles(), 'patch',
                        throwReasons=[GAPI.BAD_REQUEST, GAPI.CUSTOMER_NOT_FOUND, GAPI.FORBIDDEN]+[GAPI.NOT_FOUND, GAPI.FAILED_PRECONDITION, GAPI.CONFLICT],
                        customer=GC.Values[GC.CUSTOMER_ID], roleId=roleId, body=body, fields='roleId,roleName')
    entityActionPerformed([Ent.ADMIN_ROLE, f"{result['roleName']}({result['roleId']})"])
  except GAPI.duplicate as e:
    entityActionFailedWarning([Ent.ADMIN_ROLE, f"{body['roleName']}"], str(e))
  except (GAPI.notFound, GAPI.forbidden, GAPI.failedPrecondition, GAPI.conflict) as e:
    entityActionFailedWarning([Ent.ADMIN_ROLE, roleId], str(e))
  except (GAPI.badRequest, GAPI.customerNotFound):
    accessErrorExit(cd)

# gam delete adminrole <RoleItem>
def doDeleteAdminRole():
  cd = buildGAPIObject(API.DIRECTORY)
  role, roleId = getRoleId()
  checkForExtraneousArguments()
  try:
    callGAPI(cd.roles(), 'delete',
             throwReasons=[GAPI.BAD_REQUEST, GAPI.CUSTOMER_NOT_FOUND, GAPI.FORBIDDEN]+[GAPI.NOT_FOUND, GAPI.FAILED_PRECONDITION],
             customer=GC.Values[GC.CUSTOMER_ID], roleId=roleId)
    entityActionPerformed([Ent.ADMIN_ROLE, f"{role}({roleId})"])
  except (GAPI.notFound, GAPI.forbidden, GAPI.failedPrecondition) as e:
    entityActionFailedWarning([Ent.ADMIN_ROLE, roleId], str(e))
  except (GAPI.badRequest, GAPI.customerNotFound):
    accessErrorExit(cd)

PRINT_ADMIN_ROLES_FIELDS = ['roleId', 'roleName', 'roleDescription', 'isSuperAdminRole', 'isSystemRole']

def _showAdminRole(role, i=0, count=0):
  printEntity([Ent.ADMIN_ROLE, role['roleName']], i, count)
  Ind.Increment()
  for field in PRINT_ADMIN_ROLES_FIELDS:
    if field != 'roleName' and field in role:
      printKeyValueList([field, role[field]])
  jcount = len(role.get('rolePrivileges', []))
  if jcount > 0:
    printKeyValueList(['rolePrivileges', jcount])
    Ind.Increment()
    j = 0
    for rolePrivilege in role['rolePrivileges']:
      j += 1
      printKeyValueList(['privilegeName', rolePrivilege['privilegeName']])
      Ind.Increment()
      printKeyValueList(['serviceId', rolePrivilege['serviceId']])
      Ind.Decrement()
    Ind.Decrement()
  Ind.Decrement()

# gam info adminrole <RoleItem> [privileges]
# gam print adminroles|roles [todrive <ToDriveAttribute>*]
#	[role <RoleItem>] [privileges] [oneitemperrow]
# gam show adminroles|roles
#	[role <RoleItem>] [privileges]
def doInfoPrintShowAdminRoles():
  cd = buildGAPIObject(API.DIRECTORY)
  fieldsList = PRINT_ADMIN_ROLES_FIELDS[:]
  csvPF = CSVPrintFile(fieldsList, PRINT_ADMIN_ROLES_FIELDS) if Act.csvFormat() else None
  oneItemPerRow = False
  if Act.Get() != Act.INFO:
    roleId = None
  else:
    _, roleId = getRoleId()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif roleId is None and myarg == 'role':
      _, roleId = getRoleId()
    elif myarg == 'privileges':
      fieldsList.append('rolePrivileges')
    elif myarg == 'oneitemperrow':
      oneItemPerRow = True
    else:
      unknownArgumentExit()
  if csvPF and 'rolePrivileges' in fieldsList:
    if not oneItemPerRow:
      csvPF.AddTitles(['rolePrivileges'])
    else:
      csvPF.AddTitles(['privilegeName', 'serviceId'])
  try:
    if roleId is None:
      fields = getItemFieldsFromFieldsList('items', fieldsList)
      printGettingAllAccountEntities(Ent.ADMIN_ROLE)
      roles = callGAPIpages(cd.roles(), 'list', 'items',
                            pageMessage=getPageMessage(),
                            throwReasons=[GAPI.BAD_REQUEST, GAPI.CUSTOMER_NOT_FOUND, GAPI.FORBIDDEN],
                            customer=GC.Values[GC.CUSTOMER_ID], fields=fields)
    else:
      fields = getFieldsFromFieldsList(fieldsList)
      roles = [callGAPI(cd.roles(), 'get',
                        throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.FAILED_PRECONDITION,
                                      GAPI.BAD_REQUEST, GAPI.CUSTOMER_NOT_FOUND],
                        customer=GC.Values[GC.CUSTOMER_ID], roleId=roleId, fields=fields)]
  except (GAPI.notFound, GAPI.forbidden, GAPI.failedPrecondition) as e:
    entityActionFailedWarning([Ent.ADMIN_ROLE, roleId], str(e))
  except (GAPI.badRequest, GAPI.customerNotFound):
    accessErrorExit(cd)
  for role in roles:
    role.setdefault('isSuperAdminRole', False)
    role.setdefault('isSystemRole', False)
  if not csvPF:
    count = len(roles)
    performActionNumItems(count, Ent.ADMIN_ROLE)
    Ind.Increment()
    i = 0
    for role in roles:
      i += 1
      _showAdminRole(role, i, count)
    Ind.Decrement()
  else:
    for role in roles:
      if not oneItemPerRow or 'rolePrivileges' not in role:
        csvPF.WriteRowTitles(flattenJSON(role))
      else:
        privileges = role.pop('rolePrivileges')
        baserow = flattenJSON(role)
        for privilege in privileges:
          row = flattenJSON(privilege, flattened=baserow.copy())
          csvPF.WriteRowTitles(row)
  if csvPF:
    csvPF.writeCSVfile('Admin Roles')

ADMIN_SCOPE_TYPE_CHOICE_MAP = {'customer': 'CUSTOMER', 'orgunit': 'ORG_UNIT', 'org': 'ORG_UNIT', 'ou': 'ORG_UNIT'}

SECURITY_GROUP_CONDITION = "api.getAttribute('cloudidentity.googleapis.com/groups.labels', []).hasAny(['groups.security']) && resource.type == 'cloudidentity.googleapis.com/Group'"
NONSECURITY_GROUP_CONDITION = f'!{SECURITY_GROUP_CONDITION}'
ADMIN_CONDITION_CHOICE_MAP = {
  'securitygroup': SECURITY_GROUP_CONDITION,
  'nonsecuritygroup': NONSECURITY_GROUP_CONDITION,
  }

# gam create admin <EmailAddress>|<UniqueID> <RoleItem> customer|(org_unit <OrgUnitItem>)
#	[condition securitygroup|nonsecuritygroup]
def doCreateAdmin():
  cd = buildGAPIObject(API.DIRECTORY)
  user = getEmailAddress(returnUIDprefix='uid:')
  body = {'assignedTo': convertEmailAddressToUID(user, cd, emailType='any')}
  role, roleId = getRoleId()
  body['roleId'] = roleId
  body['scopeType'] = getChoice(ADMIN_SCOPE_TYPE_CHOICE_MAP, mapChoice=True)
  if body['scopeType'] == 'ORG_UNIT':
    orgUnit, orgUnitId = getOrgUnitId(cd)
    body['orgUnitId'] = orgUnitId[3:]
    scope = f'ORG_UNIT {orgUnit}'
  else:
    scope = 'CUSTOMER'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'condition':
      body['condition'] = getChoice(ADMIN_CONDITION_CHOICE_MAP, mapChoice=True)
    else:
      unknownArgumentExit()
  try:
    result = callGAPI(cd.roleAssignments(), 'insert',
                      throwReasons=[GAPI.INTERNAL_ERROR, GAPI.BAD_REQUEST, GAPI.CUSTOMER_NOT_FOUND,
                                    GAPI.FORBIDDEN, GAPI.CUSTOMER_EXCEEDED_ROLE_ASSIGNMENTS_LIMIT, GAPI.SERVICE_NOT_AVAILABLE,
                                    GAPI.INVALID_ORGUNIT, GAPI.DUPLICATE, GAPI.CONDITION_NOT_MET],
                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                      customer=GC.Values[GC.CUSTOMER_ID], body=body, fields='roleAssignmentId,assigneeType')
    assigneeType = result.get('assigneeType')
    if assigneeType == 'user':
      entityType = Ent.USER
    elif assigneeType == 'group':
      entityType = Ent.GROUP
    else:
      entityType = Ent.ADMINISTRATOR
    entityActionPerformedMessage([Ent.ADMIN_ROLE_ASSIGNMENT, result['roleAssignmentId']],
                                 f'{Ent.Singular(entityType)} {user}, {Ent.Singular(Ent.ADMIN_ROLE)} {role}, {Ent.Singular(Ent.SCOPE)} {scope}')
  except GAPI.internalError:
    pass
  except (GAPI.badRequest, GAPI.customerNotFound):
    accessErrorExit(cd)
  except (GAPI.forbidden, GAPI.customerExceededRoleAssignmentsLimit, GAPI.serviceNotAvailable, GAPI.conditionNotMet) as e:
    entityActionFailedWarning([Ent.ADMINISTRATOR, user, Ent.ADMIN_ROLE, role], str(e))
  except GAPI.invalidOrgunit:
    entityActionFailedWarning([Ent.ADMINISTRATOR, user], Msg.INVALID_ORGUNIT)
  except GAPI.duplicate:
    entityActionFailedWarning([Ent.ADMINISTRATOR, user, Ent.ADMIN_ROLE, role], Msg.DUPLICATE)

# gam delete admin <RoleAssignmentId>
def doDeleteAdmin():
  cd = buildGAPIObject(API.DIRECTORY)
  roleAssignmentId = getString(Cmd.OB_ROLE_ASSIGNMENT_ID)
  checkForExtraneousArguments()
  try:
    callGAPI(cd.roleAssignments(), 'delete',
             throwReasons=[GAPI.NOT_FOUND, GAPI.OPERATION_NOT_SUPPORTED, GAPI.FORBIDDEN,
                           GAPI.INVALID_INPUT, GAPI.SERVICE_NOT_AVAILABLE, GAPI.RESOURCE_NOT_FOUND,
                           GAPI.FAILED_PRECONDITION, GAPI.BAD_REQUEST, GAPI.CUSTOMER_NOT_FOUND],
             retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
             customer=GC.Values[GC.CUSTOMER_ID], roleAssignmentId=roleAssignmentId)
    entityActionPerformed([Ent.ADMIN_ROLE_ASSIGNMENT, roleAssignmentId])
  except (GAPI.notFound, GAPI.operationNotSupported, GAPI.forbidden,
          GAPI.invalidInput, GAPI.serviceNotAvailable, GAPI.resourceNotFound, GAPI.failedPrecondition) as e:
    entityActionFailedWarning([Ent.ADMIN_ROLE_ASSIGNMENT, roleAssignmentId], str(e))
  except (GAPI.badRequest, GAPI.customerNotFound):
    accessErrorExit(cd)

ASSIGNEE_EMAILTYPE_TOFIELD_MAP = {
  'user': 'assignedToUser',
  'group': 'assignedToGroup',
  'serviceaccount': 'assignedToServiceAccount',
  }
PRINT_ADMIN_FIELDS = ['roleAssignmentId', 'roleId', 'assignedTo', 'scopeType', 'orgUnitId']
PRINT_ADMIN_TITLES = ['roleAssignmentId', 'roleId', 'role',
                      'assignedTo', 'assignedToUser', 'assignedToGroup', 'assignedToServiceAccount', 'assignedToUnknown',
                      'scopeType', 'orgUnitId', 'orgUnit']

# gam print admins [todrive <ToDriveAttribute>*]
#	[user|group <EmailAddress>|<UniqueID>] [role <RoleItem>] [condition]
#	[privileges] [oneitemperrow]
# gam show admins
#	[user|group <EmailAddress>|<UniqueID>] [role <RoleItem>] [condition] [privileges]
def doPrintShowAdmins():
  def _getPrivileges(admin):
    if showPrivileges:
      roleId = admin['roleId']
      if roleId not in rolePrivileges:
        try:
          rolePrivileges[roleId] = callGAPI(cd.roles(), 'get',
                                            throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.FAILED_PRECONDITION,
                                                          GAPI.SERVICE_NOT_AVAILABLE, GAPI.BAD_REQUEST, GAPI.CUSTOMER_NOT_FOUND],
                                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                            customer=GC.Values[GC.CUSTOMER_ID],
                                            roleId=roleId,
                                            fields='rolePrivileges')
        except (GAPI.notFound, GAPI.forbidden, GAPI.failedPrecondition, GAPI.serviceNotAvailable) as e:
          entityActionFailedExit([Ent.USER, userKey, Ent.ADMIN_ROLE, admin['roleId']], str(e))
          rolePrivileges[roleId] = None
        except (GAPI.badRequest, GAPI.customerNotFound):
          accessErrorExit(cd)
      return rolePrivileges[roleId]

  def _setNamesFromIds(admin, privileges):
    admin['role'] = role_from_roleid(admin['roleId'])
    assignedTo = admin['assignedTo']
    admin['assignedToUnknown'] = False
    if assignedTo not in assignedToIdEmailMap:
      assigneeType = admin.get('assigneeType')
      assignedToField = ASSIGNEE_EMAILTYPE_TOFIELD_MAP.get(assigneeType, None)
      assigneeEmail, assigneeType = convertUIDtoEmailAddressWithType(f'uid:{assignedTo}',
                                                                     cd,
                                                                     sal,
                                                                     emailTypes=list(ASSIGNEE_EMAILTYPE_TOFIELD_MAP.keys()))
      if not assignedToField and assigneeType in ASSIGNEE_EMAILTYPE_TOFIELD_MAP:
        assignedToField = ASSIGNEE_EMAILTYPE_TOFIELD_MAP[assigneeType]
      if assigneeType == 'unknown':
        assignedToField = 'assignedToUnknown'
        assigneeEmail = True
      assignedToIdEmailMap[assignedTo] = {'assignedToField': assignedToField, 'assigneeEmail': assigneeEmail}
    admin[assignedToIdEmailMap[assignedTo]['assignedToField']] = assignedToIdEmailMap[assignedTo]['assigneeEmail']
    if privileges is not None:
      admin.update(privileges)
    if 'orgUnitId' in admin:
      admin['orgUnit'] = convertOrgUnitIDtoPath(cd, f'id:{admin["orgUnitId"]}')
    if 'condition' in admin:
      if admin['condition'] == SECURITY_GROUP_CONDITION:
        admin['condition'] = 'securitygroup'
      elif admin['condition'] == NONSECURITY_GROUP_CONDITION:
        admin['condition'] = 'nonsecuritygroup'

  cd = buildGAPIObject(API.DIRECTORY)
  sal = buildGAPIObject(API.SERVICEACCOUNTLOOKUP)
  csvPF = CSVPrintFile(PRINT_ADMIN_TITLES) if Act.csvFormat() else None
  roleId = None
  userKey = None
  oneItemPerRow = showPrivileges = False
  kwargs = {}
  rolePrivileges = {}
  fieldsList = PRINT_ADMIN_FIELDS
  assignedToIdEmailMap = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'user', 'group'}:
      userKey = kwargs['userKey'] = getEmailAddress()
    elif myarg == 'role':
      _, roleId = getRoleId()
    elif myarg == 'condition':
      fieldsList.append('condition')
      if csvPF:
        csvPF.AddTitle('condition')
    elif myarg == 'privileges':
      showPrivileges = True
    elif myarg == 'oneitemperrow':
      oneItemPerRow = True
    else:
      unknownArgumentExit()
  if roleId and not kwargs:
    kwargs['roleId'] = roleId
    roleId = None
  fields = getItemFieldsFromFieldsList('items', fieldsList)
  printGettingAllAccountEntities(Ent.ADMIN_ROLE_ASSIGNMENT)
  try:
    admins = callGAPIpages(cd.roleAssignments(), 'list', 'items',
                           pageMessage=getPageMessage(),
                           throwReasons=[GAPI.INVALID, GAPI.USER_NOT_FOUND,
                                         GAPI.FORBIDDEN, GAPI.SERVICE_NOT_AVAILABLE,
                                         GAPI.BAD_REQUEST, GAPI.CUSTOMER_NOT_FOUND],
                           retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                           customer=GC.Values[GC.CUSTOMER_ID], fields=fields, **kwargs)
  except (GAPI.invalid, GAPI.userNotFound):
    entityUnknownWarning(Ent.ADMINISTRATOR, userKey)
    return
  except (GAPI.forbidden, GAPI.serviceNotAvailable) as e:
    entityActionFailedExit([Ent.ADMINISTRATOR, userKey, Ent.ADMIN_ROLE, roleId], str(e))
  except (GAPI.badRequest, GAPI.customerNotFound):
    accessErrorExit(cd)
  if not csvPF:
    count = len(admins)
    performActionNumItems(count, Ent.ADMIN_ROLE_ASSIGNMENT)
    Ind.Increment()
    i = 0
    for admin in admins:
      i += 1
      if roleId and roleId != admin['roleId']:
        continue
      _setNamesFromIds(admin, _getPrivileges(admin))
      printEntity([Ent.ADMIN_ROLE_ASSIGNMENT, admin['roleAssignmentId']], i, count)
      Ind.Increment()
      for field in PRINT_ADMIN_TITLES:
        if field in admin:
          if field == 'roleAssignmentId':
            continue
          if field != 'rolePrivileges':
            printKeyValueList([field, admin[field]])
          else:
            showJSON(None, admin[field])
      Ind.Decrement()
    Ind.Decrement()
  else:
    for admin in admins:
      if roleId and roleId != admin['roleId']:
        continue
      _setNamesFromIds(admin, _getPrivileges(admin))
      if not oneItemPerRow or 'rolePrivileges' not in admin:
        csvPF.WriteRowTitles(flattenJSON(admin))
      else:
        privileges = admin.pop('rolePrivileges')
        baserow = flattenJSON(admin)
        for privilege in privileges:
          row = flattenJSON(privilege, flattened=baserow.copy())
          csvPF.WriteRowTitles(row)
  if csvPF:
    csvPF.writeCSVfile('Admins')

def getTransferApplications(dt):
  try:
    return callGAPIpages(dt.applications(), 'list', 'applications',
                         throwReasons=[GAPI.UNKNOWN_ERROR, GAPI.FORBIDDEN],
                         customerId=GC.Values[GC.CUSTOMER_ID], fields='applications(id,name,transferParams)')
  except (GAPI.unknownError, GAPI.forbidden):
    accessErrorExit(None)

def _convertTransferAppIDtoName(apps, appID):
  for app in apps:
    if appID == app['id']:
      return app['name']
  return f'applicationId: {appID}'

DRIVE_AND_DOCS_APP_NAME = 'drive and docs'
GOOGLE_DATA_STUDIO_APP_NAME = 'google data studio'

SERVICE_NAME_CHOICE_MAP = {
  'datastudio': GOOGLE_DATA_STUDIO_APP_NAME,
  'drive': DRIVE_AND_DOCS_APP_NAME,
  'googledrive': DRIVE_AND_DOCS_APP_NAME,
  'gdrive': DRIVE_AND_DOCS_APP_NAME,
  'lookerstudio': GOOGLE_DATA_STUDIO_APP_NAME,
  }

def _validateTransferAppName(apps, appName):
  appName = appName.strip().lower()
  appName = SERVICE_NAME_CHOICE_MAP.get(appName, appName)
  appNameList = []
  for app in apps:
    if appName == app['name'].lower():
      return (app['name'], app['id'])
    appNameList.append(app['name'].lower())
  invalidChoiceExit(appName, appNameList, True)

PRIVACY_LEVEL_CHOICE_MAP = {
  'private': ['PRIVATE'],
  'shared': ['SHARED'],
  'all': ['PRIVATE', 'SHARED'],
  }

# gam create datatransfer|transfer <OldOwnerID> <ServiceNameList> <NewOwnerID>
#	[private|shared|all] [release_resources] (<ParameterKey> <ParameterValue>)*
#	[wait <Integer> <Integer>]
def doCreateDataTransfer():
  def _assignAppParameter(key, value, doubleBackup=False):
    keyValid = False
    for app in apps:
      for params in app.get('transferParams', []):
        if key == params['key']:
          appIndex = appIndicies.get(app['id'])
          if appIndex is not None:
            body['applicationDataTransfers'][appIndex].setdefault('applicationTransferParams', [])
            body['applicationDataTransfers'][appIndex]['applicationTransferParams'].append({'key': key, 'value': value})
            keyValid = True
          break
    if not keyValid:
      Cmd.Backup()
      if doubleBackup:
        Cmd.Backup()
      usageErrorExit(Msg.NO_DATA_TRANSFER_APP_FOR_PARAMETER.format(key))

  dt = buildGAPIObject(API.DATATRANSFER)
  apps = getTransferApplications(dt)
  old_owner = getEmailAddress(returnUIDprefix='uid:')
  body = {'oldOwnerUserId': convertEmailAddressToUID(old_owner)}
  appIndicies = {}
  appNameList = []
  waitInterval = waitRetries = 0
  i = 0
  body['applicationDataTransfers'] = []
  for appName in getString(Cmd.OB_SERVICE_NAME_LIST).split(','):
    appName, appId = _validateTransferAppName(apps, appName)
    body['applicationDataTransfers'].append({'applicationId': appId})
    appIndicies[appId] = i
    i += 1
    appNameList.append(appName)
  new_owner = getEmailAddress(returnUIDprefix='uid:')
  body['newOwnerUserId'] = convertEmailAddressToUID(new_owner)
  if body['oldOwnerUserId'] == body['newOwnerUserId']:
    Cmd.Backup()
    usageErrorExit(Msg.NEW_OWNER_MUST_DIFFER_FROM_OLD_OWNER)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in PRIVACY_LEVEL_CHOICE_MAP:
      _assignAppParameter('PRIVACY_LEVEL', PRIVACY_LEVEL_CHOICE_MAP[myarg])
    elif myarg == 'releaseresources':
      if getBoolean():
        _assignAppParameter('RELEASE_RESOURCES', ['TRUE'])
    elif myarg == 'wait':
      waitInterval = getInteger(minVal=5, maxVal=60)
      waitRetries = getInteger(minVal=0)
    else:
      _assignAppParameter(Cmd.Previous().upper(), getString(Cmd.OB_PARAMETER_VALUE).upper().split(','), True)
  try:
    result = callGAPI(dt.transfers(), 'insert',
                      throwReasons=[GAPI.UNKNOWN_ERROR, GAPI.FORBIDDEN],
                      body=body, fields='id')
  except (GAPI.unknownError, GAPI.forbidden) as e:
    entityActionFailedExit([Ent.USER, old_owner], str(e))
  entityActionPerformed([Ent.TRANSFER_REQUEST, None])
  Ind.Increment()
  printEntity([Ent.TRANSFER_ID, result['id']])
  printEntity([Ent.SERVICE, ','.join(appNameList)])
  printKeyValueList([Msg.FROM, old_owner])
  printKeyValueList([Msg.TO, new_owner])
  Ind.Decrement()
  if waitRetries == 0:
    return
  retry = 0
  status = 'inProgress'
  dtId = result['id']
  while True:
    writeStderr(Ind.Spaces()+Msg.WAITING_FOR_DATA_TRANSFER_TO_COMPLETE_SLEEPING.format(waitInterval))
    time.sleep(waitInterval)
    try:
      result = callGAPI(dt.transfers(), 'get',
                        throwReasons=[GAPI.NOT_FOUND],
                        dataTransferId=dtId, fields='overallTransferStatusCode')
      if result['overallTransferStatusCode'] == 'completed':
        status = result['overallTransferStatusCode']
        break
      retry += 1
      if retry >= waitRetries:
        break
    except GAPI.notFound:
      entityActionFailedWarning([Ent.TRANSFER_ID, dtId], Msg.DOES_NOT_EXIST)
      break
  printEntity([Ent.TRANSFER_ID, dtId, Ent.STATUS, status])

def _showTransfer(apps, transfer, i, count):
  printEntity([Ent.TRANSFER_ID, transfer['id']], i, count)
  Ind.Increment()
  printKeyValueList(['Request Time', formatLocalTime(transfer['requestTime'])])
  printKeyValueList(['Old Owner', convertUserIDtoEmail(transfer['oldOwnerUserId'])])
  printKeyValueList(['New Owner', convertUserIDtoEmail(transfer['newOwnerUserId'])])
  printKeyValueList(['Overall Transfer Status', transfer['overallTransferStatusCode']])
  for app in transfer['applicationDataTransfers']:
    printKeyValueList(['Application', _convertTransferAppIDtoName(apps, app['applicationId'])])
    Ind.Increment()
    printKeyValueList(['Status', app['applicationTransferStatus']])
    printKeyValueList(['Parameters'])
    Ind.Increment()
    if 'applicationTransferParams' in app:
      for param in app['applicationTransferParams']:
        key = param['key']
        value = param.get('value', [])
        if value:
          printKeyValueList([key, ','.join(value)])
        else:
          printKeyValueList([key])
    else:
      printKeyValueList(['None'])
    Ind.Decrement()
    Ind.Decrement()
  Ind.Decrement()

# gam info datatransfer|transfer <TransferID>
def doInfoDataTransfer():
  dt = buildGAPIObject(API.DATATRANSFER)
  apps = getTransferApplications(dt)
  dtId = getString(Cmd.OB_TRANSFER_ID)
  checkForExtraneousArguments()
  try:
    transfer = callGAPI(dt.transfers(), 'get',
                        throwReasons=[GAPI.NOT_FOUND],
                        dataTransferId=dtId)
    _showTransfer(apps, transfer, 0, 0)
  except GAPI.notFound:
    entityActionFailedWarning([Ent.TRANSFER_ID, dtId], Msg.DOES_NOT_EXIST)

DATA_TRANSFER_STATUS_MAP = {
  'completed': 'completed',
  'failed': 'failed',
#  'pending': 'pending',
  'inprogress': 'inProgress',
  }
DATA_TRANSFER_SORT_TITLES = ['id', 'requestTime', 'oldOwnerUserEmail', 'newOwnerUserEmail',
                             'overallTransferStatusCode', 'application', 'applicationId', 'status']

# gam print datatransfers|transfers [todrive <ToDriveAttribute>*]
#	[olduser|oldowner <UserItem>] [newuser|newowner <UserItem>]
#	[status <String>] [delimiter <Character>]
#	(addcsvdata <FieldName> <String>)*
# gam show datatransfers|transfers
#	[olduser|oldowner <UserItem>] [newuser|newowner <UserItem>]
#	[status <String>] [delimiter <Character>]
def doPrintShowDataTransfers():
  dt = buildGAPIObject(API.DATATRANSFER)
  apps = getTransferApplications(dt)
  newOwnerUserId = None
  oldOwnerUserId = None
  status = None
  csvPF = CSVPrintFile(['id'], DATA_TRANSFER_SORT_TITLES) if Act.csvFormat() else None
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  addCSVData = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'olduser', 'oldowner'}:
      oldOwnerUserId = convertEmailAddressToUID(getEmailAddress(returnUIDprefix='uid:'))
    elif myarg in {'newuser', 'newowner'}:
      newOwnerUserId = convertEmailAddressToUID(getEmailAddress(returnUIDprefix='uid:'))
    elif myarg == 'status':
      status = getChoice(DATA_TRANSFER_STATUS_MAP, mapChoice=True)
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    elif csvPF and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    else:
      unknownArgumentExit()
  try:
    transfers = callGAPIpages(dt.transfers(), 'list', 'dataTransfers',
                              throwReasons=[GAPI.UNKNOWN_ERROR, GAPI.FORBIDDEN, GAPI.INVALID_INPUT],
                              customerId=GC.Values[GC.CUSTOMER_ID], status=status,
                              newOwnerUserId=newOwnerUserId, oldOwnerUserId=oldOwnerUserId)
  except (GAPI.unknownError, GAPI.forbidden, GAPI.invalidInput) as e:
    entityActionFailedExit([Ent.TRANSFER_REQUEST, None], str(e))
  if not csvPF:
    count = len(transfers)
    performActionNumItems(count, Ent.TRANSFER_REQUEST)
    Ind.Increment()
    i = 0
    for transfer in sorted(transfers, key=lambda k: k['requestTime']):
      i += 1
      _showTransfer(apps, transfer, i, count)
    Ind.Decrement()
  else:
    for transfer in sorted(transfers, key=lambda k: k['requestTime']):
      row = {}
      row['id'] = transfer['id']
      row['requestTime'] = formatLocalTime(transfer['requestTime'])
      row['oldOwnerUserEmail'] = convertUserIDtoEmail(transfer['oldOwnerUserId'])
      row['newOwnerUserEmail'] = convertUserIDtoEmail(transfer['newOwnerUserId'])
      row['overallTransferStatusCode'] = transfer['overallTransferStatusCode']
      if addCSVData:
        row.update(addCSVData)
      for app in transfer['applicationDataTransfers']:
        xrow = row.copy()
        xrow['application'] = _convertTransferAppIDtoName(apps, app['applicationId'])
        xrow['applicationId'] = app['applicationId']
        xrow['status'] = app['applicationTransferStatus']
        for param in app.get('applicationTransferParams', []):
          key = param['key']
          xrow[key] = delimiter.join(param.get('value', [] if key != 'RELEASE_RESOURCES' else ['TRUE']))
        csvPF.WriteRowTitles(xrow)
  if csvPF:
    csvPF.writeCSVfile('Data Transfers')

# gam show transferapps
def doShowTransferApps():
  dt = buildGAPIObject(API.DATATRANSFER)
  checkForExtraneousArguments()
  Act.Set(Act.SHOW)
  try:
    apps = callGAPIpages(dt.applications(), 'list', 'applications',
                         throwReasons=[GAPI.UNKNOWN_ERROR, GAPI.FORBIDDEN],
                         customerId=GC.Values[GC.CUSTOMER_ID], fields='applications(id,name,transferParams)')
  except (GAPI.unknownError, GAPI.forbidden):
    accessErrorExit(None)
  count = len(apps)
  performActionNumItems(count, Ent.TRANSFER_APPLICATION)
  Ind.Increment()
  i = 0
  for app in apps:
    i += 1
    printKeyValueListWithCount([app['name']], i, count)
    Ind.Increment()
    printKeyValueList(['id', app['id']])
    transferParams = app.get('transferParams', [])
    if transferParams:
      printKeyValueList(['Parameters'])
      Ind.Increment()
      for param in transferParams:
        printKeyValueList(['key', param['key']])
        Ind.Increment()
        printKeyValueList(['value', ','.join(param['value'])])
        Ind.Decrement()
      Ind.Decrement()
    Ind.Decrement()
  Ind.Decrement()

def _getOrgInheritance(myarg, body):
  if myarg == 'noinherit':
    Cmd.Backup()
    deprecatedArgumentExit(myarg)
  elif myarg == 'inherit':
    body['blockInheritance'] = False
  elif myarg in {'blockinheritance', 'inheritanceblocked'}:
    location = Cmd.Location()-1
    if getBoolean():
      Cmd.SetLocation(location)
      deprecatedArgumentExit(myarg)
    body['blockInheritance'] = False
  else:
    return False
  return True

# gam create org|ou <String> [description <String>] [parent <OrgUnitItem>] [inherit|(blockinheritance False)] [buildpath]
def doCreateOrg():

  def _createOrg(body, parentPath, fullPath):
    try:
      callGAPI(cd.orgunits(), 'insert',
               throwReasons=[GAPI.INVALID_PARENT_ORGUNIT, GAPI.INVALID_ORGUNIT, GAPI.BACKEND_ERROR, GAPI.BAD_REQUEST, GAPI.INVALID_CUSTOMER_ID, GAPI.LOGIN_REQUIRED],
               customerId=GC.Values[GC.CUSTOMER_ID], body=body, fields='')
      entityActionPerformed([Ent.ORGANIZATIONAL_UNIT, fullPath])
    except GAPI.invalidParentOrgunit:
      entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, fullPath, Ent.PARENT_ORGANIZATIONAL_UNIT, parentPath], Msg.ENTITY_DOES_NOT_EXIST.format(Ent.Singular(Ent.PARENT_ORGANIZATIONAL_UNIT)))
      return False
    except (GAPI.invalidOrgunit, GAPI.backendError):
      entityDuplicateWarning([Ent.ORGANIZATIONAL_UNIT, fullPath])
    except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
      checkEntityAFDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, fullPath)
    return True

  cd = buildGAPIObject(API.DIRECTORY)
  name = getOrgUnitItem(pathOnly=True, absolutePath=False)
  parent = ''
  body = {}
  buildPath = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'description':
      body['description'] = getStringWithCRsNLs()
    elif myarg == 'parent':
      parent = getOrgUnitItem()
    elif _getOrgInheritance(myarg, body):
      pass
    elif myarg == 'buildpath':
      buildPath = True
    else:
      unknownArgumentExit()
  if parent.startswith('id:'):
    parentPath = None
    try:
      parentPath = callGAPI(cd.orgunits(), 'get',
                            throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                            customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=parent, fields='orgUnitPath')['orgUnitPath']
    except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError):
      pass
    except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
      errMsg = accessErrorMessage(cd)
      if errMsg:
        systemErrorExit(INVALID_DOMAIN_RC, errMsg)
    if not parentPath and not buildPath:
      entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, name, Ent.PARENT_ORGANIZATIONAL_UNIT, parent], Msg.ENTITY_DOES_NOT_EXIST.format(Ent.Singular(Ent.PARENT_ORGANIZATIONAL_UNIT)))
      return
    parent = parentPath
  if parent == '/':
    orgUnitPath = parent+name
  else:
    orgUnitPath = parent+'/'+name
  if orgUnitPath.count('/') > 1:
    body['parentOrgUnitPath'], body['name'] = orgUnitPath.rsplit('/', 1)
  else:
    body['parentOrgUnitPath'] = '/'
    body['name'] = orgUnitPath[1:]
  parent = body['parentOrgUnitPath']
  if _createOrg(body, parent, orgUnitPath) or not buildPath:
    return
  description = body.pop('description', None)
  fullPath = '/'
  getPath = ''
  orgNames = orgUnitPath.split('/')
  n = len(orgNames)-1
  for i in range(1, n+1):
    body['parentOrgUnitPath'] = fullPath
    if fullPath != '/':
      fullPath += '/'
    fullPath += orgNames[i]
    if getPath != '':
      getPath += '/'
    getPath += orgNames[i]
    try:
      callGAPI(cd.orgunits(), 'get',
               throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
               customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=encodeOrgUnitPath(getPath), fields='')
      printKeyValueList([Ent.Singular(Ent.ORGANIZATIONAL_UNIT), fullPath, Msg.EXISTS])
    except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError):
      body['name'] = orgNames[i]
      if i == n and description:
        body['description'] = description
      if not _createOrg(body, body['parentOrgUnitPath'], fullPath):
        return
    except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
      checkEntityAFDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, fullPath)

def checkOrgUnitPathExists(cd, orgUnitPath, i=0, count=0, showError=False):
  if orgUnitPath == '/':
    _, orgUnitId = getOrgUnitId(cd, orgUnitPath)
    return (True, orgUnitPath, orgUnitId)
  try:
    orgUnit = callGAPI(cd.orgunits(), 'get',
                       throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                       customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=encodeOrgUnitPath(makeOrgUnitPathRelative(orgUnitPath)),
                       fields='orgUnitPath,orgUnitId')
    return (True, orgUnit['orgUnitPath'], orgUnit['orgUnitId'])
  except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError):
    pass
  except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
    errMsg = accessErrorMessage(cd)
    if errMsg:
      systemErrorExit(INVALID_DOMAIN_RC, errMsg)
  if showError:
    entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], Msg.DOES_NOT_EXIST, i, count)
  return (False, orgUnitPath, orgUnitPath)

def _batchMoveCrOSesToOrgUnit(cd, orgUnitPath, orgUnitId, i, count, items, quickCrOSMove, fromOrgUnitPath=None):
  def _callbackMoveCrOSesToOrgUnit(request_id, _, exception):
    ri = request_id.splitlines()
    if exception is None:
      if not fromOrgUnitPath:
        entityActionPerformed([Ent.ORGANIZATIONAL_UNIT, orgUnitPath, Ent.CROS_DEVICE, ri[RI_ITEM]], int(ri[RI_J]), int(ri[RI_JCOUNT]))
      else:
        entityModifierActionPerformed([Ent.ORGANIZATIONAL_UNIT, fromOrgUnitPath, Ent.CROS_DEVICE, ri[RI_ITEM]], toOrgUnitPath, int(ri[RI_J]), int(ri[RI_JCOUNT]))
    else:
      http_status, reason, message = checkGAPIError(exception)
      if reason in [GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN]:
        checkEntityItemValueAFDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, orgUnitPath, Ent.CROS_DEVICE, ri[RI_ITEM], int(ri[RI_J]), int(ri[RI_JCOUNT]))
      else:
        errMsg = getHTTPError({}, http_status, reason, message)
        if not fromOrgUnitPath:
          entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath, Ent.CROS_DEVICE, ri[RI_ITEM]], errMsg, int(ri[RI_J]), int(ri[RI_JCOUNT]))
        else:
          entityModifierActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, fromOrgUnitPath, Ent.CROS_DEVICE, ri[RI_ITEM]], toOrgUnitPath, errMsg, int(ri[RI_J]), int(ri[RI_JCOUNT]))

  jcount = len(items)
  if not fromOrgUnitPath:
    entityPerformActionNumItems([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], jcount, Ent.CROS_DEVICE, i, count)
  else:
    toOrgUnitPath = f'{Act.MODIFIER_TO} {Ent.Singular(Ent.ORGANIZATIONAL_UNIT)}: {orgUnitPath}'
    entityPerformActionNumItemsModifier([Ent.ORGANIZATIONAL_UNIT, fromOrgUnitPath], jcount, Ent.CROS_DEVICE, toOrgUnitPath, i, count)
  Ind.Increment()
  if not quickCrOSMove:
    svcargs = dict([('customerId', GC.Values[GC.CUSTOMER_ID]),
                    ('deviceId', None),
                    ('fields', '')]+GM.Globals[GM.EXTRA_ARGS_LIST])
    if not GC.Values[GC.UPDATE_CROS_OU_WITH_ID]:
      svcargs['body'] = {'orgUnitPath': orgUnitPath}
      method = getattr(cd.chromeosdevices(), 'update')
    else:
      svcargs['body'] = {'orgUnitPath': orgUnitPath, 'orgUnitId': orgUnitId}
      method = getattr(cd.chromeosdevices(), 'patch')
    dbatch = cd.new_batch_http_request(callback=_callbackMoveCrOSesToOrgUnit)
    bcount = 0
    j = 0
    for deviceId in items:
      j += 1
      svcparms = svcargs.copy()
      svcparms['deviceId'] = deviceId
      dbatch.add(method(**svcparms), request_id=batchRequestID('', 0, 0, j, jcount, deviceId))
      bcount += 1
      if bcount >= GC.Values[GC.BATCH_SIZE]:
        executeBatch(dbatch)
        dbatch = cd.new_batch_http_request(callback=_callbackMoveCrOSesToOrgUnit)
        bcount = 0
    if bcount > 0:
      dbatch.execute()
  else:
    bcount = 0
    j = 0
    while bcount < jcount:
      kcount = min(jcount-bcount, GC.Values[GC.BATCH_SIZE])
      try:
        deviceIds = items[bcount:bcount+kcount]
        callGAPI(cd.chromeosdevices(), 'moveDevicesToOu',
                 throwReasons=[GAPI.INVALID_ORGUNIT, GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                 customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=orgUnitPath,
                 body={'deviceIds': deviceIds})
        for deviceId in deviceIds:
          j += 1
          entityActionPerformed([Ent.ORGANIZATIONAL_UNIT, orgUnitPath, Ent.CROS_DEVICE, deviceId], j, jcount)
        bcount += kcount
      except GAPI.invalidOrgunit:
        entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], Msg.INVALID_ORGUNIT, i, count)
        break
      except GAPI.invalidInput as e:
        entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath, Ent.CROS_DEVICE, None], str(e), i, count)
        break
      except GAPI.resourceNotFound as e:
        entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath, Ent.CROS_DEVICE, ','.join(deviceIds)], str(e), i, count)
        break
      except (GAPI.badRequest, GAPI.forbidden):
        checkEntityAFDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, orgUnitPath, i, count)
        bcount += kcount
  Ind.Decrement()

def _batchMoveUsersToOrgUnit(cd, orgUnitPath, i, count, items, fromOrgUnitPath=None):
  _MOVE_USER_REASON_TO_MESSAGE_MAP = {GAPI.USER_NOT_FOUND: Msg.DOES_NOT_EXIST, GAPI.DOMAIN_NOT_FOUND: Msg.SERVICE_NOT_APPLICABLE, GAPI.FORBIDDEN: Msg.SERVICE_NOT_APPLICABLE}
  def _callbackMoveUsersToOrgUnit(request_id, _, exception):
    ri = request_id.splitlines()
    if exception is None:
      if not fromOrgUnitPath:
        entityActionPerformed([Ent.ORGANIZATIONAL_UNIT, orgUnitPath, Ent.USER, ri[RI_ITEM]], int(ri[RI_J]), int(ri[RI_JCOUNT]))
      else:
        entityModifierActionPerformed([Ent.ORGANIZATIONAL_UNIT, fromOrgUnitPath, Ent.USER, ri[RI_ITEM]], toOrgUnitPath, int(ri[RI_J]), int(ri[RI_JCOUNT]))
    else:
      http_status, reason, message = checkGAPIError(exception)
      errMsg = getHTTPError(_MOVE_USER_REASON_TO_MESSAGE_MAP, http_status, reason, message)
      if not fromOrgUnitPath:
        entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath, Ent.USER, ri[RI_ITEM]], errMsg, int(ri[RI_J]), int(ri[RI_JCOUNT]))
      else:
        entityModifierActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, fromOrgUnitPath, Ent.USER, ri[RI_ITEM]], toOrgUnitPath, errMsg, int(ri[RI_J]), int(ri[RI_JCOUNT]))

  jcount = len(items)
  if not fromOrgUnitPath:
    entityPerformActionNumItems([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], jcount, Ent.USER, i, count)
  else:
    toOrgUnitPath = f'{Act.MODIFIER_TO} {Ent.Singular(Ent.ORGANIZATIONAL_UNIT)}: {orgUnitPath}'
    entityPerformActionNumItemsModifier([Ent.ORGANIZATIONAL_UNIT, fromOrgUnitPath], jcount, Ent.USER, toOrgUnitPath, i, count)
  Ind.Increment()
  svcargs = dict([('userKey', None), ('body', {'orgUnitPath': orgUnitPath}), ('fields', '')]+GM.Globals[GM.EXTRA_ARGS_LIST])
  method = getattr(cd.users(), 'update')
  dbatch = cd.new_batch_http_request(callback=_callbackMoveUsersToOrgUnit)
  bcount = 0
  j = 0
  for user in items:
    j += 1
    svcparms = svcargs.copy()
    svcparms['userKey'] = normalizeEmailAddressOrUID(user)
    dbatch.add(method(**svcparms), request_id=batchRequestID('', 0, 0, j, jcount, svcparms['userKey']))
    bcount += 1
    if bcount >= GC.Values[GC.BATCH_SIZE]:
      executeBatch(dbatch)
      dbatch = cd.new_batch_http_request(callback=_callbackMoveUsersToOrgUnit)
      bcount = 0
  if bcount > 0:
    dbatch.execute()
  Ind.Decrement()

def _doUpdateOrgs(entityList):
  cd = buildGAPIObject(API.DIRECTORY)
  if checkArgumentPresent(['move', 'add']):
    entityType, items = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS, crosAllowed=True)
    orgItemLists = items if isinstance(items, dict) else None
    quickCrOSMove = GC.Values[GC.QUICK_CROS_MOVE]
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if entityType == Cmd.ENTITY_CROS and myarg == 'quickcrosmove':
        quickCrOSMove = getBoolean()
      else:
        unknownArgumentExit()
    Act.Set(Act.ADD)
    i = 0
    count = len(entityList)
    for orgUnitPath in entityList:
      i += 1
      if orgItemLists:
        items = orgItemLists[orgUnitPath]
      status, orgUnitPath, orgUnitId = checkOrgUnitPathExists(cd, orgUnitPath, i, count, True)
      if not status:
        continue
      if entityType == Cmd.ENTITY_USERS:
        _batchMoveUsersToOrgUnit(cd, orgUnitPath, i, count, items)
      else:
        _batchMoveCrOSesToOrgUnit(cd, orgUnitPath, orgUnitId, i, count, items, quickCrOSMove)
  elif checkArgumentPresent(['sync']):
    entityType, syncMembers = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS, crosAllowed=True)
    cmdEntityType = Cmd.ENTITY_OU if entityType == Cmd.ENTITY_USERS else Cmd.ENTITY_CROS_OU
    orgItemLists = syncMembers if isinstance(syncMembers, dict) else None
    if orgItemLists is None:
      syncMembersSet = set(syncMembers)
    removeToOrgUnitPath = '/'
    removeToOrgUnitId = None
    quickCrOSMove = GC.Values[GC.QUICK_CROS_MOVE]
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if entityType == Cmd.ENTITY_CROS and myarg == 'quickcrosmove':
        quickCrOSMove = getBoolean()
      elif myarg == 'removetoou':
        status, removeToOrgUnitPath, removeToOrgUnitId = checkOrgUnitPathExists(cd, getOrgUnitItem())
        if not status:
          entityDoesNotExistExit(Ent.ORGANIZATIONAL_UNIT, removeToOrgUnitPath)
      else:
        unknownArgumentExit()
    if entityType == Cmd.ENTITY_CROS and not removeToOrgUnitId:
      _, removeToOrgUnitPath, removeToOrgUnitId = checkOrgUnitPathExists(cd, removeToOrgUnitPath)
    i = 0
    count = len(entityList)
    for orgUnitPath in entityList:
      i += 1
      if orgItemLists:
        syncMembersSet = set(orgItemLists[orgUnitPath])
      status, orgUnitPath, orgUnitId = checkOrgUnitPathExists(cd, orgUnitPath, i, count, True)
      if not status:
        continue
      currentMembersSet = set(getItemsToModify(cmdEntityType, orgUnitPath))
      if entityType == Cmd.ENTITY_USERS:
        Act.Set(Act.ADD)
        _batchMoveUsersToOrgUnit(cd, orgUnitPath, i, count, list(syncMembersSet-currentMembersSet))
        Act.Set(Act.REMOVE)
        _batchMoveUsersToOrgUnit(cd, removeToOrgUnitPath, i, count, list(currentMembersSet-syncMembersSet), orgUnitPath)
      else:
        Act.Set(Act.ADD)
        _batchMoveCrOSesToOrgUnit(cd, orgUnitPath, orgUnitId, i, count, list(syncMembersSet-currentMembersSet), quickCrOSMove)
        Act.Set(Act.REMOVE)
        _batchMoveCrOSesToOrgUnit(cd, removeToOrgUnitPath, removeToOrgUnitId, i, count, list(currentMembersSet-syncMembersSet), quickCrOSMove, orgUnitPath)
  else:
    body = {}
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg == 'name':
        body['name'] = getString(Cmd.OB_STRING)
      elif myarg == 'description':
        body['description'] = getStringWithCRsNLs()
      elif myarg == 'parent':
        parent = getOrgUnitItem()
        if parent.startswith('id:'):
          body['parentOrgUnitId'] = parent
        else:
          body['parentOrgUnitPath'] = parent
      elif _getOrgInheritance(myarg, body):
        pass
      else:
        unknownArgumentExit()
    i = 0
    count = len(entityList)
    for orgUnitPath in entityList:
      i += 1
      try:
        callGAPI(cd.orgunits(), 'update',
                 throwReasons=[GAPI.INVALID_ORGUNIT, GAPI.ORGUNIT_NOT_FOUND, GAPI.BACKEND_ERROR, GAPI.INVALID_ORGUNIT_NAME,
                               GAPI.CONDITION_NOT_MET, GAPI.BAD_REQUEST, GAPI.INVALID_CUSTOMER_ID, GAPI.LOGIN_REQUIRED],
                 customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=encodeOrgUnitPath(makeOrgUnitPathRelative(orgUnitPath)), body=body, fields='')
        entityActionPerformed([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], i, count)
      except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError):
        entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], Msg.DOES_NOT_EXIST, i, count)
      except GAPI.invalidOrgunitName as e:
        entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath, Ent.NAME, body['name']], str(e), i, count)
      except GAPI.conditionNotMet as e:
        entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], str(e), i, count)
      except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
        checkEntityAFDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, orgUnitPath)

# gam update orgs|ous <OrgUnitEntity> [name <String>] [description <String>] [parent <OrgUnitItem>] [inherit|(blockinheritance False)]
# gam update orgs|ous <OrgUnitEntity> add|move <CrosTypeEntity> [quickcrosmove [<Boolean>]]
# gam update orgs|ous <OrgUnitEntity> add|move <UserTypeEntity>
# gam update orgs|ous <OrgUnitEntity> sync <CrosTypeEntity> [removetoou <OrgUnitItem>] [quickcrosmove [<Boolean>]]
# gam update orgs|ous <OrgUnitEntity> sync <UserTypeEntity> [removetoou <OrgUnitItem>]
def doUpdateOrgs():
  _doUpdateOrgs(getEntityList(Cmd.OB_ORGUNIT_ENTITY, shlexSplit=True))

# gam update org|ou <OrgUnitItem> [name <String>] [description <String>]  [parent <OrgUnitItem>] [inherit|(blockinheritance False)]
# gam update org|ou <OrgUnitItem> add|move <CrosTypeEntity> [quickcrosmove [<Boolean>]]
# gam update org|ou <OrgUnitItem> add|move <UserTypeEntity>
# gam update org|ou <OrgUnitItem> sync <CrosTypeEntity> [removetoou <OrgUnitItem>] [quickcrosmove [<Boolean>]]
# gam update org|ou <OrgUnitItem> sync <UserTypeEntity> [removetoou <OrgUnitItem>]
def doUpdateOrg():
  _doUpdateOrgs([getOrgUnitItem()])

def _doDeleteOrgs(entityList):
  cd = buildGAPIObject(API.DIRECTORY)
  checkForExtraneousArguments()
  i = 0
  count = len(entityList)
  for orgUnitPath in entityList:
    i += 1
    try:
      orgUnitPath = makeOrgUnitPathAbsolute(orgUnitPath)
      callGAPI(cd.orgunits(), 'delete',
               throwReasons=[GAPI.CONDITION_NOT_MET, GAPI.INVALID_ORGUNIT, GAPI.ORGUNIT_NOT_FOUND, GAPI.BACKEND_ERROR,
                             GAPI.INVALID_CUSTOMER_ID, GAPI.SERVICE_NOT_AVAILABLE,
                             GAPI.BAD_REQUEST,  GAPI.LOGIN_REQUIRED],
               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
               customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=encodeOrgUnitPath(makeOrgUnitPathRelative(orgUnitPath)))
      entityActionPerformed([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], i, count)
    except GAPI.conditionNotMet:
      entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], Msg.HAS_CHILD_ORGS.format(Ent.Plural(Ent.ORGANIZATIONAL_UNIT)), i, count)
    except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError):
      entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], Msg.DOES_NOT_EXIST, i, count)
    except (GAPI.invalidCustomerId, GAPI.serviceNotAvailable) as e:
### Check for my_customer
      entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath, Ent.CUSTOMER_ID, GC.Values[GC.CUSTOMER_ID]], str(e), i, count)
    except (GAPI.badRequest, GAPI.loginRequired):
      checkEntityAFDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, orgUnitPath)

# gam delete orgs|ous <OrgUnitEntity>
def doDeleteOrgs():
  _doDeleteOrgs(getEntityList(Cmd.OB_ORGUNIT_ENTITY, shlexSplit=True))

# gam delete org|ou <OrgUnitItem>
def doDeleteOrg():
  _doDeleteOrgs([getOrgUnitItem()])

ORG_FIELD_INFO_ORDER = ['orgUnitId', 'name', 'description', 'parentOrgUnitPath', 'parentOrgUnitId', 'blockInheritance']
ORG_FIELDS_WITH_CRS_NLS = {'description'}

def _doInfoOrgs(entityList):
  cd = buildGAPIObject(API.DIRECTORY)
  getUsers = True
  isSuspended = None
  entityType = Ent.USER
  showChildren = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'nousers':
      getUsers = False
    elif myarg in SUSPENDED_ARGUMENTS:
      isSuspended = _getIsSuspended(myarg)
      entityType = Ent.USER_SUSPENDED if isSuspended else Ent.USER_NOT_SUSPENDED
    elif myarg in {'children', 'child'}:
      showChildren = True
    else:
      unknownArgumentExit()
  i = 0
  count = len(entityList)
  for origOrgUnitPath in entityList:
    i += 1
    try:
      if origOrgUnitPath == '/':
        _, orgUnitPath = getOrgUnitId(cd, origOrgUnitPath)
      else:
        orgUnitPath = makeOrgUnitPathRelative(origOrgUnitPath)
      result = callGAPI(cd.orgunits(), 'get',
                        throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                        customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=encodeOrgUnitPath(orgUnitPath))
      if 'orgUnitPath' not in result:
        entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, origOrgUnitPath], Msg.DOES_NOT_EXIST, i, count)
        continue
      printEntity([Ent.ORGANIZATIONAL_UNIT, result['orgUnitPath']], i, count)
      Ind.Increment()
      for field in ORG_FIELD_INFO_ORDER:
        value = result.get(field, None)
        if value is not None:
          if field not in ORG_FIELDS_WITH_CRS_NLS:
            printKeyValueList([field, value])
          else:
            printKeyValueWithCRsNLs(field, value)
      if getUsers:
        orgUnitPath = result['orgUnitPath']
        users = callGAPIpages(cd.users(), 'list', 'users',
                              throwReasons=[GAPI.BAD_REQUEST, GAPI.INVALID_INPUT, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                              customer=GC.Values[GC.CUSTOMER_ID], query=orgUnitPathQuery(orgUnitPath, isSuspended), orderBy='email',
                              fields='nextPageToken,users(primaryEmail,orgUnitPath)', maxResults=GC.Values[GC.USER_MAX_RESULTS])
        printEntitiesCount(entityType, None)
        usersInOU = 0
        Ind.Increment()
        orgUnitPath = orgUnitPath.lower()
        for user in users:
          if orgUnitPath == user['orgUnitPath'].lower():
            printKeyValueList([user['primaryEmail']])
            usersInOU += 1
          elif showChildren:
            printKeyValueList([f'{user["primaryEmail"]} (child)'])
            usersInOU += 1
        Ind.Decrement()
        printKeyValueList([Msg.TOTAL_ITEMS_IN_ENTITY.format(Ent.Plural(entityType), Ent.Singular(Ent.ORGANIZATIONAL_UNIT)), usersInOU])
      Ind.Decrement()
    except (GAPI.invalidInput, GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError):
      entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], Msg.DOES_NOT_EXIST, i, count)
    except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired, GAPI.resourceNotFound, GAPI.forbidden):
      checkEntityAFDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, orgUnitPath)

# gam info orgs|ous <OrgUnitEntity> [nousers|notsuspended|suspended] [children|child]
def doInfoOrgs():
  _doInfoOrgs(getEntityList(Cmd.OB_ORGUNIT_ENTITY, shlexSplit=True))

# gam info org|ou <OrgUnitItem> [nousers|notsuspended|suspended] [children|child]
def doInfoOrg():
  _doInfoOrgs([getOrgUnitItem()])

ORG_ARGUMENT_TO_FIELD_MAP = {
  'blockinheritance': 'blockInheritance',
  'inheritanceblocked': 'blockInheritance',
  'inherit': 'blockInheritance',
  'description': 'description',
  'id': 'orgUnitId',
  'name': 'name',
  'orgunitid': 'orgUnitId',
  'orgunitpath': 'orgUnitPath',
  'path': 'orgUnitPath',
  'parentorgunitid': 'parentOrgUnitId',
  'parentid': 'parentOrgUnitId',
  'parentorgunitpath': 'parentOrgUnitPath',
  'parent': 'parentOrgUnitPath',
  }
ORG_FIELD_PRINT_ORDER = ['orgUnitPath', 'orgUnitId', 'name', 'description', 'parentOrgUnitPath', 'parentOrgUnitId', 'blockInheritance']
PRINT_ORGS_DEFAULT_FIELDS = ['orgUnitPath', 'orgUnitId', 'name', 'parentOrgUnitId']

ORG_UNIT_SELECTOR_FIELD = 'orgUnitSelector'
PRINT_OUS_SELECTOR_CHOICES = [
  Cmd.ENTITY_CROS_OU, Cmd.ENTITY_CROS_OU_AND_CHILDREN,
  Cmd.ENTITY_OU, Cmd.ENTITY_OU_NS, Cmd.ENTITY_OU_SUSP,
  Cmd.ENTITY_OU_AND_CHILDREN, Cmd.ENTITY_OU_AND_CHILDREN_NS, Cmd.ENTITY_OU_AND_CHILDREN_SUSP,
  ]

def _getOrgUnits(cd, orgUnitPath, fieldsList, listType, showParent, batchSubOrgs, childSelector=None, parentSelector=None):
  def _callbackListOrgUnits(request_id, response, exception):
    ri = request_id.splitlines()
    if exception is None:
      orgUnits.extend(response.get('organizationUnits', []))
    else:
      http_status, reason, message = checkGAPIError(exception)
      errMsg = getHTTPError({}, http_status, reason, message)
      if reason not in GAPI.DEFAULT_RETRY_REASONS:
        if reason in [GAPI.BAD_REQUEST, GAPI.INVALID_CUSTOMER_ID, GAPI.LOGIN_REQUIRED]:
          accessErrorExit(cd)
        entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, topLevelOrgUnits[int(ri[RI_I])]], errMsg)
        return
      waitOnFailure(1, 10, reason, message)
      try:
        response = callGAPI(cd.orgunits(), 'list',
                            throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                            customerId=GC.Values[GC.CUSTOMER_ID], type='all', orgUnitPath=topLevelOrgUnits[int(ri[RI_I])], fields=listfields)
        orgUnits.extend(response.get('organizationUnits', []))
      except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError):
        entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, topLevelOrgUnits[int(ri[RI_I])]], Msg.DOES_NOT_EXIST)
      except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
        accessErrorExit(cd)

  def _batchListOrgUnits():
    svcargs = dict([('customerId', GC.Values[GC.CUSTOMER_ID]), ('orgUnitPath', None), ('type', 'all'), ('fields', listfields)]+GM.Globals[GM.EXTRA_ARGS_LIST])
    method = getattr(cd.orgunits(), 'list')
    dbatch = cd.new_batch_http_request(callback=_callbackListOrgUnits)
    bcount = 0
    i = 0
    for orgUnitPath in topLevelOrgUnits:
      svcparms = svcargs.copy()
      svcparms['orgUnitPath'] = orgUnitPath
      dbatch.add(method(**svcparms), request_id=batchRequestID('', i, 0, 0, 0, ''))
      bcount += 1
      i += 1
      if bcount >= GC.Values[GC.BATCH_SIZE]:
        executeBatch(dbatch)
        dbatch = cd.new_batch_http_request(callback=_callbackListOrgUnits)
        bcount = 0
    if bcount > 0:
      dbatch.execute()

  deleteOrgUnitId = deleteParentOrgUnitId = False
  if showParent:
    localFieldsList = fieldsList[:]
    if 'orgUnitId' not in fieldsList:
      localFieldsList.append('orgUnitId')
      deleteOrgUnitId = True
    if 'parentOrgUnitId' not in fieldsList:
      localFieldsList.append('parentOrgUnitId')
      deleteParentOrgUnitId = True
    fields = getFieldsFromFieldsList(localFieldsList)
  else:
    fields = getFieldsFromFieldsList(fieldsList)
  listfields = f'organizationUnits({fields})'
  if listType == 'all' and  orgUnitPath == '/':
    printGettingAllAccountEntities(Ent.ORGANIZATIONAL_UNIT)
  else:
    printGettingAllEntityItemsForWhom(Ent.CHILD_ORGANIZATIONAL_UNIT, orgUnitPath,
                                      qualifier=' (Direct Children)' if listType == 'children' else '', entityType=Ent.ORGANIZATIONAL_UNIT)
  if listType == 'children':
    batchSubOrgs = False
  try:
    orgs = callGAPI(cd.orgunits(), 'list',
                    throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                    customerId=GC.Values[GC.CUSTOMER_ID], type=listType if not batchSubOrgs else 'children',
                    orgUnitPath=orgUnitPath, fields=listfields)
  except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError):
    entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], Msg.DOES_NOT_EXIST)
    return None
  except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
    accessErrorExit(cd)
  orgUnits = orgs.get('organizationUnits', [])
  topLevelOrgUnits = [orgUnit['orgUnitPath'] for orgUnit in orgUnits]
  if batchSubOrgs:
    _batchListOrgUnits()
  if showParent:
    parentOrgIds = []
    retrievedOrgIds = []
    if not orgUnits:
      topLevelOrgId = getTopLevelOrgId(cd, orgUnitPath)
      if topLevelOrgId:
        parentOrgIds.append(topLevelOrgId)
    for orgUnit in orgUnits:
      retrievedOrgIds.append(orgUnit['orgUnitId'])
      if orgUnit['parentOrgUnitId'] not in parentOrgIds:
        parentOrgIds.append(orgUnit['parentOrgUnitId'])
    missing_parents = set(parentOrgIds)-set(retrievedOrgIds)
    for missing_parent in missing_parents:
      try:
        result = callGAPI(cd.orgunits(), 'get',
                          throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                          customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=missing_parent, fields=fields)
        orgUnits.append(result)
      except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError,
              GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
        pass
  if listType == 'all' and  orgUnitPath == '/':
    printGotAccountEntities(len(orgUnits))
  else:
    printGotEntityItemsForWhom(len(orgUnits))
  if childSelector is not None:
    for orgUnit in orgUnits:
      orgUnit[ORG_UNIT_SELECTOR_FIELD] = childSelector if orgUnit['orgUnitPath'] != orgUnitPath else parentSelector
  if deleteOrgUnitId or deleteParentOrgUnitId:
    for orgUnit in orgUnits:
      if deleteOrgUnitId:
        orgUnit.pop('orgUnitId', None)
      if deleteParentOrgUnitId:
        orgUnit.pop('parentOrgUnitId', None)
  return orgUnits

def getOrgUnitIdToPathMap(cd=None):
  if cd is None:
    cd = buildGAPIObject(API.DIRECTORY)
  orgUnits = _getOrgUnits(cd, '/', ['orgUnitPath', 'orgUnitId'], 'all', True, False)
  return {ou['orgUnitId']:ou['orgUnitPath'] for ou in orgUnits}

# gam print orgs|ous [todrive <ToDriveAttribute>*]
#	[fromparent <OrgUnitItem>] [showparent] [toplevelonly]
#	[parentselector <OrgUnitSelector> childselector <OrgUnitSelector>]
#	[allfields|<OrgUnitFieldName>*|(fields <OrgUnitFieldNameList>)] [convertcrnl] [batchsuborgs [<Boolean>]]
#	[mincroscount <Number>] [maxcroscount <Number>]
#	[minusercount <Number>] [maxusercount <Number>]
# 	[showitemcountonly]
def doPrintOrgs():
  cd = buildGAPIObject(API.DIRECTORY)
  convertCRNL = GC.Values[GC.CSV_OUTPUT_CONVERT_CR_NL]
  fieldsList = []
  csvPF = CSVPrintFile(sortTitles=ORG_FIELD_PRINT_ORDER)
  orgUnitPath = '/'
  listType = 'all'
  batchSubOrgs = showParent = False
  childSelector = parentSelector = None
  minCrOSCounts = maxCrOSCounts = minUserCounts = maxUserCounts = -1
  crosCounts = {}
  userCounts = {}
  showItemCountOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'fromparent':
      orgUnitPath = getOrgUnitItem()
    elif myarg == 'showparent':
      showParent = getBoolean()
    elif myarg == 'parentselector':
      parentSelector = getChoice(PRINT_OUS_SELECTOR_CHOICES)
    elif myarg == 'childselector':
      childSelector = getChoice(PRINT_OUS_SELECTOR_CHOICES)
    elif myarg == 'mincroscount':
      minCrOSCounts = getInteger(minVal=-1)
    elif myarg == 'maxcroscount':
      maxCrOSCounts = getInteger(minVal=-1)
    elif myarg == 'minusercount':
      minUserCounts = getInteger(minVal=-1)
    elif myarg == 'maxusercount':
      maxUserCounts = getInteger(minVal=-1)
    elif myarg == 'batchsuborgs':
      batchSubOrgs = getBoolean()
    elif myarg == 'toplevelonly':
      listType = 'children'
    elif myarg == 'allfields':
      fieldsList = []
      csvPF.SetTitles(fieldsList)
      for field in ORG_FIELD_PRINT_ORDER:
        csvPF.AddField(field, ORG_ARGUMENT_TO_FIELD_MAP, fieldsList)
    elif myarg in ORG_ARGUMENT_TO_FIELD_MAP:
      if not fieldsList:
        csvPF.AddField('orgUnitPath', ORG_ARGUMENT_TO_FIELD_MAP, fieldsList)
      csvPF.AddField(myarg, ORG_ARGUMENT_TO_FIELD_MAP, fieldsList)
    elif myarg == 'fields':
      if not fieldsList:
        csvPF.AddField('orgUnitPath', ORG_ARGUMENT_TO_FIELD_MAP, fieldsList)
      for field in _getFieldsList():
        if field in ORG_ARGUMENT_TO_FIELD_MAP:
          csvPF.AddField(field, ORG_ARGUMENT_TO_FIELD_MAP, fieldsList)
        else:
          invalidChoiceExit(field, list(ORG_ARGUMENT_TO_FIELD_MAP), True)
    elif myarg in {'convertcrnl', 'converttextnl'}:
      convertCRNL = True
    elif myarg == 'showitemcountonly':
      showItemCountOnly = True
    else:
      unknownArgumentExit()
  if childSelector:
    if showParent and parentSelector is None:
      missingArgumentExit('parentselector')
    csvPF.AddTitle(ORG_UNIT_SELECTOR_FIELD)
    csvPF.AddSortTitle(ORG_UNIT_SELECTOR_FIELD)
  showCrOSCounts = (minCrOSCounts >= 0 or maxCrOSCounts >= 0)
  showUserCounts = (minUserCounts >= 0 or maxUserCounts >= 0)
  if not fieldsList:
    for field in PRINT_ORGS_DEFAULT_FIELDS:
      csvPF.AddField(field, ORG_ARGUMENT_TO_FIELD_MAP, fieldsList)
  orgUnits = _getOrgUnits(cd, orgUnitPath, fieldsList, listType, showParent, batchSubOrgs, childSelector, parentSelector)
  if showItemCountOnly:
    writeStdout(f'{0 if orgUnits is None else (len(orgUnits))}\n')
    return
  if orgUnits is None:
    return
  if showUserCounts:
    for orgUnit in orgUnits:
      userCounts[orgUnit['orgUnitPath']] = [0, 0]
    qualifier = Msg.IN_THE.format(Ent.Singular(Ent.ORGANIZATIONAL_UNIT))
    printGettingAllEntityItemsForWhom(Ent.USER, orgUnitPath, qualifier=qualifier, entityType=Ent.ORGANIZATIONAL_UNIT)
    pageMessage = getPageMessageForWhom()
    try:
      feed = yieldGAPIpages(cd.users(), 'list', 'users',
                            pageMessage=pageMessage,
                            throwReasons=[GAPI.INVALID_ORGUNIT, GAPI.ORGUNIT_NOT_FOUND,
                                          GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                            customer=GC.Values[GC.CUSTOMER_ID], query=orgUnitPathQuery(orgUnitPath, None), orderBy='email',
                            fields='nextPageToken,users(orgUnitPath,suspended)', maxResults=GC.Values[GC.USER_MAX_RESULTS])
      for users in feed:
        for user in users:
          if user['orgUnitPath'] in userCounts:
            userCounts[user['orgUnitPath']][user['suspended']] += 1
    except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.invalidInput, GAPI.badRequest, GAPI.backendError,
            GAPI.invalidCustomerId, GAPI.loginRequired, GAPI.resourceNotFound, GAPI.forbidden):
      checkEntityDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, orgUnitPath)
  for orgUnit in sorted(orgUnits, key=lambda k: k['orgUnitPath']):
    orgUnitPath = orgUnit['orgUnitPath']
    if showCrOSCounts:
      crosCounts[orgUnit['orgUnitPath']] = {}
      printGettingAllEntityItemsForWhom(Ent.CROS_DEVICE, orgUnitPath, entityType=Ent.ORGANIZATIONAL_UNIT)
      pageMessage = getPageMessageForWhom()
      pageToken = None
      totalItems = 0
      tokenRetries = 0
      while True:
        try:
          feed = callGAPI(cd.chromeosdevices(), 'list', 'chromeosdevices',
                          throwReasons=[GAPI.INVALID_INPUT, GAPI.INVALID_ORGUNIT, GAPI.ORGUNIT_NOT_FOUND,
                                        GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                          retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                          pageToken=pageToken,
                          customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=orgUnitPath, includeChildOrgunits=False,
                          fields='nextPageToken,chromeosdevices(status)', maxResults=GC.Values[GC.DEVICE_MAX_RESULTS])
          tokenRetries = 0
          pageToken, totalItems = _processGAPIpagesResult(feed, 'chromeosdevices', None, totalItems, pageMessage, None, Ent.CROS_DEVICE)
          if feed:
            for cros in feed.get('chromeosdevices', []):
              crosCounts[orgUnitPath].setdefault(cros['status'], 0)
              crosCounts[orgUnitPath][cros['status']] += 1
            del feed
          if not pageToken:
            _finalizeGAPIpagesResult(pageMessage)
            break
        except GAPI.invalidInput as e:
          message = str(e)
# Invalid Input: xyz - Check for invalid pageToken!!
# 0123456789012345
          if message[15:] == pageToken:
            tokenRetries += 1
            if tokenRetries <= 2:
              writeStderr(f'{WARNING_PREFIX}{Msg.LIST_CHROMEOS_INVALID_INPUT_PAGE_TOKEN_RETRY}')
              time.sleep(tokenRetries*5)
              continue
          entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath, Ent.CROS_DEVICE, None], message)
          break
        except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.badRequest, GAPI.backendError,
                GAPI.invalidCustomerId, GAPI.loginRequired, GAPI.resourceNotFound, GAPI.forbidden):
          checkEntityDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, orgUnitPath)
          break
    row = {}
    for field in fieldsList:
      if convertCRNL and field in ORG_FIELDS_WITH_CRS_NLS:
        row[field] = escapeCRsNLs(orgUnit.get(field, ''))
      else:
        row[field] = orgUnit.get(field, '')
    if childSelector:
      row[ORG_UNIT_SELECTOR_FIELD] = orgUnit[ORG_UNIT_SELECTOR_FIELD]
    if showCrOSCounts or showUserCounts:
      if showCrOSCounts:
        total = 0
        for k, v in sorted(iter(crosCounts[orgUnitPath].items())):
          row[f'CrOS{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{k}'] = v
          total += v
        row[f'CrOS{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}Total'] = total
        if ((minCrOSCounts != -1 and total < minCrOSCounts) or
            (maxCrOSCounts != -1 and total > maxCrOSCounts)):
          continue
      if showUserCounts:
        row[f'Users{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}NotSuspended'] = userCounts[orgUnitPath][0]
        row[f'Users{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}Suspended'] = userCounts[orgUnitPath][1]
        row[f'Users{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}Total'] = total = userCounts[orgUnitPath][0]+userCounts[orgUnitPath][1]
        if ((minUserCounts != -1 and total < minUserCounts) or
            (maxUserCounts != -1 and total > maxUserCounts)):
          continue
      csvPF.WriteRowTitles(row)
    else:
      csvPF.WriteRow(row)
  csvPF.writeCSVfile('Orgs')

# gam show orgtree [fromparent <OrgUnitItem>] [batchsuborgs [Boolean>]]
def doShowOrgTree():
  def addOrgUnitToTree(orgPathList, i, n, tree):
    if orgPathList[i] not in tree:
      tree[orgPathList[i]] = {}
    if i < n:
      addOrgUnitToTree(orgPathList, i+1, n, tree[orgPathList[i]])

  def printOrgUnit(parentOrgUnit, tree):
    printKeyValueList([parentOrgUnit])
    Ind.Increment()
    for childOrgUnit in sorted(tree[parentOrgUnit]):
      printOrgUnit(childOrgUnit, tree[parentOrgUnit])
    Ind.Decrement()

  cd = buildGAPIObject(API.DIRECTORY)
  orgUnitPath = '/'
  fieldsList = ['orgUnitPath']
  listType = 'all'
  batchSubOrgs = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'fromparent':
      orgUnitPath = getOrgUnitItem()
    elif myarg == 'batchsuborgs':
      batchSubOrgs = getBoolean()
    else:
      unknownArgumentExit()
  orgUnits = _getOrgUnits(cd, orgUnitPath, fieldsList, listType, False, batchSubOrgs)
  if orgUnits is None:
    return
  orgTree = {}
  for orgUnit in orgUnits:
    orgPath = orgUnit['orgUnitPath'].split('/')
    addOrgUnitToTree(orgPath, 1, len(orgPath)-1, orgTree)
  for org in sorted(orgTree):
    printOrgUnit(org, orgTree)

ORG_ITEMS_FIELD_MAP = {
  'browsers': 'browsers',
  'devices': 'devices',
  'shareddrives': 'sharedDrives',
  'subous': 'subOus',
  'users': 'users',
  }

# gam check ou|org <OrgUnitItem> [todrive <ToDriveAttribute>*]
#	[<OrgUnitCheckName>*|(fields <OrgUnitCheckNameList>)]
#	[filename <FileName>] [movetoou <OrgUnitItem>]
#	[formatjson [quotechar <Character>]]
def doCheckOrgUnit():
  def writeCommandInfo(field):
    nonlocal commitBatch
    if commitBatch:
      f.write(f'{Cmd.COMMIT_BATCH_CMD}\n')
    else:
      commitBatch = True
    f.write(f'{Cmd.PRINT_CMD} Move {field} from {orgUnitPath} to {moveToOrgUnitPath}\n')

  cd = buildGAPIObject(API.DIRECTORY)
  csvPF = CSVPrintFile(['orgUnitPath', 'orgUnitId', 'empty'])
  FJQC = FormatJSONQuoteChar(csvPF)
  f = orgUnitPath = None
  fieldsList = []
  titlesList = []
  status, orgUnitPath, orgUnitId = checkOrgUnitPathExists(cd, getOrgUnitItem())
  if not status:
    entityDoesNotExistExit(Ent.ORGANIZATIONAL_UNIT, orgUnitPath)
  orgUnitPathLower = orgUnitPath.lower()
  fileName = 'CleanOuBatch.txt'
  moveToOrgUnitPath = moveToOrgUnitPathLower = None
  commitBatch = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in ORG_ITEMS_FIELD_MAP:
      fieldsList.append(myarg)
    elif myarg == 'fields':
      for field in _getFieldsList():
        if field in ORG_ITEMS_FIELD_MAP:
          fieldsList.append(field)
        else:
          invalidChoiceExit(field, list(ORG_ITEMS_FIELD_MAP), True)
    elif myarg == 'filename':
      fileName = setFilePath(getString(Cmd.OB_FILE_NAME))
    elif myarg == 'movetoou':
      movetoouLocation = Cmd.Location()
      status, moveToOrgUnitPath, _ = checkOrgUnitPathExists(cd, getOrgUnitItem())
      moveToOrgUnitPathLower = moveToOrgUnitPath.lower()
      if not status:
        entityDoesNotExistExit(Ent.ORGANIZATIONAL_UNIT, moveToOrgUnitPath)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if not fieldsList:
    fieldsList = ORG_ITEMS_FIELD_MAP.keys()
  if moveToOrgUnitPath is not None:
    Cmd.SetLocation(movetoouLocation)
    if orgUnitPathLower == moveToOrgUnitPathLower:
      usageErrorExit(Msg.OU_AND_MOVETOOU_CANNOT_BE_IDENTICAL.format(orgUnitPath, moveToOrgUnitPath))
    if 'subous' in fieldsList and moveToOrgUnitPathLower.startswith(orgUnitPathLower):
      usageErrorExit(Msg.OU_SUBOUS_CANNOT_BE_MOVED_TO_MOVETOOU.format(orgUnitPath, moveToOrgUnitPath))
    fileName = setFilePath(fileName)
    f = openFile(fileName, DEFAULT_FILE_WRITE_MODE)
  orgUnitItemCounts = {}
  for field in sorted(fieldsList):
    title = ORG_ITEMS_FIELD_MAP[field]
    orgUnitItemCounts[title] = 0
    if not FJQC.formatJSON:
      titlesList.append(title)
  if 'browsers' in fieldsList:
    cbcm = buildGAPIObject(API.CBCM)
    customerId = _getCustomerIdNoC()
    printGettingAllEntityItemsForWhom(Ent.CHROME_BROWSER, orgUnitPath, entityType=Ent.ORGANIZATIONAL_UNIT)
    pageMessage = getPageMessage()
    try:
      feed = yieldGAPIpages(cbcm.chromebrowsers(), 'list', 'browsers',
                            pageMessage=pageMessage, messageAttribute='deviceId',
                            throwReasons=[GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.INVALID_ORGUNIT, GAPI.FORBIDDEN],
                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                            customer=customerId, orgUnitPath=orgUnitPath, projection='BASIC',
                            fields='nextPageToken,browsers(deviceId)')
      for browsers in feed:
        orgUnitItemCounts['browsers'] += len(browsers)
      if f is not None and orgUnitItemCounts['browsers'] > 0:
        writeCommandInfo('browsers')
        f.write(f'gam move browsers ou {moveToOrgUnitPath} browserou {orgUnitPath}\n')
    except (GAPI.invalidInput, GAPI.forbidden) as e:
      entityActionFailedWarning([Ent.CHROME_BROWSER, None], str(e))
    except GAPI.invalidOrgunit  as e:
      entityActionFailedExit([Ent.CHROME_BROWSER, None], str(e))
    except (GAPI.badRequest, GAPI.resourceNotFound):
      accessErrorExit(None)
  if 'devices' in fieldsList:
    printGettingAllEntityItemsForWhom(Ent.CROS_DEVICE, orgUnitPath, entityType=Ent.ORGANIZATIONAL_UNIT)
    pageMessage = getPageMessageForWhom()
    pageToken = None
    totalItems = 0
    tokenRetries = 0
    while True:
      try:
        feed = callGAPI(cd.chromeosdevices(), 'list',
                        throwReasons=[GAPI.INVALID_INPUT, GAPI.INVALID_ORGUNIT,
                                      GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                        retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                        pageToken=pageToken, customerId=GC.Values[GC.CUSTOMER_ID],
                        orgUnitPath=orgUnitPath, fields='nextPageToken,chromeosdevices(deviceId)', maxResults=GC.Values[GC.DEVICE_MAX_RESULTS])
        tokenRetries = 0
        pageToken, totalItems = _processGAPIpagesResult(feed, 'chromeosdevices', None, totalItems, pageMessage, None, Ent.CROS_DEVICE)
        if feed:
          orgUnitItemCounts['devices'] += len(feed.get('chromeosdevices', []))
          del feed
        if not pageToken:
          _finalizeGAPIpagesResult(pageMessage)
          printGotAccountEntities(totalItems)
          if f is not None and orgUnitItemCounts['devices'] > 0:
            writeCommandInfo('devices')
            f.write(f'gam update  ou {moveToOrgUnitPath} add cros_ou {orgUnitPath}\n')
          break
      except GAPI.invalidInput as e:
        message = str(e)
# Invalid Input: xyz - Check for invalid pageToken!!
# 0123456789012345
        if message[15:] == pageToken:
          tokenRetries += 1
          if tokenRetries <= 2:
            writeStderr(f'{WARNING_PREFIX}{Msg.LIST_CHROMEOS_INVALID_INPUT_PAGE_TOKEN_RETRY}')
            time.sleep(tokenRetries*5)
            continue
          entityActionFailedWarning([Ent.CROS_DEVICE, None], message)
          break
        entityActionFailedWarning([Ent.CROS_DEVICE, None], message)
        break
      except GAPI.invalidOrgunit as e:
        entityActionFailedExit([Ent.CROS_DEVICE, None], str(e))
      except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
        accessErrorExit(cd)
  if 'shareddrives' in fieldsList:
    ci = buildGAPIObject(API.CLOUDIDENTITY_ORGUNITS_BETA)
    printGettingAllEntityItemsForWhom(Ent.SHAREDDRIVE, orgUnitPath, entityType=Ent.ORGANIZATIONAL_UNIT)
    sds = callGAPIpages(ci.orgUnits().memberships(), 'list', 'orgMemberships',
                        pageMessage=getPageMessageForWhom(),
                        parent=f'orgUnits/{orgUnitId[3:]}',
                        customer=_getCustomersCustomerIdWithC(),
                        filter="type == 'shared_drive'")
    orgUnitItemCounts['sharedDrives'] = len(sds)
    if f is not None and orgUnitItemCounts['sharedDrives'] > 0:
      writeCommandInfo('Shared Drives')
      for sd in sds:
        name = sd['name'].split(';')[1]
        f.write(f'gam update shareddrive {name} ou {moveToOrgUnitPath}\n')
  if 'subous' in fieldsList:
    subOus = _getOrgUnits(cd, orgUnitPath, ['orgUnitPath'], 'children', False, False, None, None)
    orgUnitItemCounts['subOus'] = len(subOus)
    if f is not None and orgUnitItemCounts['subOus'] > 0:
      writeCommandInfo('Sub OrgUnit')
      for ou in subOus:
        f.write(f'gam update ou {ou["orgUnitPath"]} parent {moveToOrgUnitPath}\n')
  if 'users' in fieldsList:
    printGettingAllEntityItemsForWhom(Ent.USER, orgUnitPath, entityType=Ent.ORGANIZATIONAL_UNIT)
    pageMessage = getPageMessageForWhom()
    try:
      feed = yieldGAPIpages(cd.users(), 'list', 'users',
                            pageMessage=pageMessage,
                            throwReasons=[GAPI.INVALID_ORGUNIT, GAPI.ORGUNIT_NOT_FOUND,
                                          GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                            customer=GC.Values[GC.CUSTOMER_ID], query=orgUnitPathQuery(orgUnitPath, None),
                            fields='nextPageToken,users(orgUnitPath)', maxResults=GC.Values[GC.USER_MAX_RESULTS])
      for users in feed:
        for user in users:
          if orgUnitPathLower == user.get('orgUnitPath', '').lower():
            orgUnitItemCounts['users'] += 1
      if f is not None and orgUnitItemCounts['users'] > 0:
        writeCommandInfo('users')
        f.write(f'gam update ou {moveToOrgUnitPath} add ou {orgUnitPath}\n')
    except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.invalidInput, GAPI.badRequest, GAPI.backendError,
            GAPI.invalidCustomerId, GAPI.loginRequired, GAPI.resourceNotFound, GAPI.forbidden):
      checkEntityDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, orgUnitPath)
  if f is not None:
    closeFile(f)
    writeStderr(Msg.GAM_BATCH_FILE_WRITTEN.format(fileName))
  empty = True
  for count in orgUnitItemCounts.values():
    if count > 0:
      empty = False
      break
  baseRow = {'orgUnitPath': orgUnitPath, 'orgUnitId': orgUnitId, 'empty': empty}
  row = flattenJSON(orgUnitItemCounts, baseRow.copy())
  if not FJQC.formatJSON:
    csvPF.WriteRowTitles(row)
  elif csvPF.CheckRowTitles(row):
    baseRow['JSON'] = json.dumps(cleanJSON(orgUnitItemCounts), ensure_ascii=False, sort_keys=True)
    csvPF.WriteRowNoFilter(baseRow)
  csvPF.writeCSVfile(f'OrgUnit {orgUnitPath} Item Counts')
  if not empty and GM.Globals[GM.SYSEXITRC] == 0:
    setSysExitRC(ORGUNIT_NOT_EMPTY_RC)

ALIAS_TARGET_TYPES = ['user', 'group', 'target']

# gam create aliases|nicknames <EmailAddressEntity> user|group|target <UniqueID>|<EmailAddress>
#	[verifynotinvitable]
# gam update aliases|nicknames <EmailAddressEntity> user|group|target <UniqueID>|<EmailAddress>
#	[notargetverify] [waitafterdelete <Integer>]
def doCreateUpdateAliases():
  def verifyAliasTargetExists():
    if targetType != 'group':
      try:
        callGAPI(cd.users(), 'get',
                 throwReasons=GAPI.USER_GET_THROW_REASONS,
                 userKey=targetEmail, fields='primaryEmail')
        return 'user'
      except (GAPI.userNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
              GAPI.badRequest, GAPI.backendError, GAPI.systemError):
        if targetType == 'user':
          return None
    try:
      callGAPI(cd.groups(), 'get',
               throwReasons=GAPI.GROUP_GET_THROW_REASONS, retryReasons=GAPI.GROUP_GET_RETRY_REASONS,
               groupKey=targetEmail, fields='email')
      return 'group'
    except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
            GAPI.badRequest, GAPI.invalid, GAPI.systemError):
      return None

  def deleteAliasOnUpdate():
# User alias
    if targetType != 'group':
      try:
        callGAPI(cd.users().aliases(), 'delete',
                 throwReasons=[GAPI.USER_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.INVALID, GAPI.FORBIDDEN, GAPI.INVALID_RESOURCE,
                               GAPI.CONDITION_NOT_MET],
                 userKey=aliasEmail, alias=aliasEmail)
        printEntityKVList([Ent.USER_ALIAS, aliasEmail], [Act.PerformedName(Act.DELETE)], i, count)
        time.sleep(waitAfterDelete)
        return True
      except GAPI.conditionNotMet as e:
        entityActionFailedWarning([Ent.USER_ALIAS, aliasEmail], str(e), i, count)
        return False
      except (GAPI.userNotFound, GAPI.badRequest, GAPI.invalid, GAPI.forbidden, GAPI.invalidResource):
        if targetType == 'user':
          entityUnknownWarning(Ent.USER_ALIAS, aliasEmail, i, count)
          return False
# Group alias
    try:
      callGAPI(cd.groups().aliases(), 'delete',
               throwReasons=[GAPI.GROUP_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.INVALID, GAPI.FORBIDDEN, GAPI.INVALID_RESOURCE,
                             GAPI.CONDITION_NOT_MET],
               groupKey=aliasEmail, alias=aliasEmail)
      time.sleep(waitAfterDelete)
      return True
    except GAPI.conditionNotMet as e:
      entityActionFailedWarning([Ent.GROUP_ALIAS, aliasEmail], str(e), i, count)
      return False
    except GAPI.forbidden:
      entityUnknownWarning(Ent.GROUP_ALIAS, aliasEmail, i, count)
      return False
    except (GAPI.groupNotFound, GAPI.badRequest, GAPI.invalid, GAPI.invalidResource):
      entityUnknownWarning(Ent.GROUP_ALIAS, aliasEmail, i, count)
      return False

  cd = buildGAPIObject(API.DIRECTORY)
  ci = None
  updateCmd = Act.Get() == Act.UPDATE
  aliasList = getEntityList(Cmd.OB_EMAIL_ADDRESS_ENTITY)
  targetType = getChoice(ALIAS_TARGET_TYPES)
  targetEmails = getEntityList(Cmd.OB_GROUP_ENTITY)
  entityLists = targetEmails if isinstance(targetEmails, dict) else None
  verifyNotInvitable = False
  verifyTarget = updateCmd
  waitAfterDelete = 2
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if (not updateCmd) and myarg == 'verifynotinvitable':
      verifyNotInvitable = True
    elif updateCmd and myarg == 'notargetverify':
      verifyTarget = False
    elif updateCmd and myarg == 'waitafterdelete':
      waitAfterDelete = getInteger(minVal=2, maxVal=10)
    else:
      unknownArgumentExit()
  i = 0
  count = len(aliasList)
  for aliasEmail in aliasList:
    i += 1
    if entityLists:
      targetEmails = entityLists[aliasEmail]
    aliasEmail = normalizeEmailAddressOrUID(aliasEmail, noUid=True, noLower=True)
    if verifyNotInvitable:
      isInvitableUser, ci = _getIsInvitableUser(ci, aliasEmail)
      if isInvitableUser:
        entityActionNotPerformedWarning([Ent.ALIAS_EMAIL, aliasEmail], Msg.EMAIL_ADDRESS_IS_UNMANAGED_ACCOUNT)
        continue
    body = {'alias': aliasEmail}
    jcount = len(targetEmails)
    if jcount > 0:
# Only process first target
      targetEmail = normalizeEmailAddressOrUID(targetEmails[0])
      if verifyTarget:
        targetType = verifyAliasTargetExists()
        if targetType is None:
          entityUnknownWarning(Ent.ALIAS_TARGET, targetEmail, i, count)
          continue
      if updateCmd and not deleteAliasOnUpdate():
        continue
# User alias
      if targetType != 'group':
        try:
          callGAPI(cd.users().aliases(), 'insert',
                   throwReasons=[GAPI.USER_NOT_FOUND, GAPI.BAD_REQUEST,
                                 GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.FORBIDDEN, GAPI.DUPLICATE,
                                 GAPI.CONDITION_NOT_MET, GAPI.LIMIT_EXCEEDED],
                   userKey=targetEmail, body=body, fields='')
          entityActionPerformed([Ent.USER_ALIAS, aliasEmail, Ent.USER, targetEmail], i, count)
          continue
        except (GAPI.conditionNotMet, GAPI.limitExceeded) as e:
          entityActionFailedWarning([Ent.USER_ALIAS, aliasEmail, Ent.USER, targetEmail], str(e), i, count)
          continue
        except GAPI.duplicate:
          duplicateAliasGroupUserWarning(cd, [Ent.USER_ALIAS, aliasEmail, Ent.USER, targetEmail], i, count)
          continue
        except (GAPI.invalid, GAPI.invalidInput):
          entityActionFailedWarning([Ent.USER_ALIAS, aliasEmail, Ent.USER, targetEmail], Msg.INVALID_ALIAS, i, count)
          continue
        except (GAPI.userNotFound, GAPI.badRequest, GAPI.forbidden):
          if targetType == 'user':
            entityUnknownWarning(Ent.ALIAS_TARGET, targetEmail, i, count)
            continue
# Group alias
      try:
        callGAPI(cd.groups().aliases(), 'insert',
                 throwReasons=[GAPI.GROUP_NOT_FOUND, GAPI.USER_NOT_FOUND, GAPI.BAD_REQUEST,
                               GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.FORBIDDEN, GAPI.DUPLICATE,
                               GAPI.CONDITION_NOT_MET, GAPI.LIMIT_EXCEEDED],
                 groupKey=targetEmail, body=body, fields='')
        entityActionPerformed([Ent.GROUP_ALIAS, aliasEmail, Ent.GROUP, targetEmail], i, count)
      except (GAPI.conditionNotMet, GAPI.limitExceeded) as e:
        entityActionFailedWarning([Ent.GROUP_ALIAS, aliasEmail, Ent.GROUP, targetEmail], str(e), i, count)
      except GAPI.duplicate:
        duplicateAliasGroupUserWarning(cd, [Ent.GROUP_ALIAS, aliasEmail, Ent.GROUP, targetEmail], i, count)
      except (GAPI.invalid, GAPI.invalidInput):
        entityActionFailedWarning([Ent.GROUP_ALIAS, aliasEmail, Ent.GROUP, targetEmail], Msg.INVALID_ALIAS, i, count)
      except (GAPI.groupNotFound, GAPI.userNotFound, GAPI.badRequest, GAPI.forbidden):
        entityUnknownWarning(Ent.ALIAS_TARGET, targetEmail, i, count)

# gam delete aliases|nicknames [user|group|target] <EmailAddressEntity>
def doDeleteAliases():
  cd = buildGAPIObject(API.DIRECTORY)
  targetType = getChoice(ALIAS_TARGET_TYPES, defaultChoice='target')
  entityList = getEntityList(Cmd.OB_EMAIL_ADDRESS_ENTITY)
  checkForExtraneousArguments()
  i = 0
  count = len(entityList)
  for aliasEmail in entityList:
    i += 1
    aliasEmail = normalizeEmailAddressOrUID(aliasEmail, noUid=True)
    aliasDeleted = False
    if targetType != 'group':
      try:
        result = callGAPI(cd.users().aliases(), 'list',
                          throwReasons=[GAPI.USER_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.INVALID, GAPI.FORBIDDEN, GAPI.INVALID_RESOURCE,
                                        GAPI.CONDITION_NOT_MET],
                          userKey=aliasEmail, fields='aliases(alias)')
        for aliasEntry in result.get('aliases', []):
          if aliasEmail == aliasEntry['alias'].lower():
            aliasEmail = aliasEntry['alias']
            callGAPI(cd.users().aliases(), 'delete',
                     throwReasons=[GAPI.USER_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.INVALID, GAPI.FORBIDDEN, GAPI.INVALID_RESOURCE,
                                   GAPI.CONDITION_NOT_MET],
                     userKey=aliasEmail, alias=aliasEmail)
            entityActionPerformed([Ent.USER_ALIAS, aliasEmail], i, count)
            aliasDeleted = True
            break
        if aliasDeleted:
          continue
      except GAPI.conditionNotMet as e:
        entityActionFailedWarning([Ent.USER_ALIAS, aliasEmail], str(e), i, count)
        continue
      except (GAPI.userNotFound, GAPI.badRequest, GAPI.invalid, GAPI.forbidden, GAPI.invalidResource):
        pass
      if targetType == 'user':
        entityUnknownWarning(Ent.USER_ALIAS, aliasEmail, i, count)
        continue
    try:
      result = callGAPI(cd.groups().aliases(), 'list',
                        throwReasons=[GAPI.GROUP_NOT_FOUND, GAPI.USER_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.INVALID, GAPI.FORBIDDEN, GAPI.INVALID_RESOURCE,
                                      GAPI.CONDITION_NOT_MET],
                        groupKey=aliasEmail, fields='aliases(alias)')
      for aliasEntry in result.get('aliases', []):
        if aliasEmail == aliasEntry['alias'].lower():
          aliasEmail = aliasEntry['alias']
          callGAPI(cd.groups().aliases(), 'delete',
                   throwReasons=[GAPI.GROUP_NOT_FOUND, GAPI.USER_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.INVALID, GAPI.FORBIDDEN, GAPI.INVALID_RESOURCE,
                                 GAPI.CONDITION_NOT_MET],
                   groupKey=aliasEmail, alias=aliasEmail)
          entityActionPerformed([Ent.GROUP_ALIAS, aliasEmail], i, count)
          aliasDeleted = True
          break
      if aliasDeleted:
        continue
    except GAPI.conditionNotMet as e:
      entityActionFailedWarning([Ent.GROUP_ALIAS, aliasEmail], str(e), i, count)
      continue
    except (GAPI.groupNotFound, GAPI.userNotFound, GAPI.badRequest, GAPI.invalid, GAPI.forbidden, GAPI.invalidResource):
      pass
    if targetType == 'group':
      entityUnknownWarning(Ent.GROUP_ALIAS, aliasEmail, i, count)
      continue
    entityUnknownWarning(Ent.ALIAS, aliasEmail, i, count)

# gam remove aliases|nicknames <EmailAddress> user|group <EmailAddressEntity>
def doRemoveAliases():
  cd = buildGAPIObject(API.DIRECTORY)
  targetEmail = getEmailAddress()
  targetType = getChoice(['user', 'group'])
  entityList = getEntityList(Cmd.OB_EMAIL_ADDRESS_ENTITY)
  checkForExtraneousArguments()
  count = len(entityList)
  i = 0
  if targetType == 'user':
    try:
      for aliasEmail in entityList:
        i += 1
        aliasEmail = normalizeEmailAddressOrUID(aliasEmail, noUid=True)
        callGAPI(cd.users().aliases(), 'delete',
                 throwReasons=[GAPI.USER_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.INVALID, GAPI.FORBIDDEN, GAPI.INVALID_RESOURCE,
                               GAPI.CONDITION_NOT_MET],
                 userKey=targetEmail, alias=aliasEmail)
        entityActionPerformed([Ent.USER, targetEmail, Ent.USER_ALIAS, aliasEmail], i, count)
    except (GAPI.userNotFound, GAPI.badRequest, GAPI.invalid, GAPI.forbidden, GAPI.conditionNotMet) as e:
      entityActionFailedWarning([Ent.USER, targetEmail, Ent.USER_ALIAS, aliasEmail], str(e), i, count)
    except GAPI.invalidResource:
      entityActionFailedWarning([Ent.USER, targetEmail, Ent.USER_ALIAS, aliasEmail], Msg.DOES_NOT_EXIST, i, count)
  else:
    try:
      for aliasEmail in entityList:
        i += 1
        aliasEmail = normalizeEmailAddressOrUID(aliasEmail, noUid=True)
        callGAPI(cd.groups().aliases(), 'delete',
                 throwReasons=[GAPI.GROUP_NOT_FOUND, GAPI.USER_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.INVALID, GAPI.FORBIDDEN, GAPI.INVALID_RESOURCE,
                               GAPI.CONDITION_NOT_MET],
                 groupKey=targetEmail, alias=aliasEmail)
        entityActionPerformed([Ent.GROUP, targetEmail, Ent.GROUP_ALIAS, aliasEmail], i, count)
    except (GAPI.groupNotFound, GAPI.userNotFound, GAPI.badRequest, GAPI.invalid, GAPI.forbidden, GAPI.conditionNotMet) as e:
      entityActionFailedWarning([Ent.GROUP, targetEmail, Ent.GROUP_ALIAS, aliasEmail], str(e), i, count)
    except GAPI.invalidResource:
      entityActionFailedWarning([Ent.GROUP, targetEmail, Ent.GROUP_ALIAS, aliasEmail], Msg.DOES_NOT_EXIST, i, count)

def _addUserAliases(cd, user, aliasList, i, count):
  jcount = len(aliasList)
  entityPerformActionNumItems([Ent.USER, user], jcount, Ent.USER_ALIAS, i, count)
  Ind.Increment()
  j = 0
  for aliasEmail in aliasList:
    j += 1
    aliasEmail = normalizeEmailAddressOrUID(aliasEmail, noUid=True, noLower=True)
    body = {'alias': aliasEmail}
    try:
      callGAPI(cd.users().aliases(), 'insert',
               throwReasons=[GAPI.USER_NOT_FOUND, GAPI.BAD_REQUEST,
                             GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.FORBIDDEN, GAPI.DUPLICATE,
                             GAPI.CONDITION_NOT_MET, GAPI.LIMIT_EXCEEDED],
               userKey=user, body=body, fields='')
      entityActionPerformed([Ent.USER, user, Ent.USER_ALIAS, aliasEmail], j, jcount)
    except (GAPI.conditionNotMet, GAPI.limitExceeded) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.USER_ALIAS, aliasEmail], str(e), j, jcount)
    except GAPI.duplicate:
      duplicateAliasGroupUserWarning(cd, [Ent.USER, user, Ent.USER_ALIAS, aliasEmail], j, jcount)
    except (GAPI.invalid, GAPI.invalidInput):
      entityActionFailedWarning([Ent.USER, user, Ent.USER_ALIAS, aliasEmail], Msg.INVALID_ALIAS, j, jcount)
    except (GAPI.userNotFound, GAPI.badRequest, GAPI.forbidden):
      entityUnknownWarning(Ent.USER, user, i, count)
  Ind.Decrement()

# gam <UserTypeEntity> delete alias|aliases
def deleteUsersAliases(users):
  cd = buildGAPIObject(API.DIRECTORY)
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    try:
      user_aliases = callGAPI(cd.users(), 'get',
                              throwReasons=GAPI.USER_GET_THROW_REASONS,
                              userKey=user, fields='id,primaryEmail,aliases')
      user_id = user_aliases['id']
      user_primary = user_aliases['primaryEmail']
      jcount = len(user_aliases['aliases']) if ('aliases' in user_aliases) else 0
      entityPerformActionNumItems([Ent.USER, user_primary], jcount, Ent.ALIAS, i, count)
      if jcount == 0:
        setSysExitRC(NO_ENTITIES_FOUND_RC)
        continue
      Ind.Increment()
      j = 0
      for an_alias in user_aliases['aliases']:
        j += 1
        try:
          callGAPI(cd.users().aliases(), 'delete',
                   throwReasons=[GAPI.RESOURCE_ID_NOT_FOUND],
                   userKey=user_id, alias=an_alias)
          entityActionPerformed([Ent.USER, user_primary, Ent.ALIAS, an_alias], j, jcount)
        except GAPI.resourceIdNotFound:
          entityActionFailedWarning([Ent.USER, user_primary, Ent.ALIAS, an_alias], Msg.DOES_NOT_EXIST, j, jcount)
      Ind.Decrement()
    except (GAPI.userNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
            GAPI.badRequest, GAPI.backendError, GAPI.systemError):
      entityUnknownWarning(Ent.USER, user, i, count)

def infoAliases(entityList):

  def _showAliasInfo(uid, email, aliasEmail, entityType, aliasEntityType, i, count):
    if email.lower() != aliasEmail:
      printEntity([aliasEntityType, aliasEmail], i, count)
      Ind.Increment()
      printEntity([entityType, email])
      printEntity([Ent.UNIQUE_ID, uid])
      Ind.Decrement()
    else:
      setSysExitRC(ENTITY_IS_NOT_AN_ALIAS_RC)
      printEntityKVList([Ent.EMAIL, aliasEmail],
                        [f'Is a {Ent.Singular(entityType)}, not a {Ent.Singular(aliasEntityType)}'],
                        i, count)

  cd = buildGAPIObject(API.DIRECTORY)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
# Ignore info group/user arguments that may have come from whatis
    if (myarg in INFO_GROUP_OPTIONS) or (myarg in INFO_USER_OPTIONS):
      if myarg == 'schemas':
        getString(Cmd.OB_SCHEMA_NAME_LIST)
    else:
      unknownArgumentExit()
  i = 0
  count = len(entityList)
  for aliasEmail in entityList:
    i += 1
    aliasEmail = normalizeEmailAddressOrUID(aliasEmail, noUid=True, noLower=True)
    try:
      result = callGAPI(cd.users(), 'get',
                        throwReasons=GAPI.USER_GET_THROW_REASONS,
                        userKey=aliasEmail, fields='id,primaryEmail')
      _showAliasInfo(result['id'], result['primaryEmail'], aliasEmail, Ent.USER_EMAIL, Ent.USER_ALIAS, i, count)
      continue
    except (GAPI.userNotFound, GAPI.badRequest):
      pass
    except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
            GAPI.backendError, GAPI.systemError):
      entityUnknownWarning(Ent.USER_ALIAS, aliasEmail, i, count)
      continue
    try:
      result = callGAPI(cd.groups(), 'get',
                        throwReasons=GAPI.GROUP_GET_THROW_REASONS,
                        groupKey=aliasEmail, fields='id,email')
      _showAliasInfo(result['id'], result['email'], aliasEmail, Ent.GROUP_EMAIL, Ent.GROUP_ALIAS, i, count)
      continue
    except GAPI.groupNotFound:
      pass
    except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest):
      entityUnknownWarning(Ent.GROUP_ALIAS, aliasEmail, i, count)
      continue
    entityUnknownWarning(Ent.EMAIL, aliasEmail, i, count)

# gam info aliases|nicknames <EmailAddressEntity>
def doInfoAliases():
  infoAliases(getEntityList(Cmd.OB_EMAIL_ADDRESS_ENTITY))

def initUserGroupDomainQueryFilters():
  if not GC.Values[GC.PRINT_AGU_DOMAINS]:
    return {'list': [{'customer': GC.Values[GC.CUSTOMER_ID]}], 'queries': [None]}
  return {'list': [{'domain': domain.lower()} for domain in GC.Values[GC.PRINT_AGU_DOMAINS].replace(',', ' ').split()], 'queries': [None]}

def getUserGroupDomainQueryFilters(myarg, kwargsDict):
  if myarg in {'domain', 'domains'}:
    kwargsDict['list'] = [{'domain': domain.lower()} for domain in getEntityList(Cmd.OB_DOMAIN_NAME_ENTITY)]
  elif myarg in {'query', 'queries'}:
    kwargsDict['queries'] = getQueries(myarg)
  else:
    return False
  return True

def makeUserGroupDomainQueryFilters(kwargsDict):
  kwargsQueries = []
  for kwargs in kwargsDict['list']:
    for query in kwargsDict['queries']:
      kwargsQueries.append((kwargs, query))
  return kwargsQueries

def userFilters(kwargs, query, orgUnitPath, isSuspended):
  queryTitle = ''
  if kwargs.get('domain'):
    queryTitle += f'domain={kwargs["domain"]}, '
  if orgUnitPath is not None:
    if query is not None and query.find(orgUnitPath) == -1:
      query += f" orgUnitPath='{orgUnitPath}'"
    else:
      if query is None:
        query = ''
      else:
        query += ' '
      query += f"orgUnitPath='{orgUnitPath}'"
  if isSuspended is not None:
    if query is None:
      query = ''
    else:
      query += ' '
    query += f'isSuspended={isSuspended}'
  if query is not None:
    queryTitle += f'query="{query}", '
  if queryTitle:
    return query, queryTitle[:-2]
  return query, queryTitle

# gam print aliases|nicknames [todrive <ToDriveAttribute>*]
#	([domain|domains <DomainNameEntity>] [(query <QueryUser>)|(queries <QueryUserList>)]
#	 [limittoou <OrgUnitItem>])
#	[user|users <EmailAddressList>] [group|groups <EmailAddressList>]
#	[select <UserTypeEntity>]
#	[issuspended <Boolean>] [aliasmatchpattern <REMatchPattern>]
#	[shownoneditable] [nogroups] [nousers]
#	[onerowpertarget] [delimiter <Character>]
#	[suppressnoaliasrows]
#	(addcsvdata <FieldName> <String>)*
def doPrintAliases():
  def writeAliases(target, targetEmail, targetType):
    if not oneRowPerTarget:
      for alias in target.get('aliases', []):
        if aliasMatchPattern.match(alias):
          row = {'Alias': alias, 'Target': targetEmail, 'TargetType': targetType}
          if addCSVData:
            row.update(addCSVData)
          csvPF.WriteRow(row)
      if showNonEditable:
        for alias in target.get('nonEditableAliases', []):
          if aliasMatchPattern.match(alias):
            row = {'NonEditableAlias': alias, 'Target': targetEmail, 'TargetType': targetType}
            if addCSVData:
              row.update(addCSVData)
            csvPF.WriteRow(row)
    else:
      aliases = [alias for alias in target.get('aliases', []) if aliasMatchPattern.match(alias)]
      if showNonEditable:
        nealiases = [alias for alias in target.get('nonEditableAliases', []) if aliasMatchPattern.match(alias)]
      else:
        nealiases = []
      if suppressNoAliasRows and not aliases and not nealiases:
        return
      row = {'Target': targetEmail, 'TargetType': targetType, 'Aliases': delimiter.join(aliases)}
      if showNonEditable:
        row['NonEditableAliases'] = delimiter.join(nealiases)
      if addCSVData:
        row.update(addCSVData)
      csvPF.WriteRow(row)

  cd = buildGAPIObject(API.DIRECTORY)
  csvPF = CSVPrintFile()
  userFields = ['primaryEmail', 'aliases']
  groupFields = ['email', 'aliases']
  oneRowPerTarget = showNonEditable = suppressNoAliasRows = False
  kwargsDict = initUserGroupDomainQueryFilters()
  getGroups = getUsers = True
  groups = []
  users = []
  aliasMatchPattern = re.compile(r'^.*$')
  isSuspended = orgUnitPath = None
  addCSVData = {}
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'shownoneditable':
      showNonEditable= True
      userFields.append('nonEditableAliases')
      groupFields.append('nonEditableAliases')
    elif myarg == 'nogroups':
      getGroups = False
    elif myarg == 'nousers':
      getUsers = False
    elif myarg == 'limittoou':
      orgUnitPath = getOrgUnitItem(pathOnly=True, cd=cd)
      orgUnitPathLower = orgUnitPath.lower()
      userFields.append('orgUnitPath')
      getGroups = False
    elif getUserGroupDomainQueryFilters(myarg, kwargsDict):
      pass
    elif myarg == 'select':
      _, users = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)
    elif myarg == 'issuspended':
      isSuspended = getBoolean()
    elif myarg in {'user','users'}:
      users.extend(convertEntityToList(getString(Cmd.OB_EMAIL_ADDRESS_LIST, minLen=0)))
    elif myarg in {'group', 'groups'}:
      groups.extend(convertEntityToList(getString(Cmd.OB_EMAIL_ADDRESS_LIST, minLen=0)))
    elif myarg == 'aliasmatchpattern':
      aliasMatchPattern = getREPattern(re.IGNORECASE)
    elif myarg == 'onerowpertarget':
      oneRowPerTarget = True
    elif myarg == 'suppressnoaliasrows':
      suppressNoAliasRows = True
    elif myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    else:
      unknownArgumentExit()
  if (users or groups) and kwargsDict['queries'][0] is None:
    getUsers = getGroups = False
  if not oneRowPerTarget:
    titlesList = ['Alias', 'Target', 'TargetType']
    if showNonEditable:
      titlesList.insert(1, 'NonEditableAlias')
  else:
    titlesList = ['Target', 'TargetType', 'Aliases']
    if showNonEditable:
      titlesList.append('NonEditableAliases')
  csvPF.SetTitles(titlesList)
  if addCSVData:
    csvPF.AddTitles(sorted(addCSVData.keys()))
  if getUsers:
    for kwargsQuery in makeUserGroupDomainQueryFilters(kwargsDict):
      kwargs = kwargsQuery[0]
      query = kwargsQuery[1]
      query, pquery = userFilters(kwargs, query, orgUnitPath, isSuspended)
      printGettingAllAccountEntities(Ent.USER, pquery)
      try:
        entityList = callGAPIpages(cd.users(), 'list', 'users',
                                   pageMessage=getPageMessage(showFirstLastItems=True), messageAttribute='primaryEmail',
                                   throwReasons=[GAPI.INVALID_ORGUNIT, GAPI.INVALID_INPUT, GAPI.DOMAIN_NOT_FOUND,
                                                 GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN, GAPI.BAD_REQUEST,
                                                 GAPI.UNKNOWN_ERROR, GAPI.FAILED_PRECONDITION],
                                   retryReasons=[GAPI.UNKNOWN_ERROR, GAPI.FAILED_PRECONDITION],
                                   query=query, orderBy='email',
                                   fields=f'nextPageToken,users({",".join(userFields)})',
                                   maxResults=GC.Values[GC.USER_MAX_RESULTS], **kwargs)
        for user in entityList:
          if orgUnitPath is None or orgUnitPathLower == user.get('orgUnitPath', '').lower():
            writeAliases(user, user['primaryEmail'], 'User')
      except (GAPI.invalidOrgunit, GAPI.invalidInput):
        entityActionFailedWarning([Ent.ALIAS, None], invalidQuery(query))
        continue
      except GAPI.domainNotFound as e:
        entityActionFailedWarning([Ent.ALIAS, None, Ent.DOMAIN, kwargs['domain']], str(e))
        continue
      except (GAPI.unknownError, GAPI.failedPrecondition) as e:
        entityActionFailedExit([Ent.USER, None], str(e))
      except (GAPI.resourceNotFound, GAPI.forbidden, GAPI.badRequest):
        accessErrorExit(cd)
  count = len(users)
  i = 0
  for user in users:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    printGettingEntityItemForWhom(Ent.USER_ALIAS, user, i, count)
    try:
      result = callGAPI(cd.users().aliases(), 'list',
                        throwReasons=[GAPI.USER_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.INVALID, GAPI.FORBIDDEN, GAPI.INVALID_RESOURCE,
                                      GAPI.CONDITION_NOT_MET],
                        userKey=user, fields='aliases(alias)')
      aliases = {'aliases': [alias['alias'] for alias in result.get('aliases', [])]}
      writeAliases(aliases, user, 'User')
    except (GAPI.userNotFound, GAPI.badRequest, GAPI.invalid, GAPI.forbidden, GAPI.invalidResource, GAPI.conditionNotMet) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
  if getGroups:
    for kwargsQuery in makeUserGroupDomainQueryFilters(kwargsDict):
      kwargs = kwargsQuery[0]
      query = kwargsQuery[1]
      query, pquery = groupFilters(kwargs, query)
      printGettingAllAccountEntities(Ent.GROUP, pquery)
      try:
        entityList = callGAPIpages(cd.groups(), 'list', 'groups',
                                   pageMessage=getPageMessage(showFirstLastItems=True), messageAttribute='email',
                                   throwReasons=GAPI.GROUP_LIST_USERKEY_THROW_REASONS,
                                   retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                   query=query, orderBy='email',
                                   fields=f'nextPageToken,groups({",".join(groupFields)})', **kwargs)
        for group in entityList:
          writeAliases(group, group['email'], 'Group')
      except (GAPI.invalidMember, GAPI.invalidInput) as e:
        if not invalidMember(query):
          entityActionFailedExit([Ent.GROUP, None], str(e))
      except GAPI.domainNotFound as e:
        entityActionFailedWarning([Ent.ALIAS, None, Ent.DOMAIN, kwargs['domain']], str(e))
        continue
      except (GAPI.resourceNotFound, GAPI.forbidden, GAPI.badRequest):
        accessErrorExit(cd)
  count = len(groups)
  i = 0
  for group in groups:
    i += 1
    group = normalizeEmailAddressOrUID(group)
    printGettingEntityItemForWhom(Ent.GROUP_ALIAS, group, i, count)
    try:
      result = callGAPI(cd.groups().aliases(), 'list',
                        throwReasons=[GAPI.GROUP_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.INVALID, GAPI.FORBIDDEN, GAPI.INVALID_RESOURCE,
                                      GAPI.CONDITION_NOT_MET],
                        groupKey=group, fields='aliases(alias)')
      aliases = {'aliases': [alias['alias'] for alias in result.get('aliases', [])]}
      writeAliases(aliases, group, 'Group')
    except (GAPI.groupNotFound, GAPI.badRequest, GAPI.invalid, GAPI.forbidden, GAPI.invalidResource, GAPI.conditionNotMet) as e:
      entityActionFailedWarning([Ent.GROUP, group], str(e), i, count)
  csvPF.writeCSVfile('Aliases')

# gam print addresses [todrive <ToDriveAttribute>*]
#	[domain <DomainName>]
def doPrintAddresses():
  cd = buildGAPIObject(API.DIRECTORY)
  kwargs = {'customer': GC.Values[GC.CUSTOMER_ID]}
  csvPF = CSVPrintFile()
  titlesList = ['Type', 'Email', 'Target']
  userFields = ['primaryEmail', 'aliases', 'nonEditableAliases', 'suspended']
  groupFields = ['email', 'aliases', 'nonEditableAliases']
  domainFields = ['domainName', 'isPrimary', 'domainAliases']
  resourceFields = ['resourceEmail']
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'domain':
      kwargs['domain'] = getString(Cmd.OB_DOMAIN_NAME).lower()
      kwargs.pop('customer', None)
    else:
      unknownArgumentExit()
  csvPF.SetTitles(titlesList)
  printGettingAllAccountEntities(Ent.USER)
  try:
    entityList = callGAPIpages(cd.users(), 'list', 'users',
                               pageMessage=getPageMessage(showFirstLastItems=True), messageAttribute='primaryEmail',
                               throwReasons=[GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN, GAPI.BAD_REQUEST,
                                             GAPI.UNKNOWN_ERROR, GAPI.FAILED_PRECONDITION],
                               retryReasons=[GAPI.UNKNOWN_ERROR, GAPI.FAILED_PRECONDITION],
                               orderBy='email', fields=f'nextPageToken,users({",".join(userFields)})',
                               maxResults=GC.Values[GC.USER_MAX_RESULTS], **kwargs)
  except (GAPI.unknownError, GAPI.failedPrecondition) as e:
    entityActionFailedExit([Ent.USER, None], str(e))
  except (GAPI.resourceNotFound, GAPI.forbidden, GAPI.badRequest):
    accessErrorExit(cd)
  for user in entityList:
    userEmail = user['primaryEmail']
    prefix = '' if not user['suspended'] else 'Suspended'
    csvPF.WriteRow({'Type': f'{prefix}User', 'Email': userEmail})
    for alias in user.get('aliases', []):
      csvPF.WriteRow({'Type': f'{prefix}UserAlias', 'Email': alias, 'Target': userEmail})
    for alias in user.get('nonEditableAliases', []):
      csvPF.WriteRow({'Type': f'{prefix}UserNEAlias', 'Email': alias, 'Target': userEmail})
  printGettingAllAccountEntities(Ent.GROUP)
  try:
    entityList = callGAPIpages(cd.groups(), 'list', 'groups',
                               pageMessage=getPageMessage(showFirstLastItems=True), messageAttribute='email',
                               throwReasons=GAPI.GROUP_LIST_THROW_REASONS,
                               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                               orderBy='email', fields=f'nextPageToken,groups({",".join(groupFields)})', **kwargs)
  except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.forbidden, GAPI.badRequest):
    accessErrorExit(cd)
  for group in entityList:
    groupEmail = group['email']
    csvPF.WriteRow({'Type': 'Group', 'Email': groupEmail})
    for alias in group.get('aliases', []):
      csvPF.WriteRow({'Type': 'GroupAlias', 'Email': alias, 'Target': groupEmail})
    for alias in group.get('nonEditableAliases', []):
      csvPF.WriteRow({'Type': 'GroupNEAlias', 'Email': alias, 'Target': groupEmail})
  printGettingAllAccountEntities(Ent.RESOURCE_CALENDAR)
  try:
    entityList = callGAPIpages(cd.resources().calendars(), 'list', 'items',
                               pageMessage=getPageMessage(showFirstLastItems=True), messageAttribute='resourceEmail',
                               throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID_INPUT],
                               customer=GC.Values[GC.CUSTOMER_ID], fields=f'nextPageToken,items({",".join(resourceFields)})')
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
    accessErrorExit(cd)
  except GAPI.invalidInput as e:
    entityActionFailedWarning([Ent.RESOURCE_CALENDAR, ''], str(e))
    return
  for resource in entityList:
    csvPF.WriteRow({'Type': 'Resource', 'Email': resource['resourceEmail']})
  try:
    entityList = callGAPIitems(cd.domains(), 'list', 'domains',
                               throwReasons=[GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                               customer=GC.Values[GC.CUSTOMER_ID], fields=f'domains({",".join(domainFields)})')
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden):
    accessErrorExit(cd)
  for domain in entityList:
    domainEmail = domain['domainName']
    csvPF.WriteRow({'Type': 'DomainPrimary' if domain['isPrimary'] else 'DomainSecondary', 'Email': domainEmail})
    for alias in domain.get('domainAliases', []):
      csvPF.WriteRow({'Type': 'DomainAlias', 'Email': alias['domainAliasName'], 'Target': domainEmail})
  csvPF.SortRowsTwoTitles('Type', 'Email', False)
  csvPF.writeCSVfile('Addresses')

# Contact commands utilities
#
def _getCreateContactReturnOptions(parameters):
  myarg = getArgument()
  if myarg == 'returnidonly':
    parameters['returnIdOnly'] = True
  elif myarg == 'csv':
    parameters['csvPF'] = CSVPrintFile(parameters['titles'], 'sortall')
  elif parameters['csvPF'] and myarg == 'todrive':
    parameters['csvPF'].GetTodriveParameters()
  elif parameters['csvPF'] and myarg == 'addcsvdata':
    k = getString(Cmd.OB_STRING)
    parameters['addCSVData'][k] = getString(Cmd.OB_STRING, minLen=0)
  else:
    return False
  return True
#
CONTACT_JSON = 'JSON'

CONTACT_ID = 'ContactID'
CONTACT_UPDATED = 'Updated'
CONTACT_NAME_PREFIX = 'Name Prefix'
CONTACT_GIVEN_NAME = 'Given Name'
CONTACT_ADDITIONAL_NAME = 'Additional Name'
CONTACT_FAMILY_NAME = 'Family Name'
CONTACT_NAME_SUFFIX = 'Name Suffix'
CONTACT_NAME = 'Name'
CONTACT_NICKNAME = 'Nickname'
CONTACT_MAIDENNAME = 'Maiden Name'
CONTACT_SHORTNAME = 'Short Name'
CONTACT_INITIALS = 'Initials'
CONTACT_BIRTHDAY = 'Birthday'
CONTACT_GENDER = 'Gender'
CONTACT_LOCATION = 'Location'
CONTACT_PRIORITY = 'Priority'
CONTACT_SENSITIVITY = 'Sensitivity'
CONTACT_SUBJECT = 'Subject'
CONTACT_LANGUAGE = 'Language'
CONTACT_NOTES = 'Notes'
CONTACT_OCCUPATION = 'Occupation'
CONTACT_BILLING_INFORMATION = 'Billing Information'
CONTACT_MILEAGE = 'Mileage'
CONTACT_DIRECTORY_SERVER = 'Directory Server'
CONTACT_ADDRESSES = 'Addresses'
CONTACT_CALENDARS = 'Calendars'
CONTACT_EMAILS = 'Emails'
CONTACT_EXTERNALIDS = 'External IDs'
CONTACT_EVENTS = 'Events'
CONTACT_HOBBIES = 'Hobbies'
CONTACT_IMS = 'IMs'
CONTACT_JOTS = 'Jots'
CONTACT_ORGANIZATIONS = 'Organizations'
CONTACT_PHONES = 'Phones'
CONTACT_RELATIONS = 'Relations'
CONTACT_USER_DEFINED_FIELDS = 'User Defined Fields'
CONTACT_WEBSITES = 'Websites'
#
class ContactsManager():

  CONTACT_ARGUMENT_TO_PROPERTY_MAP = {
    'json': CONTACT_JSON,
    'name': CONTACT_NAME,
    'prefix': CONTACT_NAME_PREFIX,
    'givenname': CONTACT_GIVEN_NAME,
    'additionalname': CONTACT_ADDITIONAL_NAME,
    'familyname': CONTACT_FAMILY_NAME,
    'firstname': CONTACT_GIVEN_NAME,
    'middlename': CONTACT_ADDITIONAL_NAME,
    'lastname': CONTACT_FAMILY_NAME,
    'suffix': CONTACT_NAME_SUFFIX,
    'nickname': CONTACT_NICKNAME,
    'maidenname': CONTACT_MAIDENNAME,
    'shortname': CONTACT_SHORTNAME,
    'initials': CONTACT_INITIALS,
    'birthday': CONTACT_BIRTHDAY,
    'gender': CONTACT_GENDER,
    'location': CONTACT_LOCATION,
    'priority': CONTACT_PRIORITY,
    'sensitivity': CONTACT_SENSITIVITY,
    'subject': CONTACT_SUBJECT,
    'language': CONTACT_LANGUAGE,
    'note': CONTACT_NOTES,
    'notes': CONTACT_NOTES,
    'occupation': CONTACT_OCCUPATION,
    'billinginfo': CONTACT_BILLING_INFORMATION,
    'mileage': CONTACT_MILEAGE,
    'directoryserver': CONTACT_DIRECTORY_SERVER,
    'address': CONTACT_ADDRESSES,
    'addresses': CONTACT_ADDRESSES,
    'calendar': CONTACT_CALENDARS,
    'calendars': CONTACT_CALENDARS,
    'email': CONTACT_EMAILS,
    'emails': CONTACT_EMAILS,
    'externalid': CONTACT_EXTERNALIDS,
    'externalids': CONTACT_EXTERNALIDS,
    'event': CONTACT_EVENTS,
    'events': CONTACT_EVENTS,
    'hobby': CONTACT_HOBBIES,
    'hobbies': CONTACT_HOBBIES,
    'im': CONTACT_IMS,
    'ims': CONTACT_IMS,
    'jot': CONTACT_JOTS,
    'jots': CONTACT_JOTS,
    'organization': CONTACT_ORGANIZATIONS,
    'organizations': CONTACT_ORGANIZATIONS,
    'organisation': CONTACT_ORGANIZATIONS,
    'organisations': CONTACT_ORGANIZATIONS,
    'phone': CONTACT_PHONES,
    'phones': CONTACT_PHONES,
    'relation': CONTACT_RELATIONS,
    'relations': CONTACT_RELATIONS,
    'userdefinedfield': CONTACT_USER_DEFINED_FIELDS,
    'userdefinedfields': CONTACT_USER_DEFINED_FIELDS,
    'website': CONTACT_WEBSITES,
    'websites': CONTACT_WEBSITES,
    'updated': CONTACT_UPDATED,
    }

  GENDER_CHOICE_MAP = {'male': 'male', 'female': 'female'}

  PRIORITY_CHOICE_MAP = {'low': 'low', 'normal': 'normal', 'high': 'high'}

  SENSITIVITY_CHOICE_MAP = {
    'confidential': 'confidential',
    'normal': 'normal',
    'personal': 'personal',
    'private': 'private',
    }

  CONTACT_NAME_FIELDS = (
    CONTACT_NAME_PREFIX,
    CONTACT_GIVEN_NAME,
    CONTACT_ADDITIONAL_NAME,
    CONTACT_FAMILY_NAME,
    CONTACT_NAME_SUFFIX,
    )

  ADDRESS_TYPE_ARGUMENT_TO_REL = {
    'work': gdata.apps.contacts.REL_WORK,
    'home': gdata.apps.contacts.REL_HOME,
    'other': gdata.apps.contacts.REL_OTHER,
    }

  ADDRESS_REL_TO_TYPE_ARGUMENT = {
    gdata.apps.contacts.REL_WORK: 'work',
    gdata.apps.contacts.REL_HOME: 'home',
    gdata.apps.contacts.REL_OTHER: 'other',
    }

  ADDRESS_ARGUMENT_TO_FIELD_MAP = {
    'streetaddress': 'street',
    'pobox': 'pobox',
    'neighborhood': 'neighborhood',
    'locality': 'city',
    'region': 'region',
    'postalcode': 'postcode',
    'country': 'country',
    'formatted': 'value', 'unstructured': 'value',
    }

  ADDRESS_FIELD_TO_ARGUMENT_MAP = {
    'street': 'streetaddress',
    'pobox': 'pobox',
    'neighborhood': 'neighborhood',
    'city': 'locality',
    'region': 'region',
    'postcode': 'postalcode',
    'country': 'country',
    }

  ADDRESS_FIELD_PRINT_ORDER = [
    'street',
    'pobox',
    'neighborhood',
    'city',
    'region',
    'postcode',
    'country',
    ]

  CALENDAR_TYPE_ARGUMENT_TO_REL = {
    'work': 'work',
    'home': 'home',
    'free-busy': 'free-busy',
    }

  CALENDAR_REL_TO_TYPE_ARGUMENT = {
    'work': 'work',
    'home': 'home',
    'free-busy': 'free-busy',
    }

  EMAIL_TYPE_ARGUMENT_TO_REL = {
    'work': gdata.apps.contacts.REL_WORK,
    'home': gdata.apps.contacts.REL_HOME,
    'other': gdata.apps.contacts.REL_OTHER,
    }

  EMAIL_REL_TO_TYPE_ARGUMENT = {
    gdata.apps.contacts.REL_WORK: 'work',
    gdata.apps.contacts.REL_HOME: 'home',
    gdata.apps.contacts.REL_OTHER: 'other',
    }

  EVENT_TYPE_ARGUMENT_TO_REL = {
    'anniversary': 'anniversary',
    'other': 'other',
    }

  EVENT_REL_TO_TYPE_ARGUMENT = {
    'anniversary': 'anniversary',
    'other': 'other',
    }

  EXTERNALID_TYPE_ARGUMENT_TO_REL = {
    'account': 'account',
    'customer': 'customer',
    'network': 'network',
    'organization': 'organization',
    'organisation': 'organization',
    }

  EXTERNALID_REL_TO_TYPE_ARGUMENT = {
    'account': 'account',
    'customer': 'customer',
    'network': 'network',
    'organization': 'organization',
    'organisation': 'organization',
    }

  IM_TYPE_ARGUMENT_TO_REL = {
    'work': gdata.apps.contacts.REL_WORK,
    'home': gdata.apps.contacts.REL_HOME,
    'other': gdata.apps.contacts.REL_OTHER,
    }

  IM_REL_TO_TYPE_ARGUMENT = {
    gdata.apps.contacts.REL_WORK: 'work',
    gdata.apps.contacts.REL_HOME: 'home',
    gdata.apps.contacts.REL_OTHER: 'other',
    }

  IM_PROTOCOL_TO_REL_MAP = {
    'aim': gdata.apps.contacts.IM_AIM,
    'gtalk': gdata.apps.contacts.IM_GOOGLE_TALK,
    'icq': gdata.apps.contacts.IM_ICQ,
    'jabber': gdata.apps.contacts.IM_JABBER,
    'msn': gdata.apps.contacts.IM_MSN,
    'netmeeting': gdata.apps.contacts.IM_NETMEETING,
    'qq': gdata.apps.contacts.IM_QQ,
    'skype': gdata.apps.contacts.IM_SKYPE,
    'xmpp': gdata.apps.contacts.IM_JABBER,
    'yahoo': gdata.apps.contacts.IM_YAHOO,
    }

  IM_REL_TO_PROTOCOL_MAP = {
    gdata.apps.contacts.IM_AIM: 'aim',
    gdata.apps.contacts.IM_GOOGLE_TALK: 'gtalk',
    gdata.apps.contacts.IM_ICQ: 'icq',
    gdata.apps.contacts.IM_JABBER: 'jabber',
    gdata.apps.contacts.IM_MSN: 'msn',
    gdata.apps.contacts.IM_NETMEETING: 'netmeeting',
    gdata.apps.contacts.IM_QQ: 'qq',
    gdata.apps.contacts.IM_SKYPE: 'skype',
    gdata.apps.contacts.IM_YAHOO: 'yahoo',
    }

  JOT_TYPE_ARGUMENT_TO_REL = {
    'work': 'work',
    'home': 'home',
    'other': 'other',
    'keywords': 'keywords',
    'user': 'user',
    }

  JOT_REL_TO_TYPE_ARGUMENT = {
    'work': 'work',
    'home': 'home',
    'other': 'other',
    'keywords': 'keywords',
    'user': 'user',
    }

  ORGANIZATION_TYPE_ARGUMENT_TO_REL = {
    'work': gdata.apps.contacts.REL_WORK,
    'other': gdata.apps.contacts.REL_OTHER,
    }

  ORGANIZATION_REL_TO_TYPE_ARGUMENT = {
    gdata.apps.contacts.REL_WORK: 'work',
    gdata.apps.contacts.REL_OTHER: 'other',
    }

  ORGANIZATION_ARGUMENT_TO_FIELD_MAP = {
    'location': 'where',
    'department': 'department',
    'title': 'title',
    'jobdescription': 'jobdescription',
    'symbol': 'symbol',
    }

  ORGANIZATION_FIELD_TO_ARGUMENT_MAP = {
    'where': 'location',
    'department': 'department',
    'title': 'title',
    'jobdescription': 'jobdescription',
    'symbol': 'symbol',
    }

  ORGANIZATION_FIELD_PRINT_ORDER = [
    'where',
    'department',
    'title',
    'jobdescription',
    'symbol',
    ]

  PHONE_TYPE_ARGUMENT_TO_REL = {
    'work': gdata.apps.contacts.PHONE_WORK,
    'home': gdata.apps.contacts.PHONE_HOME,
    'other': gdata.apps.contacts.PHONE_OTHER,
    'fax': gdata.apps.contacts.PHONE_FAX,
    'home_fax': gdata.apps.contacts.PHONE_HOME_FAX,
    'work_fax': gdata.apps.contacts.PHONE_WORK_FAX,
    'other_fax': gdata.apps.contacts.PHONE_OTHER_FAX,
    'main': gdata.apps.contacts.PHONE_MAIN,
    'company_main': gdata.apps.contacts.PHONE_COMPANY_MAIN,
    'assistant': gdata.apps.contacts.PHONE_ASSISTANT,
    'mobile': gdata.apps.contacts.PHONE_MOBILE,
    'work_mobile': gdata.apps.contacts.PHONE_WORK_MOBILE,
    'pager': gdata.apps.contacts.PHONE_PAGER,
    'work_pager': gdata.apps.contacts.PHONE_WORK_PAGER,
    'car': gdata.apps.contacts.PHONE_CAR,
    'radio': gdata.apps.contacts.PHONE_RADIO,
    'callback': gdata.apps.contacts.PHONE_CALLBACK,
    'isdn': gdata.apps.contacts.PHONE_ISDN,
    'telex': gdata.apps.contacts.PHONE_TELEX,
    'tty_tdd': gdata.apps.contacts.PHONE_TTY_TDD,
    }

  PHONE_REL_TO_TYPE_ARGUMENT = {
    gdata.apps.contacts.PHONE_WORK: 'work',
    gdata.apps.contacts.PHONE_HOME: 'home',
    gdata.apps.contacts.PHONE_OTHER: 'other',
    gdata.apps.contacts.PHONE_FAX: 'fax',
    gdata.apps.contacts.PHONE_HOME_FAX: 'home_fax',
    gdata.apps.contacts.PHONE_WORK_FAX: 'work_fax',
    gdata.apps.contacts.PHONE_OTHER_FAX: 'other_fax',
    gdata.apps.contacts.PHONE_MAIN: 'main',
    gdata.apps.contacts.PHONE_COMPANY_MAIN: 'company_main',
    gdata.apps.contacts.PHONE_ASSISTANT: 'assistant',
    gdata.apps.contacts.PHONE_MOBILE: 'mobile',
    gdata.apps.contacts.PHONE_WORK_MOBILE: 'work_mobile',
    gdata.apps.contacts.PHONE_PAGER: 'pager',
    gdata.apps.contacts.PHONE_WORK_PAGER: 'work_pager',
    gdata.apps.contacts.PHONE_CAR: 'car',
    gdata.apps.contacts.PHONE_RADIO: 'radio',
    gdata.apps.contacts.PHONE_CALLBACK: 'callback',
    gdata.apps.contacts.PHONE_ISDN: 'isdn',
    gdata.apps.contacts.PHONE_TELEX: 'telex',
    gdata.apps.contacts.PHONE_TTY_TDD: 'tty_tdd',
    }

  RELATION_TYPE_ARGUMENT_TO_REL = {
    'spouse': 'spouse',
    'child': 'child',
    'mother': 'mother',
    'father': 'father',
    'parent': 'parent',
    'brother': 'brother',
    'sister': 'sister',
    'friend': 'friend',
    'relative': 'relative',
    'manager': 'manager',
    'assistant': 'assistant',
    'referredby': 'referred-by',
    'partner': 'partner',
    'domesticpartner': 'domestic-partner',
    }

  RELATION_REL_TO_TYPE_ARGUMENT = {
    'spouse' : 'spouse',
    'child' : 'child',
    'mother' : 'mother',
    'father' : 'father',
    'parent' : 'parent',
    'brother' : 'brother',
    'sister' : 'sister',
    'friend' : 'friend',
    'relative' : 'relative',
    'manager' : 'manager',
    'assistant' : 'assistant',
    'referred-by' : 'referred_by',
    'partner' : 'partner',
    'domestic-partner' : 'domestic_partner',
    }

  WEBSITE_TYPE_ARGUMENT_TO_REL = {
    'home-page': 'home-page',
    'blog': 'blog',
    'profile': 'profile',
    'work': 'work',
    'home': 'home',
    'other': 'other',
    'ftp': 'ftp',
    'reservations': 'reservations',
    'app-install-page': 'app-install-page',
    }

  WEBSITE_REL_TO_TYPE_ARGUMENT = {
    'home-page': 'home-page',
    'blog': 'blog',
    'profile': 'profile',
    'work': 'work',
    'home': 'home',
    'other': 'other',
    'ftp': 'ftp',
    'reservations': 'reservations',
    'app-install-page': 'app-install-page',
    }

  CONTACT_NAME_PROPERTY_PRINT_ORDER = [
    CONTACT_UPDATED,
    CONTACT_NAME,
    CONTACT_NAME_PREFIX,
    CONTACT_GIVEN_NAME,
    CONTACT_ADDITIONAL_NAME,
    CONTACT_FAMILY_NAME,
    CONTACT_NAME_SUFFIX,
    CONTACT_NICKNAME,
    CONTACT_MAIDENNAME,
    CONTACT_SHORTNAME,
    CONTACT_INITIALS,
    CONTACT_BIRTHDAY,
    CONTACT_GENDER,
    CONTACT_LOCATION,
    CONTACT_PRIORITY,
    CONTACT_SENSITIVITY,
    CONTACT_SUBJECT,
    CONTACT_LANGUAGE,
    CONTACT_NOTES,
    CONTACT_OCCUPATION,
    CONTACT_BILLING_INFORMATION,
    CONTACT_MILEAGE,
    CONTACT_DIRECTORY_SERVER,
    ]

  CONTACT_ARRAY_PROPERTY_PRINT_ORDER = [
    CONTACT_ADDRESSES,
    CONTACT_EMAILS,
    CONTACT_IMS,
    CONTACT_PHONES,
    CONTACT_CALENDARS,
    CONTACT_ORGANIZATIONS,
    CONTACT_EXTERNALIDS,
    CONTACT_EVENTS,
    CONTACT_HOBBIES,
    CONTACT_JOTS,
    CONTACT_RELATIONS,
    CONTACT_WEBSITES,
    CONTACT_USER_DEFINED_FIELDS,
    ]

  CONTACT_ARRAY_PROPERTIES = {
    CONTACT_ADDRESSES: {'relMap': ADDRESS_REL_TO_TYPE_ARGUMENT, 'infoTitle': 'formatted', 'primary': True},
    CONTACT_EMAILS: {'relMap': EMAIL_REL_TO_TYPE_ARGUMENT, 'infoTitle': 'address', 'primary': True},
    CONTACT_IMS: {'relMap': IM_REL_TO_TYPE_ARGUMENT, 'infoTitle': 'address', 'primary': True},
    CONTACT_PHONES: {'relMap': PHONE_REL_TO_TYPE_ARGUMENT, 'infoTitle': 'value', 'primary': True},
    CONTACT_CALENDARS: {'relMap': CALENDAR_REL_TO_TYPE_ARGUMENT, 'infoTitle': 'address', 'primary': True},
    CONTACT_ORGANIZATIONS: {'relMap': ORGANIZATION_REL_TO_TYPE_ARGUMENT, 'infoTitle': 'name', 'primary': True},
    CONTACT_EXTERNALIDS: {'relMap': EXTERNALID_REL_TO_TYPE_ARGUMENT, 'infoTitle': 'value', 'primary': False},
    CONTACT_EVENTS: {'relMap': EVENT_REL_TO_TYPE_ARGUMENT, 'infoTitle': 'date', 'primary': False},
    CONTACT_HOBBIES: {'relMap': None, 'infoTitle': 'value', 'primary': False},
    CONTACT_JOTS: {'relMap': JOT_REL_TO_TYPE_ARGUMENT, 'infoTitle': 'value', 'primary': False},
    CONTACT_RELATIONS: {'relMap': RELATION_REL_TO_TYPE_ARGUMENT, 'infoTitle': 'value', 'primary': False},
    CONTACT_USER_DEFINED_FIELDS: {'relMap': None, 'infoTitle': 'value', 'primary': False},
    CONTACT_WEBSITES: {'relMap': WEBSITE_REL_TO_TYPE_ARGUMENT, 'infoTitle': 'value', 'primary': True},
    }

  @staticmethod
  def GetContactShortId(contactEntry):
    full_id = contactEntry.id.text
    return full_id[full_id.rfind('/')+1:]

  @staticmethod
  def GetContactFields(parameters=None):

    fields = {}

    def CheckClearFieldsList(fieldName):
      if checkArgumentPresent(Cmd.CLEAR_NONE_ARGUMENT):
        fields.pop(fieldName, None)
        fields[fieldName] = []
        return True
      return False

    def InitArrayItem(choices):
      item = {}
      rel = getChoice(choices, mapChoice=True, defaultChoice=None)
      if rel:
        item['rel'] = rel
        item['label'] = None
      else:
        item['rel'] = None
        item['label'] = getString(Cmd.OB_STRING)
      return item

    def PrimaryNotPrimary(pnp, entry):
      if pnp == 'notprimary':
        entry['primary'] = 'false'
        return True
      if pnp == 'primary':
        entry['primary'] = 'true'
        primary['location'] = Cmd.Location()
        return True
      return False

    def GetPrimaryNotPrimaryChoice(entry):
      if not getChoice({'primary': True, 'notprimary': False}, mapChoice=True):
        entry['primary'] = 'false'
      else:
        entry['primary'] = 'true'
        primary['location'] = Cmd.Location()

    def AppendItemToFieldsList(fieldName, fieldValue, checkBlankField=None):
      fields.setdefault(fieldName, [])
      if checkBlankField is None or fieldValue[checkBlankField]:
        if isinstance(fieldValue, dict) and fieldValue.get('primary', 'false') == 'true':
          for citem in fields[fieldName]:
            if citem.get('primary', 'false') == 'true':
              Cmd.SetLocation(primary['location']-1)
              usageErrorExit(Msg.MULTIPLE_ITEMS_MARKED_PRIMARY.format(fieldName))
        fields[fieldName].append(fieldValue)

    primary = {}
    while Cmd.ArgumentsRemaining():
      if parameters is not None:
        if _getCreateContactReturnOptions(parameters):
          continue
        Cmd.Backup()
      fieldName = getChoice(ContactsManager.CONTACT_ARGUMENT_TO_PROPERTY_MAP, mapChoice=True)
      if fieldName == CONTACT_BIRTHDAY:
        fields[fieldName] = getYYYYMMDD(minLen=0)
      elif fieldName == CONTACT_GENDER:
        fields[fieldName] = getChoice(ContactsManager.GENDER_CHOICE_MAP, mapChoice=True)
      elif fieldName == CONTACT_PRIORITY:
        fields[fieldName] = getChoice(ContactsManager.PRIORITY_CHOICE_MAP, mapChoice=True)
      elif fieldName == CONTACT_SENSITIVITY:
        fields[fieldName] = getChoice(ContactsManager.SENSITIVITY_CHOICE_MAP, mapChoice=True)
      elif fieldName == CONTACT_LANGUAGE:
        fields[fieldName] = getLanguageCode(LANGUAGE_CODES_MAP)
      elif fieldName == CONTACT_NOTES:
        fields[fieldName] = getStringWithCRsNLsOrFile()[0]
      elif fieldName == CONTACT_ADDRESSES:
        if CheckClearFieldsList(fieldName):
          continue
        entry = InitArrayItem(ContactsManager.ADDRESS_TYPE_ARGUMENT_TO_REL)
        entry['primary'] = 'false'
        while Cmd.ArgumentsRemaining():
          argument = getArgument()
          if argument in ContactsManager.ADDRESS_ARGUMENT_TO_FIELD_MAP:
            value = getString(Cmd.OB_STRING, minLen=0)
            if value:
              entry[ContactsManager.ADDRESS_ARGUMENT_TO_FIELD_MAP[argument]] = value.replace('\\n', '\n')
          elif PrimaryNotPrimary(argument, entry):
            break
          else:
            unknownArgumentExit()
        AppendItemToFieldsList(fieldName, entry)
      elif fieldName == CONTACT_CALENDARS:
        if CheckClearFieldsList(fieldName):
          continue
        entry = InitArrayItem(ContactsManager.CALENDAR_TYPE_ARGUMENT_TO_REL)
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        GetPrimaryNotPrimaryChoice(entry)
        AppendItemToFieldsList(fieldName, entry, 'value')
      elif fieldName == CONTACT_EMAILS:
        if CheckClearFieldsList(fieldName):
          continue
        entry = InitArrayItem(ContactsManager.EMAIL_TYPE_ARGUMENT_TO_REL)
        entry['value'] = getEmailAddress(noUid=True, minLen=0)
        GetPrimaryNotPrimaryChoice(entry)
        AppendItemToFieldsList(fieldName, entry, 'value')
      elif fieldName == CONTACT_EVENTS:
        if CheckClearFieldsList(fieldName):
          continue
        entry = InitArrayItem(ContactsManager.EVENT_TYPE_ARGUMENT_TO_REL)
        entry['value'] = getYYYYMMDD(minLen=0)
        AppendItemToFieldsList(fieldName, entry, 'value')
      elif fieldName == CONTACT_EXTERNALIDS:
        if CheckClearFieldsList(fieldName):
          continue
        entry = InitArrayItem(ContactsManager.EXTERNALID_TYPE_ARGUMENT_TO_REL)
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        AppendItemToFieldsList(fieldName, entry, 'value')
      elif fieldName == CONTACT_HOBBIES:
        if CheckClearFieldsList(fieldName):
          continue
        entry = {'value': getString(Cmd.OB_STRING, minLen=0)}
        AppendItemToFieldsList(fieldName, entry, 'value')
      elif fieldName == CONTACT_IMS:
        if CheckClearFieldsList(fieldName):
          continue
        entry = InitArrayItem(ContactsManager.IM_TYPE_ARGUMENT_TO_REL)
        entry['protocol'] = getChoice(ContactsManager.IM_PROTOCOL_TO_REL_MAP, mapChoice=True)
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        GetPrimaryNotPrimaryChoice(entry)
        AppendItemToFieldsList(fieldName, entry, 'value')
      elif fieldName == CONTACT_JOTS:
        if CheckClearFieldsList(fieldName):
          continue
        entry = {'rel': getChoice(ContactsManager.JOT_TYPE_ARGUMENT_TO_REL, mapChoice=True)}
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        AppendItemToFieldsList(fieldName, entry, 'value')
      elif fieldName == CONTACT_ORGANIZATIONS:
        if CheckClearFieldsList(fieldName):
          continue
        entry = InitArrayItem(ContactsManager.ORGANIZATION_TYPE_ARGUMENT_TO_REL)
        entry['primary'] = 'false'
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        while Cmd.ArgumentsRemaining():
          argument = getArgument()
          if argument in ContactsManager.ORGANIZATION_ARGUMENT_TO_FIELD_MAP:
            value = getString(Cmd.OB_STRING, minLen=0)
            if value:
              entry[ContactsManager.ORGANIZATION_ARGUMENT_TO_FIELD_MAP[argument]] = value
          elif PrimaryNotPrimary(argument, entry):
            break
          else:
            unknownArgumentExit()
        AppendItemToFieldsList(fieldName, entry, 'value')
      elif fieldName == CONTACT_PHONES:
        if CheckClearFieldsList(fieldName):
          continue
        entry = InitArrayItem(ContactsManager.PHONE_TYPE_ARGUMENT_TO_REL)
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        GetPrimaryNotPrimaryChoice(entry)
        AppendItemToFieldsList(fieldName, entry, 'value')
      elif fieldName == CONTACT_RELATIONS:
        if CheckClearFieldsList(fieldName):
          continue
        entry = InitArrayItem(ContactsManager.RELATION_TYPE_ARGUMENT_TO_REL)
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        AppendItemToFieldsList(fieldName, entry, 'value')
      elif fieldName == CONTACT_USER_DEFINED_FIELDS:
        if CheckClearFieldsList(fieldName):
          continue
        entry = {'rel': getString(Cmd.OB_STRING, minLen=0), 'value': getString(Cmd.OB_STRING, minLen=0)}
        if not entry['rel'] or entry['rel'].lower() == 'none':
          entry['rel'] = None
        AppendItemToFieldsList(fieldName, entry, 'value')
      elif fieldName == CONTACT_WEBSITES:
        if CheckClearFieldsList(fieldName):
          continue
        entry = InitArrayItem(ContactsManager.WEBSITE_TYPE_ARGUMENT_TO_REL)
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        GetPrimaryNotPrimaryChoice(entry)
        AppendItemToFieldsList(fieldName, entry, 'value')
      else:
        fields[fieldName] = getString(Cmd.OB_STRING, minLen=0)
    return fields

  @staticmethod
  def FieldsToContact(fields):
    def GetField(fieldName):
      return fields.get(fieldName)

    def SetClassAttribute(value, fieldClass, processNLs, attr):
      if value:
        if processNLs:
          value = value.replace('\\n', '\n')
        if attr == 'text':
          return fieldClass(text=value)
        if attr == 'code':
          return fieldClass(code=value)
        if attr == 'rel':
          return fieldClass(rel=value)
        if attr == 'value':
          return fieldClass(value=value)
        if attr == 'value_string':
          return fieldClass(value_string=value)
        if attr == 'when':
          return fieldClass(when=value)
      return None

    def GetContactField(fieldName, fieldClass, processNLs=False, attr='text'):
      return SetClassAttribute(fields.get(fieldName), fieldClass, processNLs, attr)

    def GetListEntryField(entry, fieldName, fieldClass, processNLs=False, attr='text'):
      return SetClassAttribute(entry.get(fieldName), fieldClass, processNLs, attr)

    contactEntry = gdata.apps.contacts.ContactEntry()
    value = GetField(CONTACT_NAME)
    if not value:
      value = ' '.join([fields[fieldName] for fieldName in ContactsManager.CONTACT_NAME_FIELDS if fieldName in fields])
    contactEntry.name = gdata.apps.contacts.Name(full_name=gdata.apps.contacts.FullName(text=value))
    contactEntry.name.name_prefix = GetContactField(CONTACT_NAME_PREFIX, gdata.apps.contacts.NamePrefix)
    contactEntry.name.given_name = GetContactField(CONTACT_GIVEN_NAME, gdata.apps.contacts.GivenName)
    contactEntry.name.additional_name = GetContactField(CONTACT_ADDITIONAL_NAME, gdata.apps.contacts.AdditionalName)
    contactEntry.name.family_name = GetContactField(CONTACT_FAMILY_NAME, gdata.apps.contacts.FamilyName)
    contactEntry.name.name_suffix = GetContactField(CONTACT_NAME_SUFFIX, gdata.apps.contacts.NameSuffix)
    contactEntry.nickname = GetContactField(CONTACT_NICKNAME, gdata.apps.contacts.Nickname)
    contactEntry.maidenName = GetContactField(CONTACT_MAIDENNAME, gdata.apps.contacts.MaidenName)
    contactEntry.shortName = GetContactField(CONTACT_SHORTNAME, gdata.apps.contacts.ShortName)
    contactEntry.initials = GetContactField(CONTACT_INITIALS, gdata.apps.contacts.Initials)
    contactEntry.birthday = GetContactField(CONTACT_BIRTHDAY, gdata.apps.contacts.Birthday, attr='when')
    contactEntry.gender = GetContactField(CONTACT_GENDER, gdata.apps.contacts.Gender, attr='value')
    contactEntry.where = GetContactField(CONTACT_LOCATION, gdata.apps.contacts.Where, attr='value_string')
    contactEntry.priority = GetContactField(CONTACT_PRIORITY, gdata.apps.contacts.Priority, attr='rel')
    contactEntry.sensitivity = GetContactField(CONTACT_SENSITIVITY, gdata.apps.contacts.Sensitivity, attr='rel')
    contactEntry.subject = GetContactField(CONTACT_SUBJECT, gdata.apps.contacts.Subject)
    contactEntry.language = GetContactField(CONTACT_LANGUAGE, gdata.apps.contacts.Language, attr='code')
    contactEntry.content = GetContactField(CONTACT_NOTES, gdata.apps.contacts.Content, processNLs=True)
    contactEntry.occupation = GetContactField(CONTACT_OCCUPATION, gdata.apps.contacts.Occupation)
    contactEntry.billingInformation = GetContactField(CONTACT_BILLING_INFORMATION, gdata.apps.contacts.BillingInformation, processNLs=True)
    contactEntry.mileage = GetContactField(CONTACT_MILEAGE, gdata.apps.contacts.Mileage)
    contactEntry.directoryServer = GetContactField(CONTACT_DIRECTORY_SERVER, gdata.apps.contacts.DirectoryServer)
    value = GetField(CONTACT_ADDRESSES)
    if value:
      for address in value:
        street = GetListEntryField(address, 'street', gdata.apps.contacts.Street)
        pobox = GetListEntryField(address, 'pobox', gdata.apps.contacts.PoBox)
        neighborhood = GetListEntryField(address, 'neighborhood', gdata.apps.contacts.Neighborhood)
        city = GetListEntryField(address, 'city', gdata.apps.contacts.City)
        region = GetListEntryField(address, 'region', gdata.apps.contacts.Region)
        postcode = GetListEntryField(address, 'postcode', gdata.apps.contacts.Postcode)
        country = GetListEntryField(address, 'country', gdata.apps.contacts.Country)
        formatted_address = GetListEntryField(address, 'value', gdata.apps.contacts.FormattedAddress, processNLs=True)
        contactEntry.structuredPostalAddress.append(gdata.apps.contacts.StructuredPostalAddress(street=street, pobox=pobox, neighborhood=neighborhood,
                                                                                                city=city, region=region,
                                                                                                postcode=postcode, country=country,
                                                                                                formatted_address=formatted_address,
                                                                                                rel=address['rel'], label=address['label'], primary=address['primary']))
    value = GetField(CONTACT_CALENDARS)
    if value:
      for calendarLink in value:
        contactEntry.calendarLink.append(gdata.apps.contacts.CalendarLink(href=calendarLink['value'], rel=calendarLink['rel'], label=calendarLink['label'], primary=calendarLink['primary']))
    value = GetField(CONTACT_EMAILS)
    if value:
      for emailaddr in value:
        contactEntry.email.append(gdata.apps.contacts.Email(address=emailaddr['value'], rel=emailaddr['rel'], label=emailaddr['label'], primary=emailaddr['primary']))
    value = GetField(CONTACT_EXTERNALIDS)
    if value:
      for externalid in value:
        contactEntry.externalId.append(gdata.apps.contacts.ExternalId(value=externalid['value'], rel=externalid['rel'], label=externalid['label']))
    value = GetField(CONTACT_EVENTS)
    if value:
      for event in value:
        contactEntry.event.append(gdata.apps.contacts.Event(rel=event['rel'], label=event['label'],
                                                            when=gdata.apps.contacts.When(startTime=event['value'])))
    value = GetField(CONTACT_HOBBIES)
    if value:
      for hobby in value:
        contactEntry.hobby.append(gdata.apps.contacts.Hobby(text=hobby['value']))
    value = GetField(CONTACT_IMS)
    if value:
      for im in value:
        contactEntry.im.append(gdata.apps.contacts.IM(address=im['value'], protocol=im['protocol'], rel=im['rel'], label=im['label'], primary=im['primary']))
    value = GetField(CONTACT_JOTS)
    if value:
      for jot in value:
        contactEntry.jot.append(gdata.apps.contacts.Jot(text=jot['value'], rel=jot['rel']))
    value = GetField(CONTACT_ORGANIZATIONS)
    if value:
      for organization in value:
        org_name = gdata.apps.contacts.OrgName(text=organization['value'])
        department = GetListEntryField(organization, 'department', gdata.apps.contacts.OrgDepartment)
        title = GetListEntryField(organization, 'title', gdata.apps.contacts.OrgTitle)
        job_description = GetListEntryField(organization, 'jobdescription', gdata.apps.contacts.OrgJobDescription)
        symbol = GetListEntryField(organization, 'symbol', gdata.apps.contacts.OrgSymbol)
        where = GetListEntryField(organization, 'where', gdata.apps.contacts.Where, attr='value_string')
        contactEntry.organization.append(gdata.apps.contacts.Organization(name=org_name, department=department,
                                                                          title=title, job_description=job_description,
                                                                          symbol=symbol, where=where,
                                                                          rel=organization['rel'], label=organization['label'], primary=organization['primary']))
    value = GetField(CONTACT_PHONES)
    if value:
      for phone in value:
        contactEntry.phoneNumber.append(gdata.apps.contacts.PhoneNumber(text=phone['value'], rel=phone['rel'], label=phone['label'], primary=phone['primary']))
    value = GetField(CONTACT_RELATIONS)
    if value:
      for relation in value:
        contactEntry.relation.append(gdata.apps.contacts.Relation(text=relation['value'], rel=relation['rel'], label=relation['label']))
    value = GetField(CONTACT_USER_DEFINED_FIELDS)
    if value:
      for userdefinedfield in value:
        contactEntry.userDefinedField.append(gdata.apps.contacts.UserDefinedField(key=userdefinedfield['rel'], value=userdefinedfield['value']))
    value = GetField(CONTACT_WEBSITES)
    if value:
      for website in value:
        contactEntry.website.append(gdata.apps.contacts.Website(href=website['value'], rel=website['rel'], label=website['label'], primary=website['primary']))
    return contactEntry

  @staticmethod
  def ContactToFields(contactEntry):
    fields = {}
    def GetContactField(fieldName, attrlist):
      objAttr = contactEntry
      for attr in attrlist:
        objAttr = getattr(objAttr, attr)
        if not objAttr:
          return
      fields[fieldName] = objAttr

    def GetListEntryField(entry, attrlist):
      objAttr = entry
      for attr in attrlist:
        objAttr = getattr(objAttr, attr)
        if not objAttr:
          return None
      return objAttr

    def AppendItemToFieldsList(fieldName, fieldValue):
      fields.setdefault(fieldName, [])
      fields[fieldName].append(fieldValue)

    fields[CONTACT_ID] = ContactsManager.GetContactShortId(contactEntry)
    GetContactField(CONTACT_UPDATED, ['updated', 'text'])
    if not contactEntry.deleted:
      GetContactField(CONTACT_NAME, ['title', 'text'])
    else:
      fields[CONTACT_NAME] = 'Deleted'
    GetContactField(CONTACT_NAME_PREFIX, ['name', 'name_prefix', 'text'])
    GetContactField(CONTACT_GIVEN_NAME, ['name', 'given_name', 'text'])
    GetContactField(CONTACT_ADDITIONAL_NAME, ['name', 'additional_name', 'text'])
    GetContactField(CONTACT_FAMILY_NAME, ['name', 'family_name', 'text'])
    GetContactField(CONTACT_NAME_SUFFIX, ['name', 'name_suffix', 'text'])
    GetContactField(CONTACT_NICKNAME, ['nickname', 'text'])
    GetContactField(CONTACT_MAIDENNAME, ['maidenName', 'text'])
    GetContactField(CONTACT_SHORTNAME, ['shortName', 'text'])
    GetContactField(CONTACT_INITIALS, ['initials', 'text'])
    GetContactField(CONTACT_BIRTHDAY, ['birthday', 'when'])
    GetContactField(CONTACT_GENDER, ['gender', 'value'])
    GetContactField(CONTACT_SUBJECT, ['subject', 'text'])
    GetContactField(CONTACT_LANGUAGE, ['language', 'code'])
    GetContactField(CONTACT_PRIORITY, ['priority', 'rel'])
    GetContactField(CONTACT_SENSITIVITY, ['sensitivity', 'rel'])
    GetContactField(CONTACT_NOTES, ['content', 'text'])
    GetContactField(CONTACT_LOCATION, ['where', 'value_string'])
    GetContactField(CONTACT_OCCUPATION, ['occupation', 'text'])
    GetContactField(CONTACT_BILLING_INFORMATION, ['billingInformation', 'text'])
    GetContactField(CONTACT_MILEAGE, ['mileage', 'text'])
    GetContactField(CONTACT_DIRECTORY_SERVER, ['directoryServer', 'text'])
    for address in contactEntry.structuredPostalAddress:
      AppendItemToFieldsList(CONTACT_ADDRESSES,
                             {'rel': address.rel,
                              'label': address.label,
                              'value': GetListEntryField(address, ['formatted_address', 'text']),
                              'street': GetListEntryField(address, ['street', 'text']),
                              'pobox': GetListEntryField(address, ['pobox', 'text']),
                              'neighborhood': GetListEntryField(address, ['neighborhood', 'text']),
                              'city': GetListEntryField(address, ['city', 'text']),
                              'region': GetListEntryField(address, ['region', 'text']),
                              'postcode': GetListEntryField(address, ['postcode', 'text']),
                              'country': GetListEntryField(address, ['country', 'text']),
                              'primary': address.primary})
    for calendarLink in contactEntry.calendarLink:
      AppendItemToFieldsList(CONTACT_CALENDARS,
                             {'rel': calendarLink.rel,
                              'label': calendarLink.label,
                              'value': calendarLink.href,
                              'primary': calendarLink.primary})
    for emailaddr in contactEntry.email:
      AppendItemToFieldsList(CONTACT_EMAILS,
                             {'rel': emailaddr.rel,
                              'label': emailaddr.label,
                              'value': emailaddr.address,
                              'primary': emailaddr.primary})
    for externalid in contactEntry.externalId:
      AppendItemToFieldsList(CONTACT_EXTERNALIDS,
                             {'rel': externalid.rel,
                              'label': externalid.label,
                              'value': externalid.value})
    for event in contactEntry.event:
      AppendItemToFieldsList(CONTACT_EVENTS,
                             {'rel': event.rel,
                              'label': event.label,
                              'value': GetListEntryField(event, ['when', 'startTime'])})
    for hobby in contactEntry.hobby:
      AppendItemToFieldsList(CONTACT_HOBBIES,
                             {'value': hobby.text})
    for im in contactEntry.im:
      AppendItemToFieldsList(CONTACT_IMS,
                             {'rel': im.rel,
                              'label': im.label,
                              'value': im.address,
                              'protocol': im.protocol,
                              'primary': im.primary})
    for jot in contactEntry.jot:
      AppendItemToFieldsList(CONTACT_JOTS,
                             {'rel': jot.rel,
                              'value': jot.text})
    for organization in contactEntry.organization:
      AppendItemToFieldsList(CONTACT_ORGANIZATIONS,
                             {'rel': organization.rel,
                              'label': organization.label,
                              'value': GetListEntryField(organization, ['name', 'text']),
                              'department': GetListEntryField(organization, ['department', 'text']),
                              'title': GetListEntryField(organization, ['title', 'text']),
                              'symbol': GetListEntryField(organization, ['symbol', 'text']),
                              'jobdescription': GetListEntryField(organization, ['job_description', 'text']),
                              'where': GetListEntryField(organization, ['where', 'value_string']),
                              'primary': organization.primary})
    for phone in contactEntry.phoneNumber:
      AppendItemToFieldsList(CONTACT_PHONES,
                             {'rel': phone.rel,
                              'label': phone.label,
                              'value': phone.text,
                              'primary': phone.primary})
    for relation in contactEntry.relation:
      AppendItemToFieldsList(CONTACT_RELATIONS,
                             {'rel': relation.rel,
                              'label': relation.label,
                              'value': relation.text})
    for userdefinedfield in contactEntry.userDefinedField:
      AppendItemToFieldsList(CONTACT_USER_DEFINED_FIELDS,
                             {'rel': userdefinedfield.key,
                              'value': userdefinedfield.value})
    for website in contactEntry.website:
      AppendItemToFieldsList(CONTACT_WEBSITES,
                             {'rel': website.rel,
                              'label': website.label,
                              'value': website.href,
                              'primary': website.primary})
    return fields

CONTACTS_PROJECTION_CHOICE_MAP = {'basic': 'thin', 'thin': 'thin', 'full': 'full'}
CONTACTS_ORDERBY_CHOICE_MAP = {'lastmodified': 'lastmodified'}

def normalizeContactId(contactId):
  if contactId.startswith('id:'):
    return contactId[3:]
  return contactId

def _initContactQueryAttributes():
  return {'query': None, 'projection': 'full', 'url_params': {'max-results': str(GC.Values[GC.CONTACT_MAX_RESULTS])},
          'emailMatchPattern': None, 'emailMatchType': None}

def _getContactQueryAttributes(contactQuery, myarg, unknownAction, printShowCmd):
  if myarg == 'query':
    contactQuery['query'] = getString(Cmd.OB_QUERY)
  elif myarg == 'emailmatchpattern':
    contactQuery['emailMatchPattern'] = getREPattern(re.IGNORECASE)
  elif myarg == 'emailmatchtype':
    contactQuery['emailMatchType'] = getString(Cmd.OB_CONTACT_EMAIL_TYPE)
  elif myarg == 'updatedmin':
    contactQuery['url_params']['updated-min'] = getYYYYMMDD()
  elif myarg == 'endquery':
    return False
  elif not printShowCmd:
    if unknownAction < 0:
      unknownArgumentExit()
    if unknownAction > 0:
      Cmd.Backup()
    return False
  elif myarg == 'orderby':
    contactQuery['url_params']['orderby'], contactQuery['url_params']['sortorder'] = getOrderBySortOrder(CONTACTS_ORDERBY_CHOICE_MAP, 'ascending', False)
  elif myarg in CONTACTS_PROJECTION_CHOICE_MAP:
    contactQuery['projection'] = CONTACTS_PROJECTION_CHOICE_MAP[myarg]
  elif myarg == 'showdeleted':
    contactQuery['url_params']['showdeleted'] = 'true'
  else:
    if unknownAction < 0:
      unknownArgumentExit()
    if unknownAction > 0:
      Cmd.Backup()
    return False
  return True

CONTACT_SELECT_ARGUMENTS = {'query', 'emailmatchpattern', 'emailmatchtype', 'updatedmin'}

def _getContactEntityList(unknownAction, printShowCmd):
  contactQuery = _initContactQueryAttributes()
  if Cmd.PeekArgumentPresent(CONTACT_SELECT_ARGUMENTS):
    entityList = None
    queriedContacts = True
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if not _getContactQueryAttributes(contactQuery, myarg, unknownAction, printShowCmd):
        break
  else:
    entityList = getEntityList(Cmd.OB_CONTACT_ENTITY)
    queriedContacts = False
    if unknownAction < 0:
      checkForExtraneousArguments()
  return (entityList, contactQuery, queriedContacts)

def queryContacts(contactsObject, contactQuery):
  entityType = Ent.DOMAIN
  user = GC.Values[GC.DOMAIN]
  if contactQuery['query']:
    uri = getContactsQuery(feed=contactsObject.GetContactFeedUri(contact_list=user, projection=contactQuery['projection']),
                           text_query=contactQuery['query']).ToUri()
  else:
    uri = contactsObject.GetContactFeedUri(contact_list=user, projection=contactQuery['projection'])
  printGettingAllEntityItemsForWhom(Ent.CONTACT, user, query=contactQuery['query'])
  try:
    entityList = callGDataPages(contactsObject, 'GetContactsFeed',
                                pageMessage=getPageMessageForWhom(),
                                throwErrors=[GDATA.BAD_REQUEST, GDATA.FORBIDDEN],
                                retryErrors=[GDATA.INTERNAL_SERVER_ERROR],
                                uri=uri, url_params=contactQuery['url_params'])
    return entityList
  except GDATA.badRequest as e:
    entityActionFailedWarning([entityType, user, Ent.CONTACT, ''], str(e))
  except GDATA.forbidden:
    entityServiceNotApplicableWarning(entityType, user)
  return None

def localContactSelects(contactsManager, contactQuery, fields):
  if contactQuery['emailMatchPattern']:
    emailMatchType = contactQuery['emailMatchType']
    for item in fields.get(CONTACT_EMAILS, []):
      if contactQuery['emailMatchPattern'].match(item['value']):
        if (not emailMatchType or
            emailMatchType == item.get('label') or
            emailMatchType == contactsManager.CONTACT_ARRAY_PROPERTIES[CONTACT_EMAILS]['relMap'].get(item['rel'], 'custom')):
          break
    else:
      return False
  return True

def countLocalContactSelects(contactsManager, contacts, contactQuery):
  if contacts is not None and contactQuery:
    jcount = 0
    for contact in contacts:
      fields = contactsManager.ContactToFields(contact)
      if localContactSelects(contactsManager, contactQuery, fields):
        jcount += 1
  else:
    jcount = len(contacts) if contacts is not None else 0
  return jcount

def clearEmailAddressMatches(contactsManager, contactClear, fields):
  savedAddresses = []
  updateRequired = False
  emailMatchType = contactClear['emailClearType']
  for item in fields.get(CONTACT_EMAILS, []):
    if (contactClear['emailClearPattern'].match(item['value']) and
        (not emailMatchType or
         emailMatchType == item.get('label') or
         emailMatchType == contactsManager.CONTACT_ARRAY_PROPERTIES[CONTACT_EMAILS]['relMap'].get(item['rel'], 'custom'))):
      updateRequired = True
    else:
      savedAddresses.append(item)
  if updateRequired:
    fields[CONTACT_EMAILS] = savedAddresses
  return updateRequired

def dedupEmailAddressMatches(contactsManager, emailMatchType, fields):
  sai = -1
  savedAddresses = []
  matches = {}
  updateRequired = False
  for item in fields.get(CONTACT_EMAILS, []):
    emailAddr = item['value']
    emailType = item.get('label')
    if emailType is None:
      emailType = contactsManager.CONTACT_ARRAY_PROPERTIES[CONTACT_EMAILS]['relMap'].get(item['rel'], 'custom')
    if (emailAddr in matches) and (not emailMatchType or emailType in matches[emailAddr]['types']):
      if item['primary'] == 'true':
        savedAddresses[matches[emailAddr]['sai']]['primary'] = 'true'
      updateRequired = True
    else:
      savedAddresses.append(item)
      sai += 1
      matches.setdefault(emailAddr, {'types': set(), 'sai': sai})
      matches[emailAddr]['types'].add(emailType)
  if updateRequired:
    fields[CONTACT_EMAILS] = savedAddresses
  return updateRequired

# gam create contact <ContactAttribute>+
#	[(csv [todrive <ToDriveAttribute>*] (addcsvdata <FieldName> <String>)*))| returnidonly]
def doCreateDomainContact():
  entityType = Ent.DOMAIN
  contactsManager = ContactsManager()
  parameters = {'csvPF': None, 'titles': ['Domain', CONTACT_ID], 'addCSVData': {}, 'returnIdOnly': False}
  fields = contactsManager.GetContactFields(parameters)
  csvPF = parameters['csvPF']
  addCSVData = parameters['addCSVData']
  if addCSVData:
    csvPF.AddTitles(sorted(addCSVData.keys()))
  returnIdOnly = parameters['returnIdOnly']
  contactEntry = contactsManager.FieldsToContact(fields)
  user, contactsObject = getContactsObject()
  try:
    contact = callGData(contactsObject, 'CreateContact',
                        throwErrors=[GDATA.BAD_REQUEST, GDATA.SERVICE_NOT_APPLICABLE, GDATA.FORBIDDEN],
                        retryErrors=[GDATA.INTERNAL_SERVER_ERROR],
                        new_contact=contactEntry, insert_uri=contactsObject.GetContactFeedUri(contact_list=user))
    contactId = contactsManager.GetContactShortId(contact)
    if returnIdOnly:
      writeStdout(f'{contactId}\n')
    elif not csvPF:
      entityActionPerformed([entityType, user, Ent.CONTACT, contactId])
    else:
      row = {'Domain': user, CONTACT_ID: contactId}
      if addCSVData:
        row.update(addCSVData)
      csvPF.WriteRow(row)
  except GDATA.badRequest as e:
    entityActionFailedWarning([entityType, user, Ent.CONTACT, ''], str(e))
  except GDATA.forbidden:
    entityServiceNotApplicableWarning(entityType, user)
  except GDATA.serviceNotApplicable:
    entityUnknownWarning(entityType, user)
  if csvPF:
    csvPF.writeCSVfile('Contacts')

def _clearUpdateContacts(updateContacts):
  entityType = Ent.DOMAIN
  contactsManager = ContactsManager()
  entityList, contactQuery, queriedContacts = _getContactEntityList(1, False)
  if updateContacts:
    update_fields = contactsManager.GetContactFields()
  else:
    contactClear = {'emailClearPattern': contactQuery['emailMatchPattern'], 'emailClearType': contactQuery['emailMatchType']}
    deleteClearedContactsWithNoEmails = False
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg == 'emailclearpattern':
        contactClear['emailClearPattern'] = getREPattern(re.IGNORECASE)
      elif myarg == 'emailcleartype':
        contactClear['emailClearType'] = getString(Cmd.OB_CONTACT_EMAIL_TYPE)
      elif myarg == 'deleteclearedcontactswithnoemails':
        deleteClearedContactsWithNoEmails = True
      else:
        unknownArgumentExit()
    if not contactClear['emailClearPattern']:
      missingArgumentExit('emailclearpattern')
  user, contactsObject = getContactsObject()
  if queriedContacts:
    entityList = queryContacts(contactsObject, contactQuery)
    if entityList is None:
      return
  j = 0
  jcount = len(entityList)
  entityPerformActionModifierNumItems([entityType, user], Msg.MAXIMUM_OF, jcount, Ent.CONTACT)
  if jcount == 0:
    setSysExitRC(NO_ENTITIES_FOUND_RC)
    return
  Ind.Increment()
  for contact in entityList:
    j += 1
    try:
      if not queriedContacts:
        contactId = normalizeContactId(contact)
        contact = callGData(contactsObject, 'GetContact',
                            throwErrors=[GDATA.NOT_FOUND, GDATA.BAD_REQUEST, GDATA.SERVICE_NOT_APPLICABLE, GDATA.FORBIDDEN, GDATA.NOT_IMPLEMENTED],
                            retryErrors=[GDATA.INTERNAL_SERVER_ERROR],
                            uri=contactsObject.GetContactFeedUri(contact_list=user, contactId=contactId))
        fields = contactsManager.ContactToFields(contact)
      else:
        contactId = contactsManager.GetContactShortId(contact)
        fields = contactsManager.ContactToFields(contact)
        if not localContactSelects(contactsManager, contactQuery, fields):
          continue
      if updateContacts:
        for field, value in iter(update_fields.items()):
          fields[field] = value
        contactEntry = contactsManager.FieldsToContact(fields)
      else:
        if not clearEmailAddressMatches(contactsManager, contactClear, fields):
          continue
        if deleteClearedContactsWithNoEmails and not fields[CONTACT_EMAILS]:
          Act.Set(Act.DELETE)
          callGData(contactsObject, 'DeleteContact',
                    throwErrors=[GDATA.NOT_FOUND, GDATA.SERVICE_NOT_APPLICABLE, GDATA.FORBIDDEN],
                    edit_uri=contactsObject.GetContactFeedUri(contact_list=user, contactId=contactId), extra_headers={'If-Match': contact.etag})
          entityActionPerformed([entityType, user, Ent.CONTACT, contactId], j, jcount)
          continue
        contactEntry = contactsManager.FieldsToContact(fields)
      contactEntry.category = contact.category
      contactEntry.link = contact.link
      contactEntry.etag = contact.etag
      contactEntry.id = contact.id
      Act.Set(Act.UPDATE)
      callGData(contactsObject, 'UpdateContact',
                throwErrors=[GDATA.NOT_FOUND, GDATA.BAD_REQUEST, GDATA.PRECONDITION_FAILED, GDATA.SERVICE_NOT_APPLICABLE, GDATA.FORBIDDEN],
                edit_uri=contactsObject.GetContactFeedUri(contact_list=user, contactId=contactId), updated_contact=contactEntry, extra_headers={'If-Match': contact.etag})
      entityActionPerformed([entityType, user, Ent.CONTACT, contactId], j, jcount)
    except (GDATA.notFound, GDATA.badRequest, GDATA.preconditionFailed) as e:
      entityActionFailedWarning([entityType, user, Ent.CONTACT, contactId], str(e), j, jcount)
    except (GDATA.forbidden, GDATA.notImplemented):
      entityServiceNotApplicableWarning(entityType, user)
      break
    except GDATA.serviceNotApplicable:
      entityUnknownWarning(entityType, user)
      break
  Ind.Decrement()

# gam clear contacts <ContactEntity>|<ContactSelection>
#	[clearmatchpattern <REMatchPattern>] [clearmatchtype work|home|other|<String>]
#	[deleteclearedcontactswithnoemails]
def doClearDomainContacts():
  _clearUpdateContacts(False)

# gam update contacts <ContactEntity>|<ContactSelection> <ContactAttribute>+
def doUpdateDomainContacts():
  _clearUpdateContacts(True)

# gam dedup contacts <ContactEntity>|<ContactSelection> [matchType [<Boolean>]]
def doDedupDomainContacts():
  entityType = Ent.DOMAIN
  contactsManager = ContactsManager()
  contactQuery = _initContactQueryAttributes()
  emailMatchType = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'matchtype':
      emailMatchType = getBoolean()
    else:
      _getContactQueryAttributes(contactQuery, myarg, -1, False)
  user, contactsObject = getContactsObject()
  contacts = queryContacts(contactsObject, contactQuery)
  if contacts is None:
    return
  j = 0
  jcount = len(contacts)
  entityPerformActionModifierNumItems([entityType, user], Msg.MAXIMUM_OF, jcount, Ent.CONTACT)
  if jcount == 0:
    setSysExitRC(NO_ENTITIES_FOUND_RC)
    return
  Ind.Increment()
  for contact in contacts:
    j += 1
    try:
      fields = contactsManager.ContactToFields(contact)
      if not localContactSelects(contactsManager, contactQuery, fields):
        continue
      if not dedupEmailAddressMatches(contactsManager, emailMatchType, fields):
        continue
      contactId = fields[CONTACT_ID]
      contactEntry = contactsManager.FieldsToContact(fields)
      contactEntry.category = contact.category
      contactEntry.link = contact.link
      contactEntry.etag = contact.etag
      contactEntry.id = contact.id
      Act.Set(Act.UPDATE)
      callGData(contactsObject, 'UpdateContact',
                throwErrors=[GDATA.NOT_FOUND, GDATA.BAD_REQUEST, GDATA.PRECONDITION_FAILED, GDATA.SERVICE_NOT_APPLICABLE, GDATA.FORBIDDEN],
                edit_uri=contactsObject.GetContactFeedUri(contact_list=user, contactId=contactId), updated_contact=contactEntry, extra_headers={'If-Match': contact.etag})
      entityActionPerformed([entityType, user, Ent.CONTACT, contactId], j, jcount)
    except (GDATA.notFound, GDATA.badRequest, GDATA.preconditionFailed) as e:
      entityActionFailedWarning([entityType, user, Ent.CONTACT, contactId], str(e), j, jcount)
    except (GDATA.forbidden, GDATA.notImplemented):
      entityServiceNotApplicableWarning(entityType, user)
      break
    except GDATA.serviceNotApplicable:
      entityUnknownWarning(entityType, user)
      break
  Ind.Decrement()

# gam delete contacts <ContactEntity>|<ContactSelection>
def doDeleteDomainContacts():
  entityType = Ent.DOMAIN
  contactsManager = ContactsManager()
  entityList, contactQuery, queriedContacts = _getContactEntityList(-1, False)
  user, contactsObject = getContactsObject()
  if queriedContacts:
    entityList = queryContacts(contactsObject, contactQuery)
    if entityList is None:
      return
  j = 0
  jcount = len(entityList)
  entityPerformActionModifierNumItems([entityType, user], Msg.MAXIMUM_OF, jcount, Ent.CONTACT)
  if jcount == 0:
    setSysExitRC(NO_ENTITIES_FOUND_RC)
    return
  Ind.Increment()
  for contact in entityList:
    j += 1
    try:
      if not queriedContacts:
        contactId = normalizeContactId(contact)
        contact = callGData(contactsObject, 'GetContact',
                            throwErrors=[GDATA.NOT_FOUND, GDATA.BAD_REQUEST, GDATA.SERVICE_NOT_APPLICABLE, GDATA.FORBIDDEN, GDATA.NOT_IMPLEMENTED],
                            retryErrors=[GDATA.INTERNAL_SERVER_ERROR],
                            uri=contactsObject.GetContactFeedUri(contact_list=user, contactId=contactId))
      else:
        contactId = contactsManager.GetContactShortId(contact)
        fields = contactsManager.ContactToFields(contact)
        if not localContactSelects(contactsManager, contactQuery, fields):
          continue
      callGData(contactsObject, 'DeleteContact',
                throwErrors=[GDATA.NOT_FOUND, GDATA.SERVICE_NOT_APPLICABLE, GDATA.FORBIDDEN],
                edit_uri=contactsObject.GetContactFeedUri(contact_list=user, contactId=contactId), extra_headers={'If-Match': contact.etag})
      entityActionPerformed([entityType, user, Ent.CONTACT, contactId], j, jcount)
    except (GDATA.notFound, GDATA.badRequest) as e:
      entityActionFailedWarning([entityType, user, Ent.CONTACT, contactId], str(e), j, jcount)
    except (GDATA.forbidden, GDATA.notImplemented):
      entityServiceNotApplicableWarning(entityType, user)
      break
    except GDATA.serviceNotApplicable:
      entityUnknownWarning(entityType, user)
      break
  Ind.Decrement()

CONTACT_TIME_OBJECTS = {CONTACT_UPDATED}
CONTACT_FIELDS_WITH_CRS_NLS = {CONTACT_NOTES, CONTACT_BILLING_INFORMATION}

def _showContact(contactsManager, fields, displayFieldsList, j, jcount, FJQC):
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(fields, timeObjects=CONTACT_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.CONTACT, fields[CONTACT_ID]], j, jcount)
  Ind.Increment()
  for key in contactsManager.CONTACT_NAME_PROPERTY_PRINT_ORDER:
    if displayFieldsList and key not in displayFieldsList:
      continue
    if key in fields:
      if key in CONTACT_TIME_OBJECTS:
        printKeyValueList([key, formatLocalTime(fields[key])])
      elif key not in CONTACT_FIELDS_WITH_CRS_NLS:
        printKeyValueList([key, fields[key]])
      else:
        printKeyValueWithCRsNLs(key, fields[key])
  for key in contactsManager.CONTACT_ARRAY_PROPERTY_PRINT_ORDER:
    if displayFieldsList and key not in displayFieldsList:
      continue
    if key in fields:
      keymap = contactsManager.CONTACT_ARRAY_PROPERTIES[key]
      printKeyValueList([key, None])
      Ind.Increment()
      for item in fields[key]:
        fn = item.get('label')
        if keymap['relMap']:
          if not fn:
            fn = keymap['relMap'].get(item['rel'], 'custom')
          printKeyValueList(['type', fn])
          Ind.Increment()
        if keymap['primary']:
          printKeyValueList(['rank', ['notprimary', 'primary'][item['primary'] == 'true']])
        value = item['value']
        if value is None:
          value = ''
        if key == CONTACT_IMS:
          printKeyValueList(['protocol', contactsManager.IM_REL_TO_PROTOCOL_MAP.get(item['protocol'], item['protocol'])])
          printKeyValueList([keymap['infoTitle'], value])
        elif key == CONTACT_ADDRESSES:
          printKeyValueWithCRsNLs(keymap['infoTitle'], value)
          for org_key in contactsManager.ADDRESS_FIELD_PRINT_ORDER:
            if item[org_key]:
              printKeyValueList([contactsManager.ADDRESS_FIELD_TO_ARGUMENT_MAP[org_key], item[org_key]])
        elif key == CONTACT_ORGANIZATIONS:
          printKeyValueList([keymap['infoTitle'], value])
          for org_key in contactsManager.ORGANIZATION_FIELD_PRINT_ORDER:
            if item[org_key]:
              printKeyValueList([contactsManager.ORGANIZATION_FIELD_TO_ARGUMENT_MAP[org_key], item[org_key]])
        elif key == CONTACT_USER_DEFINED_FIELDS:
          printKeyValueList([item.get('rel') or 'None', value])
        else:
          printKeyValueList([keymap['infoTitle'], value])
        if keymap['relMap']:
          Ind.Decrement()
      Ind.Decrement()
  Ind.Decrement()

def _getContactFieldsList(contactsManager, displayFieldsList):
  for field in _getFieldsList():
    if field in contactsManager.CONTACT_ARGUMENT_TO_PROPERTY_MAP:
      displayFieldsList.append(contactsManager.CONTACT_ARGUMENT_TO_PROPERTY_MAP[field])
    else:
      invalidChoiceExit(field, contactsManager.CONTACT_ARGUMENT_TO_PROPERTY_MAP, True)

# gam info contacts <ContactEntity>
#	[basic|full]
#	[fields <ContactFieldNameList>] [formatjson]
def doInfoDomainContacts():
  entityType = Ent.DOMAIN
  contactsManager = ContactsManager()
  entityList = getEntityList(Cmd.OB_CONTACT_ENTITY)
  contactQuery = _initContactQueryAttributes()
  FJQC = FormatJSONQuoteChar()
  displayFieldsList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in CONTACTS_PROJECTION_CHOICE_MAP:
      contactQuery['projection'] = CONTACTS_PROJECTION_CHOICE_MAP[myarg]
    elif myarg == 'fields':
      _getContactFieldsList(contactsManager, displayFieldsList)
    else:
      FJQC.GetFormatJSON(myarg)
  user, contactsObject = getContactsObject()
  j = 0
  jcount = len(entityList)
  if not FJQC.formatJSON:
    entityPerformActionNumItems([entityType, user], jcount, Ent.CONTACT)
  if jcount == 0:
    setSysExitRC(NO_ENTITIES_FOUND_RC)
    return
  Ind.Increment()
  for contact in entityList:
    j += 1
    try:
      contactId = normalizeContactId(contact)
      contact = callGData(contactsObject, 'GetContact',
                          bailOnInternalServerError=True,
                          throwErrors=[GDATA.NOT_FOUND, GDATA.BAD_REQUEST, GDATA.SERVICE_NOT_APPLICABLE,
                                       GDATA.FORBIDDEN, GDATA.NOT_IMPLEMENTED, GDATA.INTERNAL_SERVER_ERROR],
                          retryErrors=[GDATA.INTERNAL_SERVER_ERROR],
                          uri=contactsObject.GetContactFeedUri(contact_list=user, contactId=contactId, projection=contactQuery['projection']))
      fields = contactsManager.ContactToFields(contact)
      _showContact(contactsManager, fields, displayFieldsList, j, jcount, FJQC)
    except (GDATA.notFound, GDATA.badRequest, GDATA.forbidden, GDATA.notImplemented, GDATA.internalServerError) as e:
      entityActionFailedWarning([entityType, user, Ent.CONTACT, contactId], str(e), j, jcount)
    except GDATA.serviceNotApplicable:
      entityUnknownWarning(entityType, user)
      break
  Ind.Decrement()

# gam print contacts [todrive <ToDriveAttribute>*] <ContactSelection>
#	[basic|full|countsonly] [showdeleted] [orderby <ContactOrderByFieldName> [ascending|descending]]
#	[fields <ContactFieldNameList>] [formatjson [quotechar <Character>]]
# gam show contacts <ContactSelection>
#	[basic|full|countsonly] [showdeleted] [orderby <ContactOrderByFieldName> [ascending|descending]]
#	[fields <ContactFieldNameList>] [formatjson]
def doPrintShowDomainContacts():
  entityType = Ent.DOMAIN
  entityTypeName = Ent.Singular(entityType)
  contactsManager = ContactsManager()
  csvPF = CSVPrintFile([entityTypeName, CONTACT_ID, CONTACT_NAME], 'sortall',
                       contactsManager.CONTACT_ARRAY_PROPERTY_PRINT_ORDER) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  CSVTitle = 'Contacts'
  contactQuery = _initContactQueryAttributes()
  countsOnly = False
  displayFieldsList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'fields':
      _getContactFieldsList(contactsManager, displayFieldsList)
    elif myarg == 'countsonly':
      countsOnly = True
      contactQuery['projection'] = CONTACTS_PROJECTION_CHOICE_MAP['basic']
      if csvPF:
        csvPF.SetTitles([entityTypeName, CSVTitle])
    elif _getContactQueryAttributes(contactQuery, myarg, 0, True):
      pass
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  user, contactsObject = getContactsObject()
  contacts = queryContacts(contactsObject, contactQuery)
  if countsOnly:
    jcount = countLocalContactSelects(contactsManager, contacts, contactQuery)
    if csvPF:
      csvPF.WriteRowTitles({entityTypeName: user, CSVTitle: jcount})
    else:
      printEntityKVList([entityType, user], [CSVTitle, jcount])
  elif contacts is not None:
    jcount = len(contacts)
    if not csvPF:
      if not FJQC.formatJSON:
        entityPerformActionModifierNumItems([entityType, user], Msg.MAXIMUM_OF, jcount, Ent.CONTACT)
      Ind.Increment()
      j = 0
      for contact in contacts:
        j += 1
        fields = contactsManager.ContactToFields(contact)
        if not localContactSelects(contactsManager, contactQuery, fields):
          continue
        _showContact(contactsManager, fields, displayFieldsList, j, jcount, FJQC)
      Ind.Decrement()
    elif contacts:
      for contact in contacts:
        fields = contactsManager.ContactToFields(contact)
        if not localContactSelects(contactsManager, contactQuery, fields):
          continue
        contactRow = {entityTypeName: user, CONTACT_ID: fields[CONTACT_ID]}
        for key in contactsManager.CONTACT_NAME_PROPERTY_PRINT_ORDER:
          if displayFieldsList and key not in displayFieldsList:
            continue
          if key in fields:
            if key == CONTACT_UPDATED:
              contactRow[key] = formatLocalTime(fields[key])
            elif key not in (CONTACT_NOTES, CONTACT_BILLING_INFORMATION):
              contactRow[key] = fields[key]
            else:
              contactRow[key] = escapeCRsNLs(fields[key])
        for key in contactsManager.CONTACT_ARRAY_PROPERTY_PRINT_ORDER:
          if displayFieldsList and key not in displayFieldsList:
            continue
          if key in fields:
            keymap = contactsManager.CONTACT_ARRAY_PROPERTIES[key]
            j = 0
            contactRow[f'{key}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{j}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}count'] = len(fields[key])
            for item in fields[key]:
              j += 1
              fn = f'{key}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{j}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}'
              fnt = item.get('label')
              if fnt:
                contactRow[fn+'type'] = fnt
              elif keymap['relMap']:
                contactRow[fn+'type'] = keymap['relMap'].get(item['rel'], 'custom')
              if keymap['primary']:
                contactRow[fn+'rank'] = 'primary' if item['primary'] == 'true' else 'notprimary'
              value = item['value']
              if value is None:
                value = ''
              if key == CONTACT_IMS:
                contactRow[fn+'protocol'] = contactsManager.IM_REL_TO_PROTOCOL_MAP.get(item['protocol'], item['protocol'])
                contactRow[fn+keymap['infoTitle']] = value
              elif key == CONTACT_ADDRESSES:
                contactRow[fn+keymap['infoTitle']] = escapeCRsNLs(value)
                for org_key in contactsManager.ADDRESS_FIELD_PRINT_ORDER:
                  if item[org_key]:
                    contactRow[fn+contactsManager.ADDRESS_FIELD_TO_ARGUMENT_MAP[org_key]] = escapeCRsNLs(item[org_key])
              elif key == CONTACT_ORGANIZATIONS:
                contactRow[fn+keymap['infoTitle']] = value
                for org_key in contactsManager.ORGANIZATION_FIELD_PRINT_ORDER:
                  if item[org_key]:
                    contactRow[fn+contactsManager.ORGANIZATION_FIELD_TO_ARGUMENT_MAP[org_key]] = item[org_key]
              elif key == CONTACT_USER_DEFINED_FIELDS:
                contactRow[fn+'type'] = item.get('rel') or 'None'
                contactRow[fn+keymap['infoTitle']] = value
              else:
                contactRow[fn+keymap['infoTitle']] = value
        if not FJQC.formatJSON:
          csvPF.WriteRowTitles(contactRow)
        elif csvPF.CheckRowTitles(contactRow):
          csvPF.WriteRowNoFilter({entityTypeName: user, CONTACT_ID: fields[CONTACT_ID],
                                  CONTACT_NAME: fields.get(CONTACT_NAME, ''),
                                  'JSON': json.dumps(cleanJSON(fields, timeObjects=CONTACT_TIME_OBJECTS),
                                                     ensure_ascii=False, sort_keys=True)})
  if csvPF:
    csvPF.writeCSVfile(CSVTitle)

# Prople commands utilities
#
def normalizePeopleResourceName(resourceName):
  if resourceName.startswith('people/'):
    return resourceName
  return f'people/{resourceName}'

def normalizeContactGroupResourceName(resourceName):
  if resourceName.startswith('contactGroups/'):
    return resourceName
  return f'contactGroups/{resourceName}'

def normalizeOtherContactsResourceName(resourceName):
  if resourceName.startswith('otherContacts/'):
    return resourceName
  return f'otherContacts/{resourceName}'

PEOPLE_JSON = 'JSON'

PEOPLE_ADDRESSES = 'addresses'
PEOPLE_BIOGRAPHIES = 'biographies'
PEOPLE_BIRTHDAYS = 'birthdays'
PEOPLE_CALENDAR_URLS = 'calendarUrls'
PEOPLE_CLIENT_DATA = 'clientData'
PEOPLE_COVER_PHOTOS = 'coverPhotos'
PEOPLE_EMAIL_ADDRESSES = 'emailAddresses'
PEOPLE_EVENTS = 'events'
PEOPLE_EXTERNAL_IDS = 'externalIds'
PEOPLE_FILE_ASES = 'fileAses'
PEOPLE_GENDERS = 'genders'
PEOPLE_IM_CLIENTS = 'imClients'
PEOPLE_INTERESTS = 'interests'
PEOPLE_LOCALES = 'locales'
PEOPLE_LOCATIONS = 'locations'
PEOPLE_MEMBERSHIPS = 'memberships'
PEOPLE_METADATA = 'metadata'
PEOPLE_MISC_KEYWORDS = 'miscKeywords'
PEOPLE_MISC_KEYWORDS_BILLING_INFORMATION = PEOPLE_MISC_KEYWORDS+'.OUTLOOK_BILLING_INFORMATION'
PEOPLE_MISC_KEYWORDS_DIRECTORY_SERVER = PEOPLE_MISC_KEYWORDS+'.OUTLOOK_DIRECTORY_SERVER'
PEOPLE_MISC_KEYWORDS_JOT = PEOPLE_MISC_KEYWORDS+'.jot'
PEOPLE_MISC_KEYWORDS_MILEAGE = PEOPLE_MISC_KEYWORDS+'.OUTLOOK_MILEAGE'
PEOPLE_MISC_KEYWORDS_PRIORITY = PEOPLE_MISC_KEYWORDS+'.OUTLOOK_PRIORITY'
PEOPLE_MISC_KEYWORDS_SENSITIVITY = PEOPLE_MISC_KEYWORDS+'.OUTLOOK_SENSITIVITY'
PEOPLE_MISC_KEYWORDS_SUBJECT = PEOPLE_MISC_KEYWORDS+'.OUTLOOK_SUBJECT'
PEOPLE_NAMES = 'names'
PEOPLE_NAMES_FAMILY_NAME = PEOPLE_NAMES+'.familyName'
PEOPLE_NAMES_GIVEN_NAME = PEOPLE_NAMES+'.givenName'
PEOPLE_NAMES_HONORIFIC_PREFIX = PEOPLE_NAMES+'.honorificPrefix'
PEOPLE_NAMES_HONORIFIC_SUFFIX = PEOPLE_NAMES+'.honorificSuffix'
PEOPLE_NAMES_MIDDLE_NAME = PEOPLE_NAMES+'.middleName'
PEOPLE_NAMES_PHONETIC_FAMILY_NAME = PEOPLE_NAMES+'.phoneticFamilyName'
PEOPLE_NAMES_PHONETIC_GIVEN_NAME = PEOPLE_NAMES+'.phoneticGivenName'
PEOPLE_NAMES_PHONETIC_HONORIFIC_PREFIX = PEOPLE_NAMES+'.phoneticHonorificPrefix'
PEOPLE_NAMES_PHONETIC_HONORIFIC_SUFFIX = PEOPLE_NAMES+'.phoneticHonorificSuffix'
PEOPLE_NAMES_PHONETIC_MIDDLE_NAME = PEOPLE_NAMES+'.phoneticMiddleName'
PEOPLE_NAMES_UNSTRUCTURED_NAME = PEOPLE_NAMES+'.unstructuredName'
PEOPLE_NICKNAMES = 'nicknames'
PEOPLE_NICKNAMES_INITIALS = PEOPLE_NICKNAMES+'.INITIALS'
PEOPLE_NICKNAMES_MAIDENNAME = PEOPLE_NICKNAMES+'.MAIDEN_NAME'
PEOPLE_NICKNAMES_NICKNAME = PEOPLE_NICKNAMES+'.DEFAULT'
PEOPLE_NICKNAMES_SHORTNAME = PEOPLE_NICKNAMES+'.SHORT_NAME'
PEOPLE_OCCUPATIONS = 'occupations'
PEOPLE_ORGANIZATIONS = 'organizations'
PEOPLE_PHONE_NUMBERS = 'phoneNumbers'
PEOPLE_PHOTOS = 'photos'
PEOPLE_RELATIONS = 'relations'
PEOPLE_SIP_ADDRESSES = 'sipAddresses'
PEOPLE_SKILLS = 'skills'
PEOPLE_UPDATE_TIME = 'updateTime'
PEOPLE_URLS = 'urls'
PEOPLE_USER_DEFINED = 'userDefined'

PEOPLE_GROUPS = 'ContactGroups'
PEOPLE_GROUPS_LIST = 'ContactGroupsList'
PEOPLE_ADD_GROUPS = 'ContactAddGroups'
PEOPLE_ADD_GROUPS_LIST = 'ContactAddGroupsList'
PEOPLE_REMOVE_GROUPS = 'ContactRemoveGroups'
PEOPLE_REMOVE_GROUPS_LIST = 'ContactRemoveGroupsList'

PEOPLE_GROUP_NAME = 'name'
PEOPLE_GROUP_CLIENT_DATA = 'clientData'
#
class PeopleManager():
  PEOPLE_ARGUMENT_TO_PROPERTY_MAP = {
    'json': PEOPLE_JSON,
    'additionalname': PEOPLE_NAMES_MIDDLE_NAME,
    'address': PEOPLE_ADDRESSES,
    'addresses': PEOPLE_ADDRESSES,
    'billinginfo': PEOPLE_MISC_KEYWORDS_BILLING_INFORMATION,
    'biography': PEOPLE_BIOGRAPHIES,
    'biographies': PEOPLE_BIOGRAPHIES,
    'birthday': PEOPLE_BIRTHDAYS,
    'birthdays': PEOPLE_BIRTHDAYS,
    'calendar': PEOPLE_CALENDAR_URLS,
    'calendars': PEOPLE_CALENDAR_URLS,
    'clientdata': PEOPLE_CLIENT_DATA,
#    'coverphoto': PEOPLE_COVER_PHOTOS,
#    'coverphotos': PEOPLE_COVER_PHOTOS,
    'directoryserver': PEOPLE_MISC_KEYWORDS_DIRECTORY_SERVER,
    'email': PEOPLE_EMAIL_ADDRESSES,
    'emails': PEOPLE_EMAIL_ADDRESSES,
    'emailadresses': PEOPLE_EMAIL_ADDRESSES,
    'event': PEOPLE_EVENTS,
    'events': PEOPLE_EVENTS,
    'externalid': PEOPLE_EXTERNAL_IDS,
    'externalids': PEOPLE_EXTERNAL_IDS,
    'familyname': PEOPLE_NAMES_FAMILY_NAME,
    'fileas': PEOPLE_FILE_ASES,
    'firstname': PEOPLE_NAMES_GIVEN_NAME,
    'gender': PEOPLE_GENDERS,
    'genders': PEOPLE_GENDERS,
    'givenname': PEOPLE_NAMES_GIVEN_NAME,
    'hobby': PEOPLE_INTERESTS,
    'hobbies': PEOPLE_INTERESTS,
    'im': PEOPLE_IM_CLIENTS,
    'ims': PEOPLE_IM_CLIENTS,
    'imclients': 'imClients',
    'initials': PEOPLE_NICKNAMES_INITIALS,
    'interests': PEOPLE_INTERESTS,
    'jot': PEOPLE_MISC_KEYWORDS_JOT,
    'jots': PEOPLE_MISC_KEYWORDS_JOT,
    'language': PEOPLE_LOCALES,
    'lastname': PEOPLE_NAMES_FAMILY_NAME,
    'locale': PEOPLE_LOCALES,
    'location': PEOPLE_LOCATIONS,
    'locations': PEOPLE_LOCATIONS,
    'maidenname': PEOPLE_NICKNAMES_MAIDENNAME,
    'middlename': PEOPLE_NAMES_MIDDLE_NAME,
    'mileage': PEOPLE_MISC_KEYWORDS_MILEAGE,
    'misckeywords': PEOPLE_MISC_KEYWORDS,
    'name': PEOPLE_NAMES_UNSTRUCTURED_NAME,
    'names': PEOPLE_NAMES_UNSTRUCTURED_NAME,
    'nickname': PEOPLE_NICKNAMES_NICKNAME,
    'nicknames': PEOPLE_NICKNAMES_NICKNAME,
    'note': PEOPLE_BIOGRAPHIES,
    'notes': PEOPLE_BIOGRAPHIES,
    'occupation': PEOPLE_OCCUPATIONS,
    'occupations': PEOPLE_OCCUPATIONS,
    'organization': PEOPLE_ORGANIZATIONS,
    'organizations': PEOPLE_ORGANIZATIONS,
    'organisation': PEOPLE_ORGANIZATIONS,
    'organisations': PEOPLE_ORGANIZATIONS,
    'phone': PEOPLE_PHONE_NUMBERS,
    'phones': PEOPLE_PHONE_NUMBERS,
    'phonenumbers': PEOPLE_PHONE_NUMBERS,
#    'photo': PEOPLE_PHOTOS,
#    'photos': PEOPLE_PHOTOS,
    'prefix': PEOPLE_NAMES_HONORIFIC_PREFIX,
    'priority': PEOPLE_MISC_KEYWORDS_PRIORITY,
    'relation': PEOPLE_RELATIONS,
    'relations': PEOPLE_RELATIONS,
    'sensitivity': PEOPLE_MISC_KEYWORDS_SENSITIVITY,
    'shortname': PEOPLE_NICKNAMES_SHORTNAME,
    'sipaddress': PEOPLE_SIP_ADDRESSES,
    'sipaddresses': PEOPLE_SIP_ADDRESSES,
    'skills': PEOPLE_SKILLS,
    'subject': PEOPLE_MISC_KEYWORDS_SUBJECT,
    'suffix': PEOPLE_NAMES_HONORIFIC_SUFFIX,
    'url': PEOPLE_URLS,
    'urls': PEOPLE_URLS,
    'userdefined': PEOPLE_USER_DEFINED,
    'userdefinedfield': PEOPLE_USER_DEFINED,
    'userdefinedfields': PEOPLE_USER_DEFINED,
    'website': PEOPLE_URLS,
    'websites': PEOPLE_URLS,
    'contactgroup': PEOPLE_GROUPS,
    'contactgroups': PEOPLE_GROUPS,
    'addcontactgroup': PEOPLE_ADD_GROUPS,
    'addcontactgroups': PEOPLE_ADD_GROUPS,
    'removecontactgroup': PEOPLE_REMOVE_GROUPS,
    'removecontactgroups': PEOPLE_REMOVE_GROUPS,
    }

  ADDRESS_ARGUMENT_TO_FIELD_MAP = {
    'formatted': 'formattedValue',
    'unstructured': 'formattedValue',
    'pobox': 'poBox',
    'street': 'streetAddress',
    'streetaddress': 'streetAddress',
    'extended': 'extendedAddress',
    'neighborhood': 'extendedAddress',
    'city': 'city',
    'locality': 'city',
    'region': 'region',
    'postalcode': 'postalCode',
    'country': 'country',
    'countrycode': 'countryCode',
    }

  JOT_TYPE_MAP = {
    'work': 'WORK',
    'home': 'HOME',
    'other': 'OTHER',
    'keyword': 'KEYWORD',
    'keywords': 'KEYWORD',
    'user': 'USER',
    }

  IM_PROTOCOLS = {
    'aim': 'aim',
    'googletalk': 'googleTalk',
    'gtalk': 'googleTalk',
    'icq': 'icq',
    'jabber': 'jabber',
    'msn': 'msn',
    'netmeeting': 'netMeeting',
    'qq': 'qq',
    'skype': 'skype',
    'xmpp': 'jabber',
    'yahoo': 'yahoo',
    }

  ORGANIZATION_ARGUMENT_TO_FIELD_MAP = {
    'startdate': 'startDate',
    'enddate': 'endDate',
    'current': 'current',
    'phoneticname': 'phoneticName',
    'title': 'title',
    'department': 'department',
    'jobdescription': 'jobDescription',
    'symbol': 'symbol',
    'domain': 'domain',
    'location': 'location',
    }

# Fields with a key and value
  KEY_VALUE_FIELDS = {
    PEOPLE_CLIENT_DATA,
    PEOPLE_USER_DEFINED,
    }

# Fields with a type and value
  TYPE_VALUE_FIELDS = {
    PEOPLE_EVENTS: {
      'anniversary': 'anniversary',
      'other': 'other',
      },
    PEOPLE_EXTERNAL_IDS: {
      'account': 'account',
      'customer': 'customer',
      'loginid': 'loginId',
      'network': 'network',
      'organization': 'organization',
      'organisation': 'organization',
      },
    PEOPLE_RELATIONS: {
      'spouse' : 'spouse',
      'child' : 'child',
      'mother' : 'mother',
      'father' : 'father',
      'parent' : 'parent',
      'brother' : 'brother',
      'sister' : 'sister',
      'friend' : 'friend',
      'relative' : 'relative',
      'domesticpartner' : 'domesticPartner',
      'manager' : 'manager',
      'assistant' : 'assistant',
      'referredby' : 'referredBy',
      'partner' : 'partner',
      },
    PEOPLE_SIP_ADDRESSES: {
      'work': 'work',
      'home': 'home',
      'other': 'other',
      'mobile': 'mobile',
      },
    }

# Fields with a type, value and primary|notprimary; some fields may have additional arguments
  TYPE_VALUE_PNP_FIELDS = {
    PEOPLE_ADDRESSES: {
      'work': 'work',
      'home': 'home',
      'other': 'other',
      },
    PEOPLE_CALENDAR_URLS: {
      'work': 'work',
      'home': 'home',
      'freebusy': 'freeBusy',
      },
    PEOPLE_EMAIL_ADDRESSES: {
      'work': 'work',
      'home': 'home',
      'other': 'other',
      },
    PEOPLE_IM_CLIENTS: {
      'work': 'work',
      'home': 'home',
      'other': 'other',
      },
    PEOPLE_ORGANIZATIONS: {
      'school': 'school',
      'work': 'work',
      'other': 'other',
      },
    PEOPLE_PHONE_NUMBERS: {
      'work': 'work',
      'home': 'home',
      'other': 'other',
      'googlevoice': 'googleVoice',
      'fax': 'homeFax',
      'homefax': 'homeFax',
      'workfax': 'workFax',
      'otherfax': 'otherFax',
      'main': 'main',
      'company_main': 'companyMain',
      'assistant': 'assistant',
      'mobile': 'mobile',
      'workmobile': 'workMobile',
      'pager': 'pager',
      'workpager': 'workPager',
      'car': 'car',
      'radio': 'radio',
      'callback': 'callback',
      'isdn': 'isdn',
      'telex': 'telex',
      'ttytdd': 'TTY_TDD',
      },
    PEOPLE_SIP_ADDRESSES: {
      'work': 'work',
      'home': 'home',
      'mobile': 'mobile',
      'other': 'other',
      },
    PEOPLE_URLS: {
      'appinstallpage': 'appInstallPage',
      'blog': 'blog',
      'ftp': 'ftp',
      'home': 'homePage',
      'homepage': 'homePage',
      'other': 'other',
      'profile': 'profile',
      'reservations': 'reservations',
      'resume': 'resume',
      'work': 'work',
      }
    }

# Fields that allow an empty type
  EMPTY_TYPE_ALLOWED_FIELDS = {PEOPLE_ADDRESSES, PEOPLE_EMAIL_ADDRESSES, PEOPLE_PHONE_NUMBERS, PEOPLE_URLS}

# Fields with just a URL
#  URL_FIELDS = {
#    PEOPLE_COVER_PHOTOS,
#    PEOPLE_PHOTOS,
#    }

# Fields with a single value
  SINGLE_VALUE_FIELDS = {
    PEOPLE_FILE_ASES,
    }

# Fields with multiple values
  MULTI_VALUE_FIELDS = {
    PEOPLE_INTERESTS,
    PEOPLE_LOCALES,
    PEOPLE_LOCATIONS,
    PEOPLE_OCCUPATIONS,
    PEOPLE_SKILLS,
    }

  @staticmethod
  def GetPersonFields(entityType, allowAddRemove, parameters=None):
    person = {}
    contactGroupsLists = {
      PEOPLE_GROUPS_LIST: [],
      PEOPLE_ADD_GROUPS_LIST: [],
      PEOPLE_REMOVE_GROUPS_LIST: []
      }
    locations = {'primary': None}

    def CheckClearPersonField(fieldName):
      if checkArgumentPresent(Cmd.CLEAR_NONE_ARGUMENT):
        person.pop(fieldName, None)
        person[fieldName] = []
        return True
      return False

    def GetSingleFieldEntry(fieldName):
      person.setdefault(fieldName, [])
      if not person[fieldName]:
        person[fieldName].append({})
      return person[fieldName][0]

    def InitArrayFieldEntry(choices, typeMinLen=1):
      entry = {'metadata': {'primary': False}}
      if choices is not None:
        ftype = getChoice(choices, mapChoice=True, defaultChoice=None)
        if ftype:
          entry['type'] = ftype
        else:
          entry['type'] = getString(Cmd.OB_STRING, minLen=typeMinLen)
      return entry

    def GetMultiFieldEntry(fieldName):
      person.setdefault(fieldName, [])
      person[fieldName].append({})
      return person[fieldName][-1]

    def getDate(entry, fieldName):
      event = getYYYYMMDD(minLen=0, returnDateTime=True)
      if event:
        entry[fieldName] = {'year': event.year, 'month': event.month, 'day': event.day}

    def PrimaryNotPrimary(pnp, entry):
      if pnp == 'notprimary':
        entry['metadata']['primary'] = 'false'
        return True
      if pnp == 'primary':
        entry['metadata']['primary'] = 'true'
        locations['primary'] = Cmd.Location()
        return True
      return False

    def GetPrimaryNotPrimaryChoice(entry):
      pnp = getChoice({'primary': True, 'notprimary': False}, mapChoice=True)
      entry['metadata']['primary'] = pnp
      if pnp:
        locations['primary'] = Cmd.Location()

    def AppendArrayEntryToFields(fieldName, entry, checkBlankField=None):
      person.setdefault(fieldName, [])
      if checkBlankField is None or entry[checkBlankField]:
        if entry.get('metadata', {}).get('primary', False):
          for centry in person[fieldName][1:]:
            if centry.get('metadata', {}).get('primary', False):
              Cmd.SetLocation(locations['primary']-1)
              usageErrorExit(Msg.MULTIPLE_ITEMS_MARKED_PRIMARY.format(fieldName))
        person[fieldName].append(entry)

    while Cmd.ArgumentsRemaining():
      if parameters is not None:
        if _getCreateContactReturnOptions(parameters):
          continue
        Cmd.Backup()
      locations['fieldName'] = Cmd.Location()
      fieldName = getChoice(PeopleManager.PEOPLE_ARGUMENT_TO_PROPERTY_MAP, mapChoice=True)
      if '.' in fieldName:
        fieldName, subFieldName = fieldName.split('.')
      if fieldName == PEOPLE_ADDRESSES:
        if CheckClearPersonField(fieldName):
          continue
        entry = InitArrayFieldEntry(PeopleManager.TYPE_VALUE_PNP_FIELDS[fieldName], typeMinLen=0)
        while Cmd.ArgumentsRemaining():
          argument = getArgument()
          if argument in PeopleManager.ADDRESS_ARGUMENT_TO_FIELD_MAP:
            subFieldName = PeopleManager.ADDRESS_ARGUMENT_TO_FIELD_MAP[argument]
            value = getString(Cmd.OB_STRING, minLen=0)
            if value: ### Delete?
              entry[subFieldName] = value.replace('\\n', '\n')
          elif PrimaryNotPrimary(argument, entry):
            break
          else:
            unknownArgumentExit()
        AppendArrayEntryToFields(fieldName, entry, None)
      elif fieldName == PEOPLE_BIRTHDAYS:
        entry = GetSingleFieldEntry(fieldName)
        getDate(entry, 'date')
      elif fieldName == PEOPLE_BIOGRAPHIES:
        entry = GetSingleFieldEntry(fieldName)
        text, _, html = getStringWithCRsNLsOrFile()
        entry['value' ] = text
        entry['contentType'] = ['TEXT_PLAIN', 'TEXT_HTML'][html]
      elif fieldName == PEOPLE_GENDERS:
        entry = GetSingleFieldEntry(fieldName)
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
      elif fieldName == PEOPLE_MISC_KEYWORDS:
        entry = GetMultiFieldEntry(fieldName)
        if subFieldName == 'jot':
          subFieldName = getChoice(PeopleManager.JOT_TYPE_MAP, mapChoice=True)
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        entry['type'] = subFieldName
      elif fieldName == PEOPLE_NAMES:
        entry = GetSingleFieldEntry(fieldName)
        entry[subFieldName] = getString(Cmd.OB_STRING, minLen=0)
      elif fieldName == PEOPLE_NICKNAMES:
        entry = GetMultiFieldEntry(fieldName)
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        entry['type'] = subFieldName
      elif fieldName == PEOPLE_ORGANIZATIONS:
        entry = InitArrayFieldEntry(PeopleManager.TYPE_VALUE_PNP_FIELDS[fieldName])
        entry['name'] = getString(Cmd.OB_STRING, minLen=0)
        while Cmd.ArgumentsRemaining():
          argument = getArgument()
          if argument in PeopleManager.ORGANIZATION_ARGUMENT_TO_FIELD_MAP:
            subFieldName = PeopleManager.ORGANIZATION_ARGUMENT_TO_FIELD_MAP[argument]
            if subFieldName == 'current':
              entry[subFieldName] = getBoolean()
            elif subFieldName in {'startDate', 'endDate'}:
              getDate(entry, subFieldName)
            else:
              value = getString(Cmd.OB_STRING, minLen=0)
              if value: ### Delete?
                entry[subFieldName] = value
          elif PrimaryNotPrimary(argument, entry):
            break
          else:
            unknownArgumentExit()
        AppendArrayEntryToFields(fieldName, entry, None)
      elif fieldName in PeopleManager.KEY_VALUE_FIELDS:
        if CheckClearPersonField(fieldName):
          continue
        entry = InitArrayFieldEntry(None)
        entry['key'] = getString(Cmd.OB_STRING, minLen=1)
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        AppendArrayEntryToFields(fieldName, entry, 'value')
      elif fieldName in PeopleManager.TYPE_VALUE_FIELDS:
        if CheckClearPersonField(fieldName):
          continue
        entry = InitArrayFieldEntry(PeopleManager.TYPE_VALUE_FIELDS[fieldName])
        if fieldName == PEOPLE_EVENTS:
          checkBlankField = 'date'
          getDate(entry, checkBlankField)
        elif fieldName == PEOPLE_RELATIONS:
          checkBlankField = 'type'
          entry['person'] = getString(Cmd.OB_STRING, minLen=0)
        else:
          checkBlankField = 'value'
          entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        AppendArrayEntryToFields(fieldName, entry, checkBlankField)
      elif fieldName in PeopleManager.TYPE_VALUE_PNP_FIELDS:
        if CheckClearPersonField(fieldName):
          continue
        entry = InitArrayFieldEntry(PeopleManager.TYPE_VALUE_PNP_FIELDS[fieldName],
                                    typeMinLen=0 if fieldName in PeopleManager.EMPTY_TYPE_ALLOWED_FIELDS else 1)
        if fieldName == PEOPLE_IM_CLIENTS:
          checkBlankField = None
          entry['protocol'] = getChoice(PeopleManager.IM_PROTOCOLS, mapChoice=True)
          entry['username'] = getString(Cmd.OB_STRING, minLen=0)
        elif fieldName == PEOPLE_EMAIL_ADDRESSES:
          checkBlankField = 'value'
          entry[checkBlankField] = getString(Cmd.OB_STRING, minLen=0)
          if checkArgumentPresent(['displayname']):
            entry['displayName'] = getString(Cmd.OB_STRING, minLen=0)
        elif fieldName == PEOPLE_CALENDAR_URLS:
          checkBlankField = 'url'
          entry[checkBlankField] = getString(Cmd.OB_STRING, minLen=0)
        else:
          checkBlankField = 'value'
          entry[checkBlankField] = getString(Cmd.OB_STRING, minLen=0)
        GetPrimaryNotPrimaryChoice(entry)
        AppendArrayEntryToFields(fieldName, entry, checkBlankField)
      elif fieldName in PeopleManager.SINGLE_VALUE_FIELDS:
        entry = GetSingleFieldEntry(fieldName)
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
      elif fieldName in PeopleManager.MULTI_VALUE_FIELDS:
        if CheckClearPersonField(fieldName):
          continue
        entry = InitArrayFieldEntry(None)
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        AppendArrayEntryToFields(fieldName, entry, 'value')
#      elif fieldName in PeopleManager.URL_FIELDS:
#        if CheckClearPersonField(fieldName):
#          continue
#        entry = InitArrayFieldEntry(None)
#        entry['url'] = getString(Cmd.OB_STRING, minLen=0)
#        entry['default'] = False
#        AppendArrayEntryToFields(fieldName, entry, 'url')
      elif fieldName == PEOPLE_GROUPS:
        if entityType != Ent.USER:
          Cmd.Backup()
          unknownArgumentExit()
        contactGroupsLists[PEOPLE_GROUPS_LIST].append(getString(Cmd.OB_STRING))
      elif fieldName == PEOPLE_ADD_GROUPS:
        if not allowAddRemove:
          unknownArgumentExit()
        if entityType != Ent.USER:
          Cmd.Backup()
          unknownArgumentExit()
        contactGroupsLists[PEOPLE_ADD_GROUPS_LIST].append(getString(Cmd.OB_STRING))
      elif fieldName == PEOPLE_REMOVE_GROUPS:
        if not allowAddRemove:
          unknownArgumentExit()
        if entityType != Ent.USER:
          Cmd.Backup()
          unknownArgumentExit()
        contactGroupsLists[PEOPLE_REMOVE_GROUPS_LIST].append(getString(Cmd.OB_STRING))
      elif fieldName == PEOPLE_JSON:
        jsonData = getJSON(['resourceName', 'etag', 'metadata', PEOPLE_COVER_PHOTOS, PEOPLE_PHOTOS, PEOPLE_UPDATE_TIME])
        for membership in jsonData.pop('memberships', []):
          contactGroupName = membership.get('contactGroupMembership', {}).get('contactGroupName', '')
          if contactGroupName:
            contactGroupsLists[PEOPLE_GROUPS_LIST].append(contactGroupName)
        newClientData = []
        for clientData in jsonData.pop('clientData', []):
          if clientData['key'] not in {'ContactId', 'CtsContactHash'}:
            newClientData.append({'key': clientData['key'], 'value': clientData['value']})
        if newClientData:
          person.setdefault(PEOPLE_CLIENT_DATA, [])
          person[PEOPLE_CLIENT_DATA].extend(newClientData)
        person.update(jsonData)
    return (person, set(person.keys()), contactGroupsLists)

  PEOPLE_GROUP_ARGUMENT_TO_PROPERTY_MAP = {
    'json': PEOPLE_JSON,
    'name': PEOPLE_GROUP_NAME,
    'clientdata': PEOPLE_GROUP_CLIENT_DATA,
    }

  @staticmethod
  def AddContactGroupsToContact(contactEntry, contactGroupsList):
    contactEntry[PEOPLE_MEMBERSHIPS] = []
    for groupId in contactGroupsList:
      if groupId != 'clear':
        contactEntry[PEOPLE_MEMBERSHIPS].append({'contactGroupMembership': {'contactGroupResourceName': groupId}})
      else:
        contactEntry[PEOPLE_MEMBERSHIPS] = []

  @staticmethod
  def AddFilteredContactGroupsToContact(contactEntry, contactGroupsList, contactRemoveGroupsList):
    contactEntry[PEOPLE_MEMBERSHIPS] = []
    for groupId in contactGroupsList:
      if groupId not in contactRemoveGroupsList:
        contactEntry[PEOPLE_MEMBERSHIPS].append({'contactGroupMembership': {'contactGroupResourceName': groupId}})

  @staticmethod
  def AddAdditionalContactGroupsToContact(contactEntry, contactGroupsList):
    for groupId in contactGroupsList:
      contactEntry[PEOPLE_MEMBERSHIPS].append({'contactGroupMembership': {'contactGroupResourceName': groupId}})

  @staticmethod
  def GetContactGroupFields(parameters=None):
    contactGroup = {}
    while Cmd.ArgumentsRemaining():
      if parameters is not None:
        if _getCreateContactReturnOptions(parameters):
          continue
        Cmd.Backup()
      fieldName = getChoice(PeopleManager.PEOPLE_GROUP_ARGUMENT_TO_PROPERTY_MAP, mapChoice=True)
      if fieldName == PEOPLE_GROUP_NAME:
        contactGroup[PEOPLE_GROUP_NAME] = getString(Cmd.OB_STRING)
      elif fieldName == PEOPLE_GROUP_CLIENT_DATA:
        entry = {}
        entry['key'] = getString(Cmd.OB_STRING, minLen=1)
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        if entry['value']:
          contactGroup.setdefault(fieldName, [])
          contactGroup[fieldName].append(entry)
      elif fieldName == PEOPLE_JSON:
        jsonData = getJSON(['resourceName', 'etag', 'metadata', 'formattedName', 'memberResourceNames',  'memberCount'])
        if jsonData.get('groupType', '') != 'SYSTEM_CONTACT_GROUP':
          contactGroup[PEOPLE_GROUP_NAME] = jsonData['name']
    return (contactGroup, ','.join(contactGroup.keys()))

PEOPLE_DIRECTORY_SOURCES_CHOICE_MAP = {
  'contact': 'DIRECTORY_SOURCE_TYPE_DOMAIN_CONTACT',
  'contacts': 'DIRECTORY_SOURCE_TYPE_DOMAIN_CONTACT',
  'domaincontact': 'DIRECTORY_SOURCE_TYPE_DOMAIN_CONTACT',
  'comaincontacts': 'DIRECTORY_SOURCE_TYPE_DOMAIN_CONTACT',
  'profile': 'DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE',
  'profiles': 'DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE'
  }

PEOPLE_READ_SOURCES_CHOICE_MAP = {
  'contact': 'READ_SOURCE_TYPE_CONTACT',
  'contacts': 'READ_SOURCE_TYPE_CONTACT',
  'domaincontact': 'READ_SOURCE_TYPE_DOMAIN_CONTACT',
  'domaincontacts': 'READ_SOURCE_TYPE_DOMAIN_CONTACT',
  'profile': 'READ_SOURCE_TYPE_PROFILE',
  'profiles': 'READ_SOURCE_TYPE_PROFILE'
  }

PEOPLE_DIRECTORY_MERGE_SOURCES_CHOICE_MAP = {
  'contact': 'DIRECTORY_MERGE_SOURCE_TYPE_CONTACT',
  'contacts': 'DIRECTORY_MERGE_SOURCE_TYPE_CONTACT',
  }

def _initPeopleContactQueryAttributes(printShowCmd):
  return {'query': None, 'updateTime': None,
          'contactGroupSelect': None, 'contactGroupFilter': None, 'group': None, 'dropMemberships': False,
          'mainContacts': True, 'otherContacts': printShowCmd, 'emailMatchPattern': None, 'emailMatchType': None}

def _getPeopleContactQueryAttributes(contactQuery, myarg, entityType, unknownAction, printShowCmd):
  if myarg == 'query':
    contactQuery['query'] = getString(Cmd.OB_QUERY)
  elif myarg in {'contactgroup', 'selectcontactgroup'}:
    if entityType == Ent.USER:
      contactQuery['contactGroupSelect'] = getString(Cmd.OB_CONTACT_GROUP_ITEM)
      contactQuery['contactGroupFilter'] = None
      contactQuery['mainContacts'] = True
      contactQuery['otherContacts'] = False
    else:
      unknownArgumentExit()
  elif myarg == 'emailmatchpattern':
    contactQuery['emailMatchPattern'] = getREPattern(re.IGNORECASE)
  elif myarg == 'emailmatchtype':
    contactQuery['emailMatchType'] = getString(Cmd.OB_CONTACT_EMAIL_TYPE)
  elif myarg == 'updatedmin':
    deprecatedArgument(myarg)
    getYYYYMMDD()
  elif myarg == 'endquery':
    return False
  elif not printShowCmd:
    if unknownAction < 0:
      unknownArgumentExit()
    if unknownAction > 0:
      Cmd.Backup()
    return False
  elif myarg == 'filtercontactgroup':
    if entityType == Ent.USER:
      contactQuery['contactGroupFilter'] = getString(Cmd.OB_CONTACT_GROUP_ITEM)
      contactQuery['contactGroupSelect'] = None
      contactQuery['mainContacts'] = True
      contactQuery['otherContacts'] = False
    else:
      unknownArgumentExit()
  elif myarg in {'maincontacts', 'selectmaincontacts'}:
    if entityType == Ent.USER:
      contactQuery['contactGroupSelect'] = None
      contactQuery['contactGroupFilter'] = None
      contactQuery['mainContacts'] = True
      contactQuery['otherContacts'] = False
    else:
      unknownArgumentExit()
  elif myarg in {'othercontacts', 'selectothercontacts'}:
    if entityType == Ent.USER:
      contactQuery['contactGroupSelect'] = None
      contactQuery['contactGroupFilter'] = None
      contactQuery['mainContacts'] = False
      contactQuery['otherContacts'] = True
    else:
      unknownArgumentExit()
  elif myarg == 'orderby':
    getOrderBySortOrder(CONTACTS_ORDERBY_CHOICE_MAP, 'ascending', False)
    deprecatedArgument(myarg)
  elif myarg in CONTACTS_PROJECTION_CHOICE_MAP:
    deprecatedArgument(myarg)
  elif myarg == 'showdeleted':
    deprecatedArgument(myarg)
  else:
    if unknownAction < 0:
      unknownArgumentExit()
    if unknownAction > 0:
      Cmd.Backup()
    return False
  return True

PEOPLE_CONTACT_SELECT_ARGUMENTS = {
  'query', 'contactgroup', 'selectcontactgroup',
  'maincontacts', 'selectmaincontacts',
  'othercontacts', 'selectothercontacts',
  'emailmatchpattern', 'emailmatchtype',
  }
PEOPLE_CONTACT_DEPRECATED_SELECT_ARGUMENTS = {
  'orderby', 'basic', 'thin', 'full', 'showdeleted',
  }

def _getPeopleContactEntityList(entityType, unknownAction, noEntityArguments=None):
  contactQuery = _initPeopleContactQueryAttributes(False)
  if noEntityArguments is not None and (not Cmd.ArgumentsRemaining() or Cmd.PeekArgumentPresent(noEntityArguments)):
    # <PeopleResourceNameEntity>|<PeopleUserContactSelection> are optional in dedup|replacedomain contacts
    entityList = None
    queriedContacts = True
  elif Cmd.PeekArgumentPresent(PEOPLE_CONTACT_SELECT_ARGUMENTS.union(PEOPLE_CONTACT_DEPRECATED_SELECT_ARGUMENTS)):
    entityList = None
    queriedContacts = True
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if not _getPeopleContactQueryAttributes(contactQuery, myarg, entityType, unknownAction, False):
        break
  else:
    entityList = getEntityList(Cmd.OB_CONTACT_ENTITY)
    queriedContacts = False
    if unknownAction < 0:
      checkForExtraneousArguments()
  return (entityList, entityList if isinstance(entityList, dict) else None, contactQuery, queriedContacts)

def _initPeopleOtherContactQueryAttributes():
  return {'query': None,
          'otherContacts': True, 'emailMatchPattern': None, 'emailMatchType': None}

def _getPeopleOtherContactQueryAttributes(contactQuery, myarg, unknownAction):
  if myarg == 'query':
    contactQuery['query'] = getString(Cmd.OB_QUERY)
  elif myarg == 'emailmatchpattern':
    contactQuery['emailMatchPattern'] = getREPattern(re.IGNORECASE)
  elif myarg == 'emailmatchtype':
    contactQuery['emailMatchType'] = getString(Cmd.OB_CONTACT_EMAIL_TYPE)
  elif myarg == 'endquery':
    return False
  else:
    if unknownAction < 0:
      unknownArgumentExit()
    if unknownAction > 0:
      Cmd.Backup()
    return False
  return True

PEOPLE_OTHERCONTACT_SELECT_ARGUMENTS = {'query', 'emailmatchpattern', 'emailmatchtype'}

def _getPeopleOtherContactEntityList(unknownAction):
  contactQuery = _initPeopleOtherContactQueryAttributes()
  if Cmd.PeekArgumentPresent(PEOPLE_OTHERCONTACT_SELECT_ARGUMENTS):
    entityList = None
    queriedContacts = True
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if not _getPeopleOtherContactQueryAttributes(contactQuery, myarg, unknownAction):
        break
  else:
    entityList = getEntityList(Cmd.OB_CONTACT_ENTITY)
    queriedContacts = False
    if unknownAction < 0:
      checkForExtraneousArguments()
  return (entityList, entityList if isinstance(entityList, dict) else None, contactQuery, queriedContacts)

def _getPeopleOtherContacts(people, entityType, user, i=0, count=0):
  try:
    printGettingAllEntityItemsForWhom(Ent.OTHER_CONTACT, user, i, count)
    results = callGAPIpages(people.otherContacts(), 'list', 'otherContacts',
                            pageMessage=getPageMessageForWhom(),
                            throwReasons=GAPI.PEOPLE_ACCESS_THROW_REASONS,
                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                            pageSize=1000,
                            readMask='emailAddresses', fields='nextPageToken,otherContacts(etag,resourceName,emailAddresses(value,type))')
    otherContacts = {}
    for contact in results:
      resourceName = contact.pop('resourceName')
      otherContacts[resourceName] = contact
    return otherContacts
  except GAPI.permissionDenied as e:
    ClientAPIAccessDeniedExit(str(e))
  except (GAPI.serviceNotAvailable, GAPI.forbidden):
    entityUnknownWarning(entityType, user, i, count)
  return None

def queryPeopleContacts(people, contactQuery, fields, sortOrder, entityType, user, i=0, count=0):
  sources = [PEOPLE_READ_SOURCES_CHOICE_MAP['domaincontact' if entityType == Ent.DOMAIN else 'contact']]
  printGettingAllEntityItemsForWhom(Ent.PEOPLE_CONTACT, user, i, count, query=contactQuery['query'])
  pageMessage = getPageMessageForWhom()
  try:
# Contact group not selected
    if not contactQuery['contactGroupSelect']:
      if not contactQuery['query']:
        results = callGAPIpages(people.people().connections(), 'list', 'connections',
                                pageMessage=pageMessage,
                                throwReasons=GAPI.PEOPLE_ACCESS_THROW_REASONS,
                                retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                pageSize=GC.Values[GC.PEOPLE_MAX_RESULTS],
                                resourceName='people/me', sources=sources, personFields=fields,
                                sortOrder=sortOrder, fields='nextPageToken,connections')
        if contactQuery['contactGroupFilter']:
          entityList = []
          for person in results:
            for membership in person.get(PEOPLE_MEMBERSHIPS, []):
              if membership.get('contactGroupMembership', {}).get('contactGroupResourceName', '') == contactQuery['group']:
                if contactQuery['dropMemberships']:
                  person.pop(PEOPLE_MEMBERSHIPS)
                entityList.append(person)
                break
        else:
          entityList = results
      else:
        results = callGAPI(people.people(), 'searchContacts',
                           throwReasons=GAPI.PEOPLE_ACCESS_THROW_REASONS,
                           sources=sources, readMask=fields, query=contactQuery['query'])
        entityList = [person['person'] for person in results.get('results', [])]
      totalItems = len(entityList)
# Contact group selected
    else:
      totalItems = callGAPI(people.contactGroups(), 'get',
                            throwReasons=GAPI.PEOPLE_ACCESS_THROW_REASONS,
                            resourceName=contactQuery['group'], groupFields='memberCount').get('memberCount', 0)
      entityList = []
      if totalItems > 0:
        results = callGAPI(people.contactGroups(), 'get',
                           throwReasons=GAPI.PEOPLE_ACCESS_THROW_REASONS,
                           resourceName=contactQuery['group'], maxMembers=totalItems, groupFields='name')
        for resourceName in results.get('memberResourceNames', []):
          result = callGAPI(people.people(), 'get',
                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                            resourceName=resourceName, sources=sources, personFields=fields)

          entityList.append(result)
    if pageMessage and (contactQuery['contactGroupSelect'] or contactQuery['contactGroupFilter'] or contactQuery['query']):
      showMessage = pageMessage.replace(TOTAL_ITEMS_MARKER, str(totalItems))
      writeGotMessage(showMessage.replace('{0}', str(Ent.Choose(Ent.PEOPLE_CONTACT, totalItems))))
    return entityList
  except (GAPI.permissionDenied, GAPI.failedPrecondition) as e:
    ClientAPIAccessDeniedExit(str(e))
  except (GAPI.serviceNotAvailable, GAPI.forbidden):
    entityUnknownWarning(entityType, user, i, count)
  return None

def queryPeopleOtherContacts(people, contactQuery, fields, entityType, user, i=0, count=0):
  sources = [PEOPLE_READ_SOURCES_CHOICE_MAP['contact']]
  printGettingAllEntityItemsForWhom(Ent.OTHER_CONTACT, user, i, count, query=contactQuery['query'])
  pageMessage = getPageMessageForWhom()
  try:
    if not contactQuery['query']:
      entityList = callGAPIpages(people.otherContacts(), 'list', 'otherContacts',
                                 pageMessage=pageMessage,
                                 throwReasons=GAPI.PEOPLE_ACCESS_THROW_REASONS,
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 pageSize=GC.Values[GC.PEOPLE_MAX_RESULTS],
                                 readMask=fields, fields='nextPageToken,otherContacts', sources=sources)
    else:
      results = callGAPI(people.otherContacts(), 'search',
                         throwReasons=GAPI.PEOPLE_ACCESS_THROW_REASONS,
                         pageSize=30, readMask=fields, query=contactQuery['query'])
      entityList = [person['person'] for person in results.get('results', [])]
      totalItems = len(entityList)
      if pageMessage:
        showMessage = pageMessage.replace(TOTAL_ITEMS_MARKER, str(totalItems))
        writeGotMessage(showMessage.replace('{0}', str(Ent.Choose(Ent.OTHER_CONTACT, totalItems))))
    return entityList
  except GAPI.permissionDenied as e:
    ClientAPIAccessDeniedExit(str(e))
  except (GAPI.serviceNotAvailable, GAPI.forbidden):
    entityUnknownWarning(entityType, user, i, count)
  return None

def getPeopleContactGroupsInfo(people, entityType, entityName, i, count):
  contactGroupIDs = {}
  contactGroupNames = {}
  try:
    groups = callGAPIpages(people.contactGroups(), 'list', 'contactGroups',
                           throwReasons=GAPI.PEOPLE_ACCESS_THROW_REASONS,
                           pageSize=GC.Values[GC.PEOPLE_MAX_RESULTS],
                           groupFields='name', fields='nextPageToken,contactGroups(resourceName,name,formattedName)')
    if groups:
      for group in groups:
        contactGroupIDs[group['resourceName']] = group['formattedName']
        contactGroupNames.setdefault(group['formattedName'], [])
        contactGroupNames[group['formattedName']].append(group['resourceName'])
        if group['formattedName'] != group['name']:
          contactGroupNames.setdefault(group['name'], [])
          contactGroupNames[group['name']].append(group['resourceName'])
  except GAPI.permissionDenied as e:
    ClientAPIAccessDeniedExit(str(e))
  except GAPI.forbidden:
    userPeopleServiceNotEnabledWarning(entityName, i, count)
    return (contactGroupIDs, False)
  except GAPI.serviceNotAvailable:
    entityUnknownWarning(entityType, entityName, i, count)
    return (contactGroupIDs, False)
  return (contactGroupIDs, contactGroupNames)

def validatePeopleContactGroup(people, contactGroupName,
                               contactGroupIDs, contactGroupNames, entityType, entityName, i, count):
  if not contactGroupNames:
    contactGroupIDs, contactGroupNames = getPeopleContactGroupsInfo(people, entityType, entityName, i, count)
    if contactGroupNames is False:
      return (None, contactGroupIDs, contactGroupNames)
  if contactGroupName == 'clear':
    return (contactGroupName, contactGroupIDs, contactGroupNames)
  cg = UID_PATTERN.match(contactGroupName)
  if cg:
    contactGroupName = cg.group(1)
    if contactGroupName in contactGroupIDs:
      return (contactGroupName, contactGroupIDs, contactGroupNames)
    normalizedContactGroupName = normalizeContactGroupResourceName(contactGroupName)
    if normalizedContactGroupName in contactGroupIDs:
      return (normalizedContactGroupName, contactGroupIDs, contactGroupNames)
  else:
    if contactGroupName in contactGroupIDs:
      return (contactGroupName, contactGroupIDs, contactGroupNames)
    if contactGroupName in contactGroupNames:
      return (contactGroupNames[contactGroupName][0], contactGroupIDs, contactGroupNames)
    normalizedContactGroupName = normalizeContactGroupResourceName(contactGroupName)
    if normalizedContactGroupName != contactGroupName and normalizedContactGroupName in contactGroupIDs:
      return (normalizedContactGroupName, contactGroupIDs, contactGroupNames)
  return (None, contactGroupIDs, contactGroupNames)

def validatePeopleContactGroupsList(people, contactId,
                                    contactGroupsList, entityType, entityName, i, count):
  result = True
  contactGroupIDs = contactGroupNames = None
  validatedContactGroupsList = []
  for contactGroup in contactGroupsList:
    groupId, contactGroupIDs, contactGroupNames = validatePeopleContactGroup(people, contactGroup,
                                                                             contactGroupIDs, contactGroupNames, entityType, entityName, i, count)
    if groupId:
      validatedContactGroupsList.append(groupId)
    else:
      if contactGroupNames:
        entityActionNotPerformedWarning([entityType, entityName, Ent.CONTACT, contactId],
                                        Ent.TypeNameMessage(Ent.CONTACT_GROUP, contactGroup, Msg.DOES_NOT_EXIST))
      result = False
  return (result, validatedContactGroupsList, contactGroupIDs)

# gam <UserTypeEntity> create contact <PeopleContactAttribute>+
#	(contactgroup <ContactGroupItem>)*
#	[(csv [todrive <ToDriveAttribute>*] (addcsvdata <FieldName> <String>)*))| returnidonly]
def createUserPeopleContact(users):
  entityType = Ent.USER
  peopleManager = PeopleManager()
  peopleEntityType = Ent.CONTACT
  sources = PEOPLE_READ_SOURCES_CHOICE_MAP['contact']
  parameters = {'csvPF': None, 'titles': ['User', 'resourceName'], 'addCSVData': {}, 'returnIdOnly': False}
  body, personFields, contactGroupsLists = peopleManager.GetPersonFields(entityType, False, parameters)
  csvPF = parameters['csvPF']
  addCSVData = parameters['addCSVData']
  if addCSVData:
    csvPF.AddTitles(sorted(addCSVData.keys()))
  returnIdOnly = parameters['returnIdOnly']
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, people = buildGAPIServiceObject(API.PEOPLE, user, i, count)
    if not people:
      continue
    if contactGroupsLists[PEOPLE_GROUPS_LIST]:
      result, validatedContactGroupsList, _ = validatePeopleContactGroupsList(people, '',
                                                                              contactGroupsLists[PEOPLE_GROUPS_LIST], entityType, user, i, count)
      if not result:
        continue
      peopleManager.AddContactGroupsToContact(body, validatedContactGroupsList)
      personFields.add(PEOPLE_MEMBERSHIPS)
    else:
      personFields.discard(PEOPLE_MEMBERSHIPS)
    try:
      result = callGAPI(people.people(), 'createContact',
                        throwReasons=GAPI.PEOPLE_ACCESS_THROW_REASONS+[GAPI.INVALID_ARGUMENT],
                        retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                        personFields=','.join(personFields), body=body, sources=sources)
      resourceName = result['resourceName']
      if returnIdOnly:
        writeStdout(f'{resourceName}\n')
      elif not csvPF:
        entityActionPerformed([entityType, user, peopleEntityType, resourceName], i, count)
      else:
        row = {'User': user, 'resourceName': resourceName}
        if addCSVData:
          row.update(addCSVData)
        csvPF.WriteRow(row)
    except GAPI.invalidArgument as e:
      entityActionFailedWarning([entityType, user, peopleEntityType, None], str(e), i, count)
    except (GAPI.permissionDenied, GAPI.failedPrecondition) as e:
      ClientAPIAccessDeniedExit(str(e))
    except (GAPI.serviceNotAvailable, GAPI.forbidden):
      ClientAPIAccessDeniedExit()
  if csvPF:
    csvPF.writeCSVfile('People Contacts')

def localPeopleContactSelects(contactQuery, contact):
  if contactQuery['emailMatchPattern']:
    emailMatchType = contactQuery['emailMatchType']
    for item in contact.get(PEOPLE_EMAIL_ADDRESSES, []):
      if contactQuery['emailMatchPattern'].match(item['value']):
        if (not emailMatchType or emailMatchType == item.get('type', '')):
          break
    else:
      return False
  return True

def countLocalPeopleContactSelects(contactQuery, contacts):
  if contacts is not None and contactQuery['emailMatchPattern']:
    jcount = 0
    for contact in contacts:
      if localPeopleContactSelects(contactQuery, contact):
        jcount += 1
  else:
    jcount = len(contacts) if contacts is not None else 0
  return jcount

def clearPeopleEmailAddressMatches(contactClear, contact):
  savedAddresses = []
  updateRequired = False
  emailMatchType = contactClear['emailClearType']
  for item in contact.get(PEOPLE_EMAIL_ADDRESSES, []):
    if (contactClear['emailClearPattern'].match(item['value']) and
        (not emailMatchType or emailMatchType == item.get('type', ''))):
      updateRequired = True
    else:
      savedAddresses.append(item)
  if updateRequired:
    contact[PEOPLE_EMAIL_ADDRESSES] = savedAddresses
  return updateRequired

def _clearUpdatePeopleContacts(users, updateContacts):
  action = Act.Get()
  entityType = Ent.USER
  peopleManager = PeopleManager()
  peopleEntityType = Ent.PEOPLE_CONTACT
  sources = PEOPLE_READ_SOURCES_CHOICE_MAP['contact']
  entityList, resourceNameLists, contactQuery, queriedContacts = _getPeopleContactEntityList(entityType, 1)
  if updateContacts:
    body, updatePersonFields, contactGroupsLists = peopleManager.GetPersonFields(entityType, True)
  else:
    contactClear = {'emailClearPattern': contactQuery['emailMatchPattern'], 'emailClearType': contactQuery['emailMatchType']}
    deleteClearedContactsWithNoEmails = False
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg == 'emailclearpattern':
        contactClear['emailClearPattern'] = getREPattern(re.IGNORECASE)
      elif myarg == 'emailcleartype':
        contactClear['emailClearType'] = getString(Cmd.OB_CONTACT_EMAIL_TYPE)
      elif myarg == 'deleteclearedcontactswithnoemails':
        deleteClearedContactsWithNoEmails = True
      else:
        unknownArgumentExit()
    if not contactClear['emailClearPattern']:
      missingArgumentExit('emailclearpattern')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    if resourceNameLists:
      entityList = resourceNameLists[user]
    user, people = buildGAPIServiceObject(API.PEOPLE, user, i, count)
    if not people:
      continue
    if contactQuery['contactGroupSelect']:
      groupId, _, contactGroupNames = validatePeopleContactGroup(people, contactQuery['contactGroupSelect'],
                                                                 None, None, entityType, user, i, count)
      if not groupId:
        if contactGroupNames:
          entityActionFailedWarning([entityType, user, Ent.CONTACT_GROUP, contactQuery['contactGroupSelect']], Msg.DOES_NOT_EXIST, i, count)
        continue
      contactQuery['group'] = groupId
    if queriedContacts:
      entityList = queryPeopleContacts(people, contactQuery, 'emailAddresses,memberships', None, entityType, user, i, count)
      if entityList is None:
        continue
    Act.Set(action)
    j = 0
    jcount = len(entityList)
    entityPerformActionModifierNumItems([entityType, user], Msg.MAXIMUM_OF, jcount, peopleEntityType, i, count)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      continue
    validatedContactGroupsLists = {
      PEOPLE_GROUPS_LIST: [],
      PEOPLE_ADD_GROUPS_LIST: [],
      PEOPLE_REMOVE_GROUPS_LIST: []
      }
    Ind.Increment()
    for contact in entityList:
      j += 1
      try:
        if not queriedContacts:
          resourceName = contact
          contact = callGAPI(people.people(), 'get',
                             bailOnInternalError=True,
                             throwReasons=[GAPI.NOT_FOUND, GAPI.INTERNAL_ERROR]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                             retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                             resourceName=contact, sources=sources, personFields='emailAddresses,memberships')
        else:
          if not localPeopleContactSelects(contactQuery, contact):
            continue
          resourceName = contact['resourceName']
        if updateContacts:
          body['etag'] = contact['etag']
          existingContactGroupsList = []
          for contactGroup in contact.get(PEOPLE_MEMBERSHIPS, []):
            if 'contactGroupMembership' in contactGroup:
              existingContactGroupsList.append(contactGroup['contactGroupMembership']['contactGroupResourceName'])
          groupError = False
          for field in [PEOPLE_GROUPS_LIST, PEOPLE_ADD_GROUPS_LIST, PEOPLE_REMOVE_GROUPS_LIST]:
            if contactGroupsLists[field] and not validatedContactGroupsLists[field]:
              status, validatedContactGroupsLists[field], _ = validatePeopleContactGroupsList(people, resourceName,
                                                                                              contactGroupsLists[field], entityType, user, i, count)
              if not status:
                groupError = True
          if groupError:
            break
          if validatedContactGroupsLists[PEOPLE_GROUPS_LIST]:
            peopleManager.AddContactGroupsToContact(body, validatedContactGroupsLists[PEOPLE_GROUPS_LIST])
            updatePersonFields.add(PEOPLE_MEMBERSHIPS)
          elif validatedContactGroupsLists[PEOPLE_ADD_GROUPS_LIST] or validatedContactGroupsLists[PEOPLE_REMOVE_GROUPS_LIST]:
            body[PEOPLE_MEMBERSHIPS] = []
            if contact.get(PEOPLE_MEMBERSHIPS):
              peopleManager.AddFilteredContactGroupsToContact(body, existingContactGroupsList,
                                                              validatedContactGroupsLists[PEOPLE_REMOVE_GROUPS_LIST])
            if validatedContactGroupsLists[PEOPLE_ADD_GROUPS_LIST]:
              peopleManager.AddAdditionalContactGroupsToContact(body, validatedContactGroupsLists[PEOPLE_ADD_GROUPS_LIST])
            updatePersonFields.add(PEOPLE_MEMBERSHIPS)
          elif existingContactGroupsList:
            updatePersonFields.discard(PEOPLE_MEMBERSHIPS)
        else:
          if not clearPeopleEmailAddressMatches(contactClear, contact):
            continue
          if deleteClearedContactsWithNoEmails and not contact[PEOPLE_EMAIL_ADDRESSES]:
            Act.Set(Act.DELETE)
            callGAPI(people.people(), 'deleteContact',
                     bailOnInternalError=True,
                     throwReasons=[GAPI.NOT_FOUND, GAPI.INTERNAL_ERROR]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                     retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                     resourceName=resourceName)
            entityActionPerformed([entityType, user, peopleEntityType, resourceName], j, jcount)
            continue
          body = contact
          updatePersonFields = [PEOPLE_EMAIL_ADDRESSES]
        person = callGAPI(people.people(), 'updateContact',
                          throwReasons=[GAPI.INVALID_ARGUMENT, GAPI.NOT_FOUND, GAPI.INTERNAL_ERROR]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                          retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                          resourceName=resourceName,
                          updatePersonFields=','.join(updatePersonFields), body=body, sources=sources)
        entityActionPerformed([entityType, user, peopleEntityType, person['resourceName']], j, jcount)
      except GAPI.invalidArgument as e:
        entityActionFailedWarning([entityType, user, peopleEntityType, resourceName], str(e), j, jcount)
      except (GAPI.notFound, GAPI.internalError):
        entityActionFailedWarning([entityType, user, peopleEntityType, resourceName], Msg.DOES_NOT_EXIST, j, jcount)
      except (GAPI.permissionDenied, GAPI.failedPrecondition) as e:
        ClientAPIAccessDeniedExit(str(e))
      except (GAPI.serviceNotAvailable, GAPI.forbidden):
        ClientAPIAccessDeniedExit()
    Ind.Decrement()

# gam <UserTypeEntity> clear contacts <PeopleResourceNameEntity>|<PeopleUserContactSelection>
#	[emailclearpattern <REMatchPattern>] [emailcleartype work|home|other|<String>]
#	[deleteclearedcontactswithnoemails]
def clearUserPeopleContacts(users):
  _clearUpdatePeopleContacts(users, False)

# gam <UserTypeEntity> update contacts <PeopleResourceNameEntity>|(<PeopleUserContactSelection> endquery)
#	<PeopleContactAttribute>+
#	(contactgroup <ContactGroupItem>)*|((addcontactgroup <ContactGroupItem>)* (removecontactgroup <ContactGroupItem>)*)
# gam <UserTypeEntity> update contacts
def updateUserPeopleContacts(users):
  _clearUpdatePeopleContacts(users, True)

def dedupPeopleEmailAddressMatches(emailMatchType, contact):
  sai = -1
  savedAddresses = []
  matches = {}
  updateRequired = False
  for item in contact.get(PEOPLE_EMAIL_ADDRESSES, []):
    emailAddr = item['value']
    emailType = item.get('type', '')
    if (emailAddr in matches) and (not emailMatchType or emailType in matches[emailAddr]['types']):
      if item['metadata'].get('primary', False):
        savedAddresses[matches[emailAddr]['sai']]['metadata']['primary'] = True
      updateRequired = True
    else:
      savedAddresses.append(item)
      sai += 1
      matches.setdefault(emailAddr, {'types': set(), 'sai': sai})
      matches[emailAddr]['types'].add(emailType)
  if updateRequired:
    contact[PEOPLE_EMAIL_ADDRESSES] = savedAddresses
  return updateRequired

def replaceDomainPeopleEmailAddressMatches(contactQuery, contact, replaceDomains):
  updateRequired = False
  if contactQuery['emailMatchPattern']:
    emailMatchType = contactQuery['emailMatchType']
    for item in contact.get(PEOPLE_EMAIL_ADDRESSES, []):
      emailAddr = item['value']
      emailType = item.get('type', '')
      if contactQuery['emailMatchPattern'].match(emailAddr):
        userName, domain = splitEmailAddress(emailAddr)
        domain = domain.lower()
        if ((domain in replaceDomains) and
            (not emailMatchType or emailType == emailMatchType)):
          item['value'] = f'{userName}@{replaceDomains[domain]}'
          updateRequired = True
  else:
    for item in contact.get(PEOPLE_EMAIL_ADDRESSES, []):
      emailAddr = item['value']
      emailType = item.get('type', '')
      userName, domain = splitEmailAddress(emailAddr)
      domain = domain.lower()
      if domain in replaceDomains:
        item['value'] = f'{userName}@{replaceDomains[domain]}'
        updateRequired = True
  return updateRequired

# gam <UserTypeEntity> dedup contacts
#	[<PeopleResourceNameEntity>|<PeopleUserContactSelection>]
#	[matchType [<Boolean>]]
# gam <UserTypeEntity> replacedomain contacts
#	[<PeopleResourceNameEntity>|<PeopleUserContactSelection>]
#	(domain <DomainName> <DomainName>)+
def dedupReplaceDomainUserPeopleContacts(users):
  action = Act.Get()
  entityType = Ent.USER
  peopleEntityType = Ent.PEOPLE_CONTACT
  sources = PEOPLE_READ_SOURCES_CHOICE_MAP['contact']
  entityList, resourceNameLists, contactQuery, queriedContacts = _getPeopleContactEntityList(entityType, 1, {'matchtype', 'domain'})
  emailMatchType = False
  replaceDomains = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if action == Act.DEDUP and myarg == 'matchtype':
      emailMatchType = getBoolean()
    elif action == Act.REPLACE_DOMAIN and myarg == 'domain':
      domain = getString(Cmd.OB_DOMAIN_NAME).lower()
      replaceDomains[domain] = getString(Cmd.OB_DOMAIN_NAME).lower()
    else:
      unknownArgumentExit()
  if action == Act.REPLACE_DOMAIN and not replaceDomains:
    missingArgumentExit('domain')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    if resourceNameLists:
      entityList = resourceNameLists[user]
    user, people = buildGAPIServiceObject(API.PEOPLE, user, i, count)
    if not people:
      continue
    if contactQuery['contactGroupSelect']:
      groupId, _, contactGroupNames = validatePeopleContactGroup(people, contactQuery['contactGroupSelect'],
                                                                 None, None, entityType, user, i, count)
      if not groupId:
        if contactGroupNames:
          entityActionFailedWarning([entityType, user, Ent.CONTACT_GROUP, contactQuery['contactGroupSelect']], Msg.DOES_NOT_EXIST, i, count)
        continue
      contactQuery['group'] = groupId
    if queriedContacts:
      entityList = queryPeopleContacts(people, contactQuery, 'emailAddresses,memberships', None, entityType, user, i, count)
      if entityList is None:
        continue
    Act.Set(action)
    j = 0
    jcount = len(entityList)
    entityPerformActionModifierNumItems([entityType, user], Msg.MAXIMUM_OF, jcount, peopleEntityType, i, count)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      continue
    Act.Set(Act.UPDATE)
    Ind.Increment()
    for contact in entityList:
      j += 1
      try:
        if not queriedContacts:
          resourceName = contact
          contact = callGAPI(people.people(), 'get',
                             bailOnInternalError=True,
                             throwReasons=[GAPI.NOT_FOUND, GAPI.INTERNAL_ERROR]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                             retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                             resourceName=contact, sources=sources, personFields='emailAddresses,memberships')
        else:
          if action == Act.DEDUP and not localPeopleContactSelects(contactQuery, contact):
            continue
          resourceName = contact['resourceName']
        if action == Act.DEDUP:
          if not dedupPeopleEmailAddressMatches(emailMatchType, contact):
            continue
        else:
          if not replaceDomainPeopleEmailAddressMatches(contactQuery, contact, replaceDomains):
            continue
        Act.Set(Act.UPDATE)
        callGAPI(people.people(), 'updateContact',
                 throwReasons=[GAPI.INVALID_ARGUMENT, GAPI.NOT_FOUND, GAPI.INTERNAL_ERROR]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                 resourceName=resourceName,
                 updatePersonFields='emailAddresses', body=contact)
        entityActionPerformed([entityType, user, peopleEntityType, resourceName], j, jcount)
      except GAPI.invalidArgument as e:
        entityActionFailedWarning([entityType, user, peopleEntityType, resourceName], str(e), j, jcount)
      except (GAPI.notFound, GAPI.internalError):
        entityActionFailedWarning([entityType, user, peopleEntityType, resourceName], Msg.DOES_NOT_EXIST, j, jcount)
      except (GAPI.permissionDenied, GAPI.failedPrecondition) as e:
        ClientAPIAccessDeniedExit(str(e))
      except (GAPI.serviceNotAvailable, GAPI.forbidden):
        ClientAPIAccessDeniedExit()
    Ind.Decrement()

# gam <UserTypeEntity> delete contacts <PeopleResourceNameEntity>|<PeopleUserContactSelection>
def deleteUserPeopleContacts(users):
  entityType = Ent.USER
  peopleEntityType = Ent.PEOPLE_CONTACT
  entityList, resourceNameLists, contactQuery, queriedContacts = _getPeopleContactEntityList(entityType, -1)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    if resourceNameLists:
      entityList = resourceNameLists[user]
    user, people = buildGAPIServiceObject(API.PEOPLE, user, i, count)
    if not people:
      continue
    if contactQuery['contactGroupSelect']:
      groupId, _, contactGroupNames = validatePeopleContactGroup(people, contactQuery['contactGroupSelect'],
                                                                 None, None, entityType, user, i, count)
      if not groupId:
        if contactGroupNames:
          entityActionFailedWarning([entityType, user, Ent.CONTACT_GROUP, contactQuery['contactGroupSelect']], Msg.DOES_NOT_EXIST, i, count)
        continue
      contactQuery['group'] = groupId
    if queriedContacts:
      entityList = queryPeopleContacts(people, contactQuery, 'emailAddresses', None, entityType, user, i, count)
      if entityList is None:
        continue
    j = 0
    jcount = len(entityList)
    entityPerformActionModifierNumItems([entityType, user], Msg.MAXIMUM_OF, jcount, peopleEntityType, i, count)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      continue
    Ind.Increment()
    for contact in entityList:
      j += 1
      if isinstance(contact, dict):
        if not localPeopleContactSelects(contactQuery, contact):
          continue
        resourceName = contact['resourceName']
      else:
        resourceName = normalizePeopleResourceName(contact)
      try:
        callGAPI(people.people(), 'deleteContact',
                 bailOnInternalError=True,
                 throwReasons=[GAPI.NOT_FOUND, GAPI.INTERNAL_ERROR]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                 resourceName=resourceName)
        entityActionPerformed([entityType, user, peopleEntityType, resourceName], j, jcount)
      except (GAPI.notFound, GAPI.internalError):
        entityActionFailedWarning([entityType, user, peopleEntityType, resourceName], Msg.DOES_NOT_EXIST, j, jcount)
      except (GAPI.permissionDenied, GAPI.failedPrecondition) as e:
        ClientAPIAccessDeniedExit(str(e))
      except (GAPI.serviceNotAvailable, GAPI.forbidden):
        ClientAPIAccessDeniedExit()
    Ind.Decrement()

def _initPersonMetadataParameters():
  return {'strip': True, 'mapUpdateTime': False, 'sourceTypes': set()}

def _processPersonMetadata(person, parameters):
  metadata = person.get(PEOPLE_METADATA, None)
  if metadata is not None:
    if parameters['mapUpdateTime']:
      sources = person[PEOPLE_METADATA].get('sources', [])
      if sources and sources[0].get(PEOPLE_UPDATE_TIME, None) is not None:
        person[PEOPLE_UPDATE_TIME] = formatLocalTime(sources[0][PEOPLE_UPDATE_TIME])
  if parameters['sourceTypes']:
    stripKeys = []
    for k, v in iter(person.items()):
      if isinstance(v, list):
        person[k] = []
        for entry in v:
          if isinstance(entry, dict):
            if entry.get('metadata', {}).get('source', {}).get('type', None) in parameters['sourceTypes']:
              person[k].append(entry)
          else:
            person[k].append(entry)
        if not person[k]:
          stripKeys.append(k)
    for k in stripKeys:
      person.pop(k, None)
  if parameters['strip']:
    person.pop(PEOPLE_METADATA, None)
    for _, v in iter(person.items()):
      if isinstance(v, list):
        for entry in v:
          if isinstance(entry, dict):
            entry.pop(PEOPLE_METADATA, None)

def addContactGroupNamesToContacts(contacts, contactGroupIDs, showContactGroupNamesList):
  for contact in contacts:
    if showContactGroupNamesList:
      contact[PEOPLE_GROUPS_LIST] = []
    for membership in contact.get('memberships', []):
      if 'contactGroupMembership' in membership:
        membership['contactGroupMembership']['contactGroupName'] = contactGroupIDs.get(membership['contactGroupMembership']['contactGroupResourceName'], UNKNOWN)
        if showContactGroupNamesList:
          contact[PEOPLE_GROUPS_LIST].append(membership['contactGroupMembership']['contactGroupName'])

def _printPerson(entityTypeName, user, person, csvPF, FJQC, parameters):
  _processPersonMetadata(person, parameters)
  row = flattenJSON(person, flattened={entityTypeName: user})
  if not FJQC.formatJSON:
    csvPF.WriteRowTitles(row)
  elif csvPF.CheckRowTitles(row):
    csvPF.WriteRowNoFilter({entityTypeName: user, 'resourceName': person['resourceName'],
                            'JSON': json.dumps(cleanJSON(person),
                                               ensure_ascii=False, sort_keys=True)})

PEOPLE_CONTACT_OBJECT_KEYS = {
  'addresses': 'type',
  'calendarUrls': 'type',
  'emailAddresses': 'type',
  'events': 'type',
  'externalIds': 'type',
  'genders': 'value',
  'imClients': 'type',
  'locations': 'type',
  'miscKeywords': 'type',
  'nicknames': 'type',
  'organizations': 'type',
  'relations': 'type',
  'urls': 'type',
  'userDefined': 'key',
  }

def _showPerson(userEntityType, user, entityType, person, i, count, FJQC, parameters):
  _processPersonMetadata(person, parameters)
  if not FJQC.formatJSON:
    printEntity([userEntityType, user, entityType, person['resourceName']], i, count)
    Ind.Increment()
    showJSON(None, person, dictObjectsKey=PEOPLE_CONTACT_OBJECT_KEYS)
    Ind.Decrement()
  else:
    printLine(json.dumps(cleanJSON(person), ensure_ascii=False, sort_keys=True))

def _printPersonEntityList(entityType, entityList, userEntityType, user, i, count, csvPF, FJQC, parameters, contactQuery):
  if not csvPF:
    jcount = len(entityList)
    if not FJQC.formatJSON:
      entityPerformActionModifierNumItems([userEntityType, user], Msg.MAXIMUM_OF, jcount, entityType, i, count)
    Ind.Increment()
    j = 0
    for person in entityList:
      j += 1
      if not contactQuery or localPeopleContactSelects(contactQuery, person):
        _showPerson(userEntityType, user, entityType, person, j, jcount, FJQC, parameters)
    Ind.Decrement()
  else:
    entityTypeName = Ent.Singular(userEntityType)
    for person in entityList:
      if not contactQuery or localPeopleContactSelects(contactQuery, person):
        _printPerson(entityTypeName, user, person, csvPF, FJQC, parameters)

PEOPLE_FIELDS_CHOICE_MAP = {
  'additionalname': PEOPLE_NAMES,
  'address': PEOPLE_ADDRESSES,
  'addresses': PEOPLE_ADDRESSES,
  'ageranges': 'ageRanges',
  'billinginfo': PEOPLE_MISC_KEYWORDS,
  'biography': PEOPLE_BIOGRAPHIES,
  'biographies': PEOPLE_BIOGRAPHIES,
  'birthday': PEOPLE_BIRTHDAYS,
  'birthdays': PEOPLE_BIRTHDAYS,
  'calendar': PEOPLE_CALENDAR_URLS,
  'calendars': PEOPLE_CALENDAR_URLS,
  'calendarurls': PEOPLE_CALENDAR_URLS,
  'clientdata': PEOPLE_CLIENT_DATA,
  'coverphotos': PEOPLE_COVER_PHOTOS,
  'directoryserver': PEOPLE_MISC_KEYWORDS,
  'email': PEOPLE_EMAIL_ADDRESSES,
  'emails': PEOPLE_EMAIL_ADDRESSES,
  'emailaddresses': PEOPLE_EMAIL_ADDRESSES,
  'event': PEOPLE_EVENTS,
  'events': PEOPLE_EVENTS,
  'externalid': PEOPLE_EXTERNAL_IDS,
  'externalids': PEOPLE_EXTERNAL_IDS,
  'familyname': PEOPLE_NAMES,
  'fileas': PEOPLE_FILE_ASES,
  'firstname': PEOPLE_NAMES,
  'gender': PEOPLE_GENDERS,
  'genders': PEOPLE_GENDERS,
  'givenname': PEOPLE_NAMES,
  'hobby': PEOPLE_INTERESTS,
  'hobbies': PEOPLE_INTERESTS,
  'im': PEOPLE_IM_CLIENTS,
  'ims': PEOPLE_IM_CLIENTS,
  'imclients': PEOPLE_IM_CLIENTS,
  'initials': PEOPLE_NICKNAMES,
  'interests': PEOPLE_INTERESTS,
  'jot': PEOPLE_MISC_KEYWORDS,
  'jots': PEOPLE_MISC_KEYWORDS,
  'language': PEOPLE_LOCALES,
  'languages': PEOPLE_LOCALES,
  'lastname': PEOPLE_NAMES,
  'locales': PEOPLE_LOCALES,
  'location': PEOPLE_LOCATIONS,
  'locations': PEOPLE_LOCATIONS,
  'maidenname': PEOPLE_NAMES,
  'memberships': PEOPLE_MEMBERSHIPS,
  'metadata': PEOPLE_METADATA,
  'middlename': PEOPLE_NAMES,
  'mileage': PEOPLE_MISC_KEYWORDS,
  'misckeywords': PEOPLE_MISC_KEYWORDS,
  'name': PEOPLE_NAMES,
  'names': PEOPLE_NAMES,
  'nickname': PEOPLE_NICKNAMES,
  'nicknames': PEOPLE_NICKNAMES,
  'note': PEOPLE_BIOGRAPHIES,
  'notes': PEOPLE_BIOGRAPHIES,
  'occupation': PEOPLE_OCCUPATIONS,
  'occupations': PEOPLE_OCCUPATIONS,
  'organization': PEOPLE_ORGANIZATIONS,
  'organizations': PEOPLE_ORGANIZATIONS,
  'organisation': PEOPLE_ORGANIZATIONS,
  'organisations': PEOPLE_ORGANIZATIONS,
  'phone': PEOPLE_PHONE_NUMBERS,
  'phones': PEOPLE_PHONE_NUMBERS,
  'phonenumbers': PEOPLE_PHONE_NUMBERS,
  'photo': PEOPLE_PHOTOS,
  'photos': PEOPLE_PHOTOS,
  'prefix': PEOPLE_NAMES,
  'priority': PEOPLE_MISC_KEYWORDS,
  'relation': PEOPLE_RELATIONS,
  'relations': PEOPLE_RELATIONS,
  'sensitivity': PEOPLE_MISC_KEYWORDS,
  'shortname': PEOPLE_NICKNAMES,
  'sipaddress': PEOPLE_SIP_ADDRESSES,
  'sipaddresses': PEOPLE_SIP_ADDRESSES,
  'skills': PEOPLE_SKILLS,
  'subject': PEOPLE_MISC_KEYWORDS,
  'suffix': PEOPLE_NAMES,
  'updated': PEOPLE_UPDATE_TIME,
  'updatetime': PEOPLE_UPDATE_TIME,
  'urls': PEOPLE_URLS,
  'userdefined': PEOPLE_USER_DEFINED,
  'userdefinedfield': PEOPLE_USER_DEFINED,
  'userdefinedfields': PEOPLE_USER_DEFINED,
  'website': PEOPLE_URLS,
  'websites': PEOPLE_URLS,
  }

PEOPLE_OTHER_CONTACTS_FIELDS_CHOICE_MAP = {
  'email': PEOPLE_EMAIL_ADDRESSES,
  'emails': PEOPLE_EMAIL_ADDRESSES,
  'emailaddresses': PEOPLE_EMAIL_ADDRESSES,
  'metadata': PEOPLE_METADATA,
  'names': PEOPLE_NAMES,
  'phone': PEOPLE_PHONE_NUMBERS,
  'phones': PEOPLE_PHONE_NUMBERS,
  'phonenumbers': PEOPLE_PHONE_NUMBERS,
  'photo': PEOPLE_PHOTOS,
  'photos': PEOPLE_PHOTOS,
  }

PEOPLE_CONTACTS_DEFAULT_FIELDS = ['names', 'emailaddresses', 'phonenumbers']

PEOPLE_ORDERBY_CHOICE_MAP = {
  'firstname': 'FIRST_NAME_ASCENDING',
  'lastname': 'LAST_NAME_ASCENDING',
  'lastmodified': 'LAST_MODIFIED_',
  }

def getPersonFieldsList(myarg, fieldsChoiceMap, fieldsList, initialField=None, fieldsArg='fields'):
  if fieldsList is None:
    fieldsList = []
  return getFieldsList(myarg, fieldsChoiceMap, fieldsList, initialField, fieldsArg)

def _getPersonFields(fieldsChoiceMap, defaultFields, fieldsList, parameters):
  if fieldsList is None:
    fieldsList = []
    for field in fieldsChoiceMap:
      addFieldToFieldsList(field, fieldsChoiceMap, fieldsList)
  elif not fieldsList:
    for field in defaultFields:
      addFieldToFieldsList(field, fieldsChoiceMap, fieldsList)
  fieldsList = list(set(fieldsList))
  if PEOPLE_UPDATE_TIME in fieldsList:
    parameters['mapUpdateTime'] = True
    fieldsList.remove(PEOPLE_UPDATE_TIME)
    fieldsList.append(PEOPLE_METADATA)
  return ','.join(fieldsList)

def _infoPeople(users, entityType, source):
  if entityType == Ent.DOMAIN:
    people = buildGAPIObject(API.PEOPLE)
  peopleEntityType = Ent.DOMAIN_PROFILE if source == 'profile' else Ent.PEOPLE_CONTACT
  sources = [PEOPLE_READ_SOURCES_CHOICE_MAP[source]]
  entityList = getEntityList(Cmd.OB_CONTACT_ENTITY)
  resourceNameLists = entityList if isinstance(entityList, dict) else None
  showContactGroups = False
  FJQC = FormatJSONQuoteChar()
  fieldsList = []
  parameters = _initPersonMetadataParameters()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'allfields':
      fieldsList = None
    elif getPersonFieldsList(myarg, PEOPLE_FIELDS_CHOICE_MAP, fieldsList):
      pass
    elif myarg == 'showgroups':
      showContactGroups = True
    elif myarg == 'showmetadata':
      parameters['strip'] = False
    else:
      FJQC.GetFormatJSON(myarg)
  fields = _getPersonFields(PEOPLE_FIELDS_CHOICE_MAP, PEOPLE_CONTACTS_DEFAULT_FIELDS, fieldsList, parameters)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    if resourceNameLists:
      entityList = resourceNameLists[user]
    contactGroupIDs = contactGroupNames = None
    if entityType != Ent.DOMAIN:
      user, people = buildGAPIServiceObject(API.PEOPLE, user, i, count)
      if not people:
        continue
      if showContactGroups:
        contactGroupIDs, contactGroupNames = getPeopleContactGroupsInfo(people, entityType, user, i, count)
        if contactGroupNames is False:
          continue
    j = 0
    jcount = len(entityList)
    if not FJQC.formatJSON:
      entityPerformActionNumItems([entityType, user], jcount, peopleEntityType, i, count)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      continue
    Ind.Increment()
    for contact in entityList:
      j += 1
      if isinstance(contact, dict):
        resourceName = contact['resourceName']
      else:
        resourceName = normalizePeopleResourceName(contact)
      try:
        result = callGAPI(people.people(), 'get',
                          bailOnInternalError=True,
                          throwReasons=[GAPI.NOT_FOUND, GAPI.INTERNAL_ERROR, GAPI.INVALID_ARGUMENT]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                          retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                          resourceName=resourceName, sources=sources, personFields=fields)
      except (GAPI.notFound, GAPI.internalError):
        entityActionFailedWarning([entityType, user, peopleEntityType, resourceName], Msg.DOES_NOT_EXIST, j, jcount)
        continue
      except GAPI.invalidArgument as e:
        entityActionFailedWarning([entityType, user, peopleEntityType, resourceName], str(e), j, jcount)
        continue
      except (GAPI.permissionDenied, GAPI.failedPrecondition) as e:
        ClientAPIAccessDeniedExit(str(e))
      except (GAPI.serviceNotAvailable, GAPI.forbidden):
        ClientAPIAccessDeniedExit()
      if showContactGroups and contactGroupIDs:
        addContactGroupNamesToContacts([result], contactGroupIDs, False)
      _showPerson(entityType, user, peopleEntityType, result, j, jcount, FJQC, parameters)
    Ind.Decrement()

# gam <UserTypeEntity> info contacts <PeopleResourceNameEntity>
#	[showgroups]
#	[allFields|(fields <PeopleFieldNameList>)] [showmetadata]
#	[formatjson]
def infoUserPeopleContacts(users):
  _infoPeople(users, Ent.USER, 'contact')

# gam <UserTypeEntity> print contacts [todrive <ToDriveAttribute>*] <PeoplePrintShowUserContactSelection>
#	[showgroups|showgroupnameslist] [orderby firstname|lastname|(lastmodified ascending)|(lastnodified descending)
#	[countsonly|allfields|fields <PeopleFieldNameList>] [showmetadata]
#	[formatjson [quotechar <Character>]]
# gam <UserTypeEntity> show contacts <PeoplePrintShowUserContactSelection>
#	[showgroups] [orderby firstname|lastname|(lastmodified ascending)|(lastnodified descending)
#	[countsonly|allfields|(fields <PeopleFieldNameList>)] [showmetadata]
#	[formatjson]
def printShowUserPeopleContacts(users):
  entityType = Ent.USER
  entityTypeName = Ent.Singular(entityType)
  csvPF = CSVPrintFile([entityTypeName, 'resourceName'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  CSVTitle = 'People Contacts'
  fieldsList = []
  parameters = _initPersonMetadataParameters()
  sortOrder = None
  countsOnly = showContactGroups = showContactGroupNamesList = False
  contactQuery = _initPeopleContactQueryAttributes(True)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'showgroups':
      showContactGroups = True
    elif myarg == 'showgroupnameslist':
      showContactGroups = showContactGroupNamesList = True
    elif myarg == 'allfields':
      fieldsList = None
    elif getPersonFieldsList(myarg, PEOPLE_FIELDS_CHOICE_MAP, fieldsList):
      pass
    elif myarg == 'countsonly':
      countsOnly = True
      if csvPF:
        csvPF.SetTitles([entityTypeName, CSVTitle])
    elif myarg == 'showmetadata':
      parameters['strip'] = False
    elif myarg == 'orderby':
      sortOrder = getChoice(PEOPLE_ORDERBY_CHOICE_MAP, mapChoice=True)
      if sortOrder == 'LAST_MODIFIED_':
        sortOrder += getChoice(SORTORDER_CHOICE_MAP, defaultChoice='DESCENDING', mapChoice=True)
    elif _getPeopleContactQueryAttributes(contactQuery, myarg, entityType, 0, True):
      pass
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if countsOnly:
    fieldsList = ['emailAddresses']
  if contactQuery['mainContacts']:
    fields = _getPersonFields(PEOPLE_FIELDS_CHOICE_MAP, PEOPLE_CONTACTS_DEFAULT_FIELDS, fieldsList, parameters)
    if contactQuery['contactGroupFilter'] and 'memberships' not in fields:
      fields += ',memberships'
      contactQuery['dropMemberships'] = True
  if contactQuery['otherContacts']:
    if not fieldsList:
      ofields = _getPersonFields(PEOPLE_OTHER_CONTACTS_FIELDS_CHOICE_MAP, PEOPLE_CONTACTS_DEFAULT_FIELDS, fieldsList, parameters)
    else:
      ofields = getFieldsFromFieldsList([PEOPLE_OTHER_CONTACTS_FIELDS_CHOICE_MAP[field.lower()] for field in fieldsList if field.lower() in PEOPLE_OTHER_CONTACTS_FIELDS_CHOICE_MAP])
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, people = buildGAPIServiceObject(API.PEOPLE, user, i, count)
    if not people:
      continue
    if contactQuery['otherContacts']:
      _, opeople = buildGAPIServiceObject(API.PEOPLE_OTHERCONTACTS, user, i, count)
      if not opeople:
        continue
    contactGroupIDs = contactGroupNames = None
    if showContactGroups:
      contactGroupIDs, contactGroupNames = getPeopleContactGroupsInfo(people, entityType, user, i, count)
      if contactGroupNames is False:
        continue
    contactGroupSelectFilter = contactQuery['contactGroupSelect'] or contactQuery['contactGroupFilter']
    if contactGroupSelectFilter:
      groupId, _, contactGroupNames =\
        validatePeopleContactGroup(people, contactGroupSelectFilter,
                                   contactGroupIDs, contactGroupNames, entityType, user, i, count)
      if not groupId:
        if contactGroupNames:
          entityActionFailedWarning([entityType, user, Ent.CONTACT_GROUP, contactGroupSelectFilter], Msg.DOES_NOT_EXIST, i, count)
        continue
      contactQuery['group'] = groupId
    if contactQuery['mainContacts']:
      contacts = queryPeopleContacts(people, contactQuery, fields, sortOrder, entityType, user, i, count)
    else:
      contacts = []
    if contactQuery['otherContacts']:
      ocontacts = queryPeopleOtherContacts(opeople, contactQuery, ofields, entityType, user, i, count)
    else:
      ocontacts = []
    if countsOnly:
      jcount = countLocalPeopleContactSelects(contactQuery, contacts)+countLocalPeopleContactSelects(contactQuery, ocontacts)
      if csvPF:
        csvPF.WriteRowTitles({entityTypeName: user, CSVTitle: jcount})
      else:
        printEntityKVList([entityType, user], [CSVTitle, jcount], i, count)
    elif contacts is not None or ocontacts is not None:
      if not csvPF:
        if contacts is not None and contactQuery['mainContacts']:
          if showContactGroups and contactGroupIDs:
            addContactGroupNamesToContacts(contacts, contactGroupIDs, False)
          _printPersonEntityList(Ent.PEOPLE_CONTACT, contacts, entityType, user, i, count, csvPF, FJQC, parameters, contactQuery)
        if ocontacts is not None and contactQuery['otherContacts']:
          _printPersonEntityList(Ent.OTHER_CONTACT, ocontacts, entityType, user, i, count, csvPF, FJQC, parameters, contactQuery)
      elif contacts or ocontacts:
        if contacts:
          if showContactGroups and contactGroupIDs:
            addContactGroupNamesToContacts(contacts, contactGroupIDs, showContactGroupNamesList and FJQC.formatJSON)
          _printPersonEntityList(Ent.PEOPLE_CONTACT, contacts, entityType, user, i, count, csvPF, FJQC, parameters, contactQuery)
        if ocontacts:
          _printPersonEntityList(Ent.OTHER_CONTACT, ocontacts, entityType, user, i, count, csvPF, FJQC, parameters, contactQuery)
      elif GC.Values[GC.CSV_OUTPUT_USERS_AUDIT]:
        csvPF.WriteRowNoFilter({Ent.Singular(entityType): user})
  if csvPF:
    csvPF.writeCSVfile(CSVTitle)

CONTACTGROUPS_MYCONTACTS_ID = 'contactGroups/myContacts'
CONTACTGROUPS_MYCONTACTS_NAME = 'My Contacts'

# gam <UserTypeEntity> copy othercontacts
#	<OtherContactResourceNameEntity>|<OtherContactSelection>
def copyUserPeopleOtherContacts(users):
  entityType = Ent.USER
  peopleEntityType = Ent.OTHER_CONTACT
  sources = [PEOPLE_READ_SOURCES_CHOICE_MAP['contact']]
  copyMask = ['emailAddresses', 'names', 'phoneNumbers']
  entityList, resourceNameLists, contactQuery, queriedContacts = _getPeopleOtherContactEntityList(-1)
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    if resourceNameLists:
      entityList = resourceNameLists[user]
    user, people = buildGAPIServiceObject(API.PEOPLE_OTHERCONTACTS, user, i, count)
    if not people:
      continue
    if queriedContacts:
      entityList = queryPeopleOtherContacts(people, contactQuery, 'emailAddresses', entityType, user, i, count)
      if entityList is None:
        continue
    j = 0
    jcount = len(entityList)
    entityPerformActionModifierNumItems([entityType, user], Msg.MAXIMUM_OF, jcount, peopleEntityType, i, count)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      continue
    Ind.Increment()
    for contact in entityList:
      j += 1
      if isinstance(contact, dict):
        if not localPeopleContactSelects(contactQuery, contact):
          continue
        resourceName = contact['resourceName']
      else:
        resourceName = normalizeOtherContactsResourceName(contact)
      try:
        callGAPI(people.otherContacts(), 'copyOtherContactToMyContactsGroup',
                 bailOnInternalError=True,
                 throwReasons=[GAPI.NOT_FOUND, GAPI.INTERNAL_ERROR]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                 resourceName=resourceName, body={'copyMask': ','.join(copyMask), 'sources': sources})
        entityModifierNewValueActionPerformed([entityType, user, peopleEntityType, resourceName], Act.MODIFIER_TO, CONTACTGROUPS_MYCONTACTS_NAME)
      except (GAPI.notFound, GAPI.internalError):
        entityActionFailedWarning([entityType, user, peopleEntityType, resourceName], Msg.DOES_NOT_EXIST, j, jcount)
        continue
      except GAPI.permissionDenied as e:
        ClientAPIAccessDeniedExit(str(e))
      except (GAPI.serviceNotAvailable, GAPI.forbidden):
        ClientAPIAccessDeniedExit()
    Ind.Decrement()

# gam <UserTypeEntity> delete othercontacts
#	<OtherContactResourceNameEntity>|<OtherContactSelection>
# gam <UserTypeEntity> move othercontacts
#	<OtherContactResourceNameEntity>|<OtherContactSelection>
# gam <UserTypeEntity> update othercontacts
#	<OtherResourceNameEntity>|<OtherContactSelection>
#	<PeopleContactAttribute>*
#	(contactgroup <ContactGroupItem>)*
def processUserPeopleOtherContacts(users):
  action = Act.Get()
  entityType = Ent.USER
  peopleEntityType = Ent.OTHER_CONTACT
  sources = PEOPLE_READ_SOURCES_CHOICE_MAP['contact']
  entityList, resourceNameLists, contactQuery, queriedContacts = _getPeopleOtherContactEntityList(1)
  if action == Act.UPDATE:
    Act.Set(Act.UPDATE_MOVE)
    peopleManager = PeopleManager()
    body, updatePersonFields, contactGroupsLists = peopleManager.GetPersonFields(entityType, False)
  else:
    body = {PEOPLE_MEMBERSHIPS: [{'contactGroupMembership': {'contactGroupResourceName': CONTACTGROUPS_MYCONTACTS_ID}}]}
    updatePersonFields = [PEOPLE_MEMBERSHIPS]
    checkForExtraneousArguments()
  validatedContactGroupsList = [CONTACTGROUPS_MYCONTACTS_ID]
  contactGroupIDs = {CONTACTGROUPS_MYCONTACTS_ID: CONTACTGROUPS_MYCONTACTS_NAME}
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    if resourceNameLists:
      entityList = resourceNameLists[user]
    user, people = buildGAPIServiceObject(API.PEOPLE_OTHERCONTACTS, user, i, count)
    if not people:
      continue
    _, upeople = buildGAPIServiceObject(API.PEOPLE, user, i, count)
    if not upeople:
      continue
    if queriedContacts:
      entityList = queryPeopleOtherContacts(people, contactQuery, 'emailAddresses', entityType, user, i, count)
      if entityList is None:
        continue
    else:
      otherContacts = _getPeopleOtherContacts(people, entityType, user, i=0, count=0)
      if otherContacts is None:
        continue
    if action == Act.UPDATE:
      if contactGroupsLists[PEOPLE_GROUPS_LIST]:
        result, validatedContactGroupsList, contactGroupIDs =\
          validatePeopleContactGroupsList(people, '', contactGroupsLists[PEOPLE_GROUPS_LIST], entityType, user, i, count)
        if not result:
          continue
      if CONTACTGROUPS_MYCONTACTS_ID not in validatedContactGroupsList:
        validatedContactGroupsList.insert(0, CONTACTGROUPS_MYCONTACTS_ID)
      updatePersonFields.add(PEOPLE_MEMBERSHIPS)
    contactGroupNamesList = []
    for resourceName in validatedContactGroupsList:
      contactGroupNamesList.append(contactGroupIDs[resourceName])
    contactGroupNames = ','.join(contactGroupNamesList)
    j = 0
    jcount = len(entityList)
    entityPerformActionModifierNumItems([entityType, user], Msg.MAXIMUM_OF, jcount, peopleEntityType, i, count)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      continue
    Ind.Increment()
    for contact in entityList:
      j += 1
      if isinstance(contact, dict):
        if not localPeopleContactSelects(contactQuery, contact):
          continue
        resourceName = contact['resourceName']
      else:
        resourceName = normalizeOtherContactsResourceName(contact)
        contact = otherContacts.get(resourceName)
        if contact is None:
          entityActionFailedWarning([entityType, user, peopleEntityType, resourceName], Msg.DOES_NOT_EXIST, j, jcount)
          continue
      peopleResourceName = resourceName.replace('otherContacts', 'people')
      body['etag'] = contact['etag']
      if action == Act.UPDATE and validatedContactGroupsList:
        peopleManager.AddContactGroupsToContact(body, validatedContactGroupsList)
      try:
        callGAPI(upeople.people(), 'updateContact',
                 throwReasons=[GAPI.INVALID_ARGUMENT, GAPI.NOT_FOUND, GAPI.INTERNAL_ERROR]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                 resourceName=peopleResourceName,
                 updatePersonFields=','.join(updatePersonFields), body=body, sources=sources)
        if action != Act.DELETE:
          entityModifierNewValueActionPerformed([entityType, user, peopleEntityType, resourceName],
                                                Act.MODIFIER_TO, contactGroupNames, j, jcount)
        else:
          maxRetries = 5
          for retry in range(1, maxRetries+1):
            try:
              callGAPI(upeople.people(), 'deleteContact',
                       bailOnInternalError=True,
                       throwReasons=[GAPI.NOT_FOUND, GAPI.INTERNAL_ERROR]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                       retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                       resourceName=peopleResourceName)
              entityActionPerformed([entityType, user, peopleEntityType, resourceName], j, jcount)
              break
            except (GAPI.notFound, GAPI.internalError):
              if retry == maxRetries:
                entityActionFailedWarning([entityType, user, peopleEntityType, resourceName], Msg.DOES_NOT_EXIST, j, jcount)
                break
              time.sleep(retry*2)
      except GAPI.invalidArgument as e:
        entityActionFailedWarning([entityType, user, peopleEntityType, resourceName], str(e), j, jcount)
        continue
      except (GAPI.notFound, GAPI.internalError):
        entityActionFailedWarning([entityType, user, peopleEntityType, resourceName], Msg.DOES_NOT_EXIST, j, jcount)
        continue
      except GAPI.permissionDenied as e:
        ClientAPIAccessDeniedExit(str(e))
      except (GAPI.serviceNotAvailable, GAPI.forbidden):
        ClientAPIAccessDeniedExit()
    Ind.Decrement()

# gam <UserTypeEntity> print othercontacts [todrive <ToDriveAttribute>*] <OtherContactSelection>
#	[countsonly|allfields|(fields <OtherContactFieldNameList>)] [showmetadata]
#	[formatjson [quotechar <Character>]]
# gam <UserTypeEntity> show othercontacts <OtherContactSelection>
#	[countsonly|allfields|(fields <OtherContactFieldNameList>)] [showmetadata]
#	[formatjson]
def printShowUserPeopleOtherContacts(users):
  entityType = Ent.USER
  entityTypeName = Ent.Singular(entityType)
  csvPF = CSVPrintFile([entityTypeName, 'resourceName'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  CSVTitle = 'Other Contacts'
  fieldsList = []
  parameters = _initPersonMetadataParameters()
  countsOnly = False
  contactQuery = _initPeopleOtherContactQueryAttributes()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'allfields':
      fieldsList = None
    elif getPersonFieldsList(myarg, PEOPLE_OTHER_CONTACTS_FIELDS_CHOICE_MAP, fieldsList):
      pass
    elif myarg == 'countsonly':
      countsOnly = True
      if csvPF:
        csvPF.SetTitles([entityTypeName, CSVTitle])
    elif myarg == 'showmetadata':
      parameters['strip'] = False
    elif _getPeopleOtherContactQueryAttributes(contactQuery, myarg, 0):
      pass
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if countsOnly:
    fieldsList = ['emailAddresses']
  fields = _getPersonFields(PEOPLE_OTHER_CONTACTS_FIELDS_CHOICE_MAP, PEOPLE_CONTACTS_DEFAULT_FIELDS, fieldsList, parameters)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, people = buildGAPIServiceObject(API.PEOPLE_OTHERCONTACTS, user, i, count)
    if not people:
      continue
    contacts = queryPeopleOtherContacts(people, contactQuery, fields, entityType, user, i, count)
    if countsOnly:
      jcount = countLocalPeopleContactSelects(contactQuery, contacts)
      if csvPF:
        csvPF.WriteRowTitles({entityTypeName: user, CSVTitle: jcount})
      else:
        printEntityKVList([entityType, user], [CSVTitle, jcount], i, count)
    elif contacts is not None:
      if not csvPF:
        _printPersonEntityList(Ent.OTHER_CONTACT, contacts, entityType, user, i, count, csvPF, FJQC, parameters, contactQuery)
      elif contacts:
        _printPersonEntityList(Ent.OTHER_CONTACT, contacts, entityType, user, i, count, csvPF, FJQC, parameters, contactQuery)
      elif GC.Values[GC.CSV_OUTPUT_USERS_AUDIT]:
        csvPF.WriteRowNoFilter({Ent.Singular(entityType): user})
  if csvPF:
    csvPF.writeCSVfile(CSVTitle)

def _printShowPeople(source):
  people = buildGAPIObject(API.PEOPLE_DIRECTORY)
  entityType = Ent.DOMAIN
  entityTypeName = Ent.Singular(entityType)
  sources = [PEOPLE_DIRECTORY_SOURCES_CHOICE_MAP[source]]
  if sources[0] == 'DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE':
    peopleEntityType = Ent.DOMAIN_PROFILE
    CSVTitle = 'People Profiles'
  else:
    peopleEntityType = Ent.DOMAIN_PEOPLE_CONTACT
    CSVTitle = 'People Contacts'
  function = 'listDirectoryPeople'
  csvPF = CSVPrintFile([entityTypeName, 'resourceName'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  parameters = _initPersonMetadataParameters()
  mergeSources = []
  fieldsList = []
  parameters = _initPersonMetadataParameters()
  countsOnly = False
  kwargs = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif source is None and myarg in {'source', 'sources'}:
      sources = [getChoice(PEOPLE_DIRECTORY_SOURCES_CHOICE_MAP, mapChoice=True)]
    elif myarg in {'mergesource', 'mergesources'}:
      mergeSources = [getChoice(PEOPLE_DIRECTORY_MERGE_SOURCES_CHOICE_MAP, mapChoice=True)]
    elif myarg == 'showmetadata':
      parameters['strip'] = False
    elif myarg == 'allfields':
      fieldsList = None
    elif getPersonFieldsList(myarg, PEOPLE_FIELDS_CHOICE_MAP, fieldsList):
      pass
    elif myarg == 'countsonly':
      countsOnly = True
      fieldsList = [PEOPLE_METADATA]
      if csvPF:
        csvPF.SetTitles([entityTypeName, CSVTitle])
    elif myarg == 'query':
      kwargs['query'] = getString(Cmd.OB_QUERY)
      function = 'searchDirectoryPeople'
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  fields = _getPersonFields(PEOPLE_FIELDS_CHOICE_MAP, PEOPLE_CONTACTS_DEFAULT_FIELDS, fieldsList, parameters)
  printGettingAllEntityItemsForWhom(peopleEntityType, GC.Values[GC.DOMAIN], query=kwargs.get('query'))
  try:
    entityList = callGAPIpages(people.people(), function, 'people',
                               pageMessage=getPageMessageForWhom(),
                               throwReasons=GAPI.PEOPLE_ACCESS_THROW_REASONS,
                               pageSize=GC.Values[GC.PEOPLE_MAX_RESULTS],
                               sources=sources, mergeSources=mergeSources,
                               readMask=fields, fields='nextPageToken,people', **kwargs)
  except (GAPI.permissionDenied, GAPI.failedPrecondition) as e:
    ClientAPIAccessDeniedExit(str(e))
  except (GAPI.serviceNotAvailable, GAPI.forbidden):
    ClientAPIAccessDeniedExit()
  if not countsOnly:
    _printPersonEntityList(peopleEntityType, entityList, Ent.DOMAIN, GC.Values[GC.DOMAIN], 0, 0, csvPF, FJQC, parameters, None)
  else:
    jcount = len(entityList)
    if csvPF:
      csvPF.WriteRowTitles({entityTypeName: GC.Values[GC.DOMAIN], CSVTitle: jcount})
    else:
      printEntityKVList([entityType, GC.Values[GC.DOMAIN]], [CSVTitle, jcount])
  if csvPF:
    csvPF.writeCSVfile(CSVTitle)

# gam info people|peopleprofile <PeopleResourceNameEntity>
#	[allfields|(fields <PeopleFieldNameList>)] [showmetadata]
#	[formatjson]
def doInfoDomainPeopleProfile():
  _infoPeople([GC.Values[GC.DOMAIN]], Ent.DOMAIN, 'profile')

# gam info peoplecontact <PeopleResourceNameEntity>
#	[allfields|(fields <PeopleFieldNameList>)] [showmetadata]
#	[formatjson]
def doInfoDomainPeopleContacts():
  _infoPeople([GC.Values[GC.DOMAIN]], Ent.DOMAIN, 'domaincontact')

# gam print people|peopleprofile [todrive <ToDriveAttribute>*]
#	[query <String>]
#	[mergesources <PeopleMergeSourceName>]
#	[countsonly]
#	[allfields|(fields <PeopleFieldNameList>)] [showmetadata]
#	[formatjson [quotechar <Character>]]
# gam show people|peopleprofile
#	[query <String>]
#	[mergesources <PeopleMergeSourceName>]
#	[countsonly]
#	[allfields|(fields <PeopleFieldNameList>)] [showmetadata]
#	[formatjson]
# gam print domaincontacts|peoplecontacts [todrive <ToDriveAttribute>*]
#	[sources <PeopleSourceName>]
#	[query <String>]
#	[mergesources <PeopleMergeSourceName>]
#	[countsonly]
#	[allfields|(fields <PeopleFieldNameList>)] [showmetadata]
#	[formatjson [quotechar <Character>]]
# gam show domaincontacts|peoplecontacts
#	[sources <PeopleSourceName>]
#	[query <String>]
#	[mergesources <PeopleMergeSourceName>]
#	[countsonly]
#	[allfields|(fields <PeopleFieldNameList>)] [showmetadata]
#	[formatjson]
def doPrintShowDomainPeopleProfiles():
  _printShowPeople('profile')

def doPrintShowDomainPeopleContacts():
  _printShowPeople('domaincontact')

PEOPLE_PROFILE_SOURCETYPE_CHOICE_MAP = {
  'account': 'ACCOUNT',
  'accounts': 'ACCOUNT',
  'domain': 'DOMAIN_PROFILE',
  'domains': 'DOMAIN_PROFILE',
  'profile': 'PROFILE',
  'profiles': 'PROFILE',
  }

# gam <UserTypeEntity> print peopleprofile [todrive <ToDriveAttribute>*]
#	[allfields|(fields <PeopleFieldNameList>)] [showmetadata]
#	[sources <PeopleProfileSourceNameList>]
#	[formatjson [quotechar <Character>]]
# gam <UserTypeEntity> show peopleprofile
#	[allfields|(fields <PeopleFieldNameList>)] [showmetadata]
#	[sources <PeopleProfileSourceNameList>]
#	[formatjson]
def printShowUserPeopleProfiles(users):
  entityType = Ent.USER
  entityTypeName = Ent.Singular(entityType)
  sources = [PEOPLE_READ_SOURCES_CHOICE_MAP['profile']]
  csvPF = CSVPrintFile([entityTypeName, 'resourceName'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  fieldsList = []
  parameters = _initPersonMetadataParameters()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'allfields':
      fieldsList = None
    elif getPersonFieldsList(myarg, PEOPLE_FIELDS_CHOICE_MAP, fieldsList):
      pass
    elif myarg in {'source', 'sources'}:
      for field in _getFieldsList():
        if field in PEOPLE_PROFILE_SOURCETYPE_CHOICE_MAP:
          parameters['sourceTypes'].add(PEOPLE_PROFILE_SOURCETYPE_CHOICE_MAP[field])
        else:
          invalidChoiceExit(field, PEOPLE_PROFILE_SOURCETYPE_CHOICE_MAP, True)
    elif myarg == 'showmetadata':
      parameters['strip'] = False
    elif myarg == 'peoplelookupuser':
      deprecatedArgument(myarg)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  fields = _getPersonFields(PEOPLE_FIELDS_CHOICE_MAP, PEOPLE_CONTACTS_DEFAULT_FIELDS, fieldsList, parameters)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
#    user, people = buildGAPIServiceObject(API.PEOPLE_DIRECTORY, user, i, count)
    user, people = buildGAPIServiceObject(API.PEOPLE, user, i, count)
    if not people:
      continue
    if csvPF:
      printGettingEntityItemForWhom(Ent.PEOPLE_PROFILE, user, i, count)
    try:
      result = callGAPI(people.people(), 'get',
                        throwReasons=[GAPI.NOT_FOUND]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                        retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                        resourceName='people/me', sources=sources, personFields=fields)
    except GAPI.notFound:
      entityUnknownWarning(Ent.PEOPLE_PROFILE, user, i, count)
      continue
    except (GAPI.permissionDenied, GAPI.failedPrecondition) as e:
      ClientAPIAccessDeniedExit(str(e))
    except (GAPI.serviceNotAvailable, GAPI.forbidden):
      ClientAPIAccessDeniedExit()
    if not csvPF:
      _showPerson(entityType, user, Ent.PEOPLE_PROFILE, result, i, count, FJQC, parameters)
    else:
      _printPerson(entityTypeName, user, result, csvPF, FJQC, parameters)
  if csvPF:
    csvPF.writeCSVfile('People Profiles')

def _processPeopleContactPhotos(users, function):
  def _makeFilenameFromPattern(resourceName):
    filename = filenamePattern[:]
    if subForContactId:
      filename = filename.replace('#contactid#', resourceName.split('/')[1])
    if subForEmail:
      for email in result.get(PEOPLE_EMAIL_ADDRESSES, []):
        if email.get(PEOPLE_METADATA, {}).get('primary', False):
          filename = filename.replace('#email#', email['value'])
          break
      else:
        filename = filename.replace('#email#', resourceName.split('/')[1])
    return filename

  if users is not None:
    entityType = Ent.USER
    peopleEntityType = Ent.PEOPLE_CONTACT
    sources = [PEOPLE_READ_SOURCES_CHOICE_MAP['contact']]
  else:
    users = [None]
    people = buildGAPIObject(API.PEOPLE_DIRECTORY)
    entityType = Ent.DOMAIN
    peopleEntityType = Ent.PEOPLE_CONTACT
    sources = [PEOPLE_READ_SOURCES_CHOICE_MAP['domaincontact']]
  entityList, resourceNameLists, contactQuery, queriedContacts = _getPeopleContactEntityList(entityType, 1)
  if function in {'updateContactPhoto', 'getContactPhoto'}:
    targetFolder = os.getcwd()
    filenamePattern = '#contactid#.jpg'
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg == 'drivedir':
        targetFolder = GC.Values[GC.DRIVE_DIR]
      elif myarg in {'sourcefolder', 'targetfolder'}:
        targetFolder = os.path.expanduser(getString(Cmd.OB_FILE_PATH))
        if function == 'GetContactPhoto' and not os.path.isdir(targetFolder):
          os.makedirs(targetFolder)
      elif myarg == 'filename':
        filenamePattern = getString(Cmd.OB_PHOTO_FILENAME_PATTERN)
      else:
        unknownArgumentExit()
    subForContactId = filenamePattern.find('#contactid#') != -1
    subForEmail = filenamePattern.find('#email#') != -1
    if not subForContactId and not subForEmail:
      filename = filenamePattern
  else: #elif function == 'deleteContactPhoto':
    checkForExtraneousArguments()
    subForContactId = subForEmail = False
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    if resourceNameLists:
      entityList = resourceNameLists[user]
    if user is not None:
      user, people = buildGAPIServiceObject(API.PEOPLE, user, i, count)
      if not people:
        continue
    else:
      user = Ent.Singular(entityType)
    if contactQuery['contactGroupSelect']:
      groupId, _, contactGroupNames = validatePeopleContactGroup(people, contactQuery['contactGroupSelect'],
                                                                 None, None, entityType, user, i, count)
      if not groupId:
        if contactGroupNames:
          entityActionFailedWarning([entityType, user, Ent.CONTACT_GROUP, contactQuery['contactGroupSelect']], Msg.DOES_NOT_EXIST, i, count)
        continue
      contactQuery['group'] = groupId
    if queriedContacts:
      entityList = queryPeopleContacts(people, contactQuery, 'emailAddresses,photos', None, entityType, user, i, count)
      if entityList is None:
        continue
    j = 0
    jcount = len(entityList)
    entityPerformActionModifierNumItems([entityType, user], Msg.MAXIMUM_OF, jcount, Ent.PHOTO, i, count)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      continue
    Ind.Increment()
    for contact in entityList:
      j += 1
      if isinstance(contact, dict):
        if not localPeopleContactSelects(contactQuery, contact):
          continue
        resourceName = contact['resourceName']
      else:
        resourceName = normalizePeopleResourceName(contact)
      try:
        if subForEmail:
          result = callGAPI(people.people(), 'get',
                            bailOnInternalError=True,
                            throwReasons=[GAPI.NOT_FOUND, GAPI.INTERNAL_ERROR]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                            resourceName=resourceName, sources=sources, personFields='emailAddresses')
        if function == 'updateContactPhoto':
          if subForContactId or subForEmail:
            filename = _makeFilenameFromPattern(resourceName)
          filename = os.path.join(targetFolder, filename)
          with open(os.path.expanduser(filename), 'rb') as f:
            image_data = f.read()
          callGAPI(people.people(), function,
                   throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.INTERNAL_ERROR]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                   resourceName=resourceName,
                   body={'photoBytes': base64.urlsafe_b64encode(image_data).decode(UTF8)})
          entityActionPerformed([entityType, user, peopleEntityType, resourceName, Ent.PHOTO, filename], j, jcount)
        elif function == 'getContactPhoto':
          if subForContactId or subForEmail:
            filename = _makeFilenameFromPattern(resourceName)
          filename = os.path.join(targetFolder, filename)
          result = callGAPI(people.people(), 'get',
                            throwReasons=[GAPI.NOT_FOUND, GAPI.INTERNAL_ERROR]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                            resourceName=resourceName, personFields='photos')
          url = None
          for photo in result.get('photos', []):
            if photo['metadata']['source']['type'] == 'CONTACT':
              url = photo['url']
              break
          if not url:
            entityActionFailedWarning([entityType, user, peopleEntityType, resourceName, Ent.PHOTO, None], Msg.CONTACT_PHOTO_NOT_FOUND, j, jcount)
            continue
          try:
            status, photo_data = getHttpObj().request(url, 'GET')
            if status['status'] != '200':
              entityActionFailedWarning([entityType, user, Ent.PHOTO, filename], Msg.NOT_ALLOWED, j, jcount)
              continue
          except (httplib2.HttpLib2Error, google.auth.exceptions.TransportError, RuntimeError) as e:
            entityActionFailedWarning([entityType, user, peopleEntityType, resourceName, Ent.PHOTO, filename], str(e), j, jcount)
            continue
          status, e = writeFileReturnError(filename, photo_data, mode='wb')
          if status:
            entityActionPerformed([entityType, user, peopleEntityType, resourceName, Ent.PHOTO, filename], j, jcount)
          else:
            entityActionFailedWarning([entityType, user, peopleEntityType, resourceName, Ent.PHOTO, filename], str(e), j, jcount)
        else: #elif function == 'deleteContactPhoto':
          filename = ''
          callGAPI(people.people(), function,
                   throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.INTERNAL_ERROR, GAPI.PHOTO_NOT_FOUND]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                   resourceName=resourceName)
          entityActionPerformed([entityType, user, peopleEntityType, resourceName, Ent.PHOTO, filename], j, jcount)
      except (GAPI.notFound, GAPI.internalError):
        entityActionFailedWarning([entityType, user, peopleEntityType, resourceName], Msg.DOES_NOT_EXIST, j, jcount)
        continue
      except GAPI.photoNotFound:
        entityDoesNotHaveItemWarning([entityType, user, peopleEntityType, resourceName, Ent.PHOTO, filename], j, jcount)
      except (GAPI.invalidArgument, OSError, IOError) as e:
        entityActionFailedWarning([entityType, user, peopleEntityType, resourceName, Ent.PHOTO, filename], str(e), j, jcount)
      except (GAPI.permissionDenied, GAPI.failedPrecondition) as e:
        ClientAPIAccessDeniedExit(str(e))
      except (GAPI.serviceNotAvailable, GAPI.forbidden):
        ClientAPIAccessDeniedExit()
        break
    Ind.Decrement()

# gam <UserTypeEntity> update contactphotos <PeopleResourceNameEntity>|<PeopleUserContactSelection>
#	[drivedir|(sourcefolder <FilePath>)] [filename <FileNamePattern>]
def updateUserPeopleContactPhoto(users):
  _processPeopleContactPhotos(users, 'updateContactPhoto')

# gam <UserTypeEntity> get contactphotos <PeopleResourceNameEntity>|<PeopleUserContactSelection>
#	[drivedir|(targetfolder <FilePath>)] [filename <FileNamePattern>]
def getUserPeopleContactPhoto(users):
  _processPeopleContactPhotos(users, 'getContactPhoto')

# gam <UserTypeEntity> delete contactphotos <PeopleResourceNameEntity>|<PeopleUserContactSelection>
def deleteUserPeopleContactPhoto(users):
  _processPeopleContactPhotos(users, 'deleteContactPhoto')

# gam update contactphotos <PeopleResourceNameEntity>|<PeopleUserContactSelection>
#	[drivedir|(sourcefolder <FilePath>)] [filename <FileNamePattern>]
def doUpdateDomainContactPhoto():
  _processPeopleContactPhotos(None, 'updateContactPhoto')

# gam get contactphotos <PeopleResourceNameEntity>|<PeopleUserContactSelection>
#	[drivedir|(targetfolder <FilePath>)] [filename <FileNamePattern>]
def doGetDomainContactPhoto():
  _processPeopleContactPhotos(None, 'getContactPhoto')

# gam delete contactphoto <PeopleResourceNameEntity>|<PeopleUserContactSelection>
def doDeleteDomainContactPhoto():
  _processPeopleContactPhotos(None, 'deleteContactPhoto')

# gam <UserTypeEntity> create contactgroup <ContactGroupAttribute>+
#	[(csv [todrive <ToDriveAttribute>*] (addcsvdata <FieldName> <String>)*))| returnidonly]
def createUserPeopleContactGroup(users):
  peopleManager = PeopleManager()
  entityType = Ent.USER
  parameters = {'csvPF': None, 'titles': ['User', 'resourceName'], 'addCSVData': {}, 'returnIdOnly': False}
  body, _ = peopleManager.GetContactGroupFields(parameters)
  if PEOPLE_GROUP_NAME not in body:
    return
  csvPF = parameters['csvPF']
  addCSVData = parameters['addCSVData']
  if addCSVData:
    csvPF.AddTitles(sorted(addCSVData.keys()))
  returnIdOnly = parameters['returnIdOnly']
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, people = buildGAPIServiceObject(API.PEOPLE, user, i, count)
    if not people:
      continue
    _, contactGroupNames = getPeopleContactGroupsInfo(people, entityType, user, i, count)
    if contactGroupNames is False:
      continue
    contactGroup = body[PEOPLE_GROUP_NAME]
    if contactGroup in contactGroupNames:
      entityActionFailedWarning([entityType, user], Ent.TypeNameMessage(Ent.CONTACT_GROUP, contactGroup, Msg.DUPLICATE), i, count)
      continue
    try:
      result = callGAPI(people.contactGroups(), 'create',
                        throwReasons=GAPI.PEOPLE_ACCESS_THROW_REASONS,
                        body={'contactGroup': body}, fields='resourceName')
      resourceName = result['resourceName']
      if returnIdOnly:
        writeStdout(f'{resourceName}\n')
      elif not csvPF:
        entityActionPerformed([entityType, user, Ent.CONTACT_GROUP, resourceName], i, count)
      else:
        row = {'User': user, 'resourceName': resourceName}
        if addCSVData:
          row.update(addCSVData)
        csvPF.WriteRow(row)
    except (GAPI.forbidden, GAPI.permissionDenied):
      userPeopleServiceNotEnabledWarning(user, i, count)
    except GAPI.serviceNotAvailable:
      entityUnknownWarning(entityType, user, i, count)
  if csvPF:
    csvPF.writeCSVfile('People Contact Groups')

# gam <UserTypeEntity> update contactgroups <ContactGroupItem> <ContactAttribute>+
def updateUserPeopleContactGroup(users):
  peopleManager = PeopleManager()
  entityType = Ent.USER
  entityList = getStringReturnInList(Cmd.OB_CONTACT_GROUP_ITEM)
  body, fields = peopleManager.GetContactGroupFields()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, people = buildGAPIServiceObject(API.PEOPLE, user, i, count)
    if not people:
      continue
    contactGroupIDs = contactGroupNames = None
    j = 0
    jcount = len(entityList)
    entityPerformActionNumItems([entityType, user], jcount, Ent.CONTACT_GROUP, i, count)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      continue
    Ind.Increment()
    for contactGroup in entityList:
      j += 1
      try:
        groupId, contactGroupIDs, contactGroupNames = validatePeopleContactGroup(people, contactGroup,
                                                                                 contactGroupIDs, contactGroupNames, entityType, user, i, count)
        if not groupId:
          if contactGroupNames:
            entityActionFailedWarning([entityType, user, Ent.CONTACT_GROUP, contactGroup], Msg.DOES_NOT_EXIST, j, jcount)
            continue
          break
        result = callGAPI(people.contactGroups(), 'get',
                          bailOnInternalError=True,
                          throwReasons=[GAPI.NOT_FOUND, GAPI.INTERNAL_ERROR]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                          resourceName=groupId)
        body['etag'] = result['etag']
        newContactGroup = body.get(PEOPLE_GROUP_NAME)
        if newContactGroup:
          if newContactGroup in contactGroupNames and groupId not in contactGroupNames[newContactGroup]:
            entityActionFailedWarning([entityType, user, Ent.CONTACT_GROUP, contactGroup],
                                      Ent.TypeNameMessage(Ent.CONTACT_GROUP, newContactGroup, Msg.DUPLICATE), i, count)
            continue
        callGAPI(people.contactGroups(), 'update',
                 throwReasons=[GAPI.NOT_FOUND]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                 resourceName=groupId, body={'contactGroup': body, 'updateGroupFields': fields})
        entityActionPerformed([entityType, user, Ent.CONTACT_GROUP, contactGroup], j, jcount)
      except (GAPI.notFound, GAPI.internalError) as e:
        entityActionFailedWarning([entityType, user, Ent.CONTACT_GROUP, contactGroup], str(e), j, jcount)
      except (GAPI.forbidden, GAPI.permissionDenied):
        userPeopleServiceNotEnabledWarning(user, i, count)
        break
      except GAPI.serviceNotAvailable:
        entityUnknownWarning(entityType, user, i, count)
        break
    Ind.Decrement()

# gam <UserTypeEntity> delete contactgroups <ContactGroupEntity>
def deleteUserPeopleContactGroups(users):
  entityType = Ent.USER
  entityList = getEntityList(Cmd.OB_CONTACT_GROUP_ENTITY, shlexSplit=True)
  contactGroupIdLists = entityList if isinstance(entityList, dict) else None
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    if contactGroupIdLists:
      entityList = contactGroupIdLists[user]
    user, people = buildGAPIServiceObject(API.PEOPLE, user, i, count)
    if not people:
      continue
    contactGroupIDs = contactGroupNames = None
    j = 0
    jcount = len(entityList)
    entityPerformActionNumItems([entityType, user], jcount, Ent.CONTACT_GROUP, i, count)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      continue
    Ind.Increment()
    for contactGroup in entityList:
      j += 1
      try:
        groupId, contactGroupIDs, contactGroupNames = validatePeopleContactGroup(people, contactGroup,
                                                                                 contactGroupIDs, contactGroupNames, entityType, user, i, count)
        if not groupId:
          if contactGroupNames:
            entityActionFailedWarning([entityType, user, Ent.CONTACT_GROUP, contactGroup], Msg.DOES_NOT_EXIST, j, jcount)
            continue
          break
        callGAPI(people.contactGroups(), 'delete',
                 throwReasons=[GAPI.NOT_FOUND]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                 resourceName=groupId)
        entityActionPerformed([entityType, user, Ent.CONTACT_GROUP, contactGroup], j, jcount)
      except GAPI.notFound as e:
        entityActionFailedWarning([entityType, user, Ent.CONTACT_GROUP, contactGroup], str(e), j, jcount)
      except (GAPI.forbidden, GAPI.permissionDenied):
        userPeopleServiceNotEnabledWarning(user, i, count)
        break
      except GAPI.serviceNotAvailable:
        entityUnknownWarning(entityType, user, i, count)
        break
    Ind.Decrement()

PEOPLE_GROUP_TIME_OBJECTS = {'updateTime'}

def _normalizeContactGroupMetadata(contactGroup):
  normalizedContactGroup = contactGroup.copy()
  for k, v in iter(normalizedContactGroup.pop('metadata', {}).items()):
    normalizedContactGroup[k] = v
  return normalizedContactGroup

def _showContactGroup(userEntityType, user, entityType, contactGroup, i, count, FJQC):
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(contactGroup, timeObjects=PEOPLE_GROUP_TIME_OBJECTS),
                         ensure_ascii=False, sort_keys=True))
    return
  normalizedContactGroup = _normalizeContactGroupMetadata(contactGroup)
  printEntity([userEntityType, user, entityType, contactGroup['resourceName']], i, count)
  Ind.Increment()
  showJSON(None, normalizedContactGroup, timeObjects=PEOPLE_GROUP_TIME_OBJECTS)
  Ind.Decrement()

def _printContactGroup(entityTypeName, user, contactGroup, csvPF, FJQC):
  normalizedContactGroup = _normalizeContactGroupMetadata(contactGroup)
  row = flattenJSON(normalizedContactGroup, flattened={entityTypeName: user}, timeObjects=PEOPLE_GROUP_TIME_OBJECTS)
  if not FJQC.formatJSON:
    csvPF.WriteRowTitles(row)
  elif csvPF.CheckRowTitles(row):
    csvPF.WriteRowNoFilter({entityTypeName: user, 'resourceName': contactGroup['resourceName'],
                            'JSON': json.dumps(cleanJSON(contactGroup, timeObjects=PEOPLE_GROUP_TIME_OBJECTS),
                                               ensure_ascii=False, sort_keys=True)})

PEOPLE_CONTACTGROUPS_FIELDS_CHOICE_MAP = {
  'clientdata': 'clientData',
  'grouptype': 'groupType',
  'membercount': 'memberCount',
  'metadata': 'metadata',
  'name': 'name',
  }

PEOPLE_CONTACTGROUPS_DEFAULT_FIELDS = ['name', 'metadata', 'grouptype', 'membercount']

# gam <UserTypeEntity> info contactgroups <PeopleContactGroupEntity>
#	[allfields|(fields <PeoplaContactGroupFieldList>)] [showmetadata]
#	[formatjson]
def infoUserPeopleContactGroups(users):
  entityType = Ent.USER
  entityList = getEntityList(Cmd.OB_CONTACT_GROUP_ENTITY, shlexSplit=True)
  contactGroupIdLists = entityList if isinstance(entityList, dict) else None
  FJQC = FormatJSONQuoteChar()
  fieldsList = []
  parameters = _initPersonMetadataParameters()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'allfields':
      fieldsList = None
    elif getPersonFieldsList(myarg, PEOPLE_CONTACTGROUPS_FIELDS_CHOICE_MAP, fieldsList):
      pass
    elif myarg == 'showmetadata':
      parameters['strip'] = False
    else:
      FJQC.GetFormatJSON(myarg)
  fields = _getPersonFields(PEOPLE_CONTACTGROUPS_FIELDS_CHOICE_MAP, PEOPLE_CONTACTGROUPS_DEFAULT_FIELDS, fieldsList, parameters)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    if contactGroupIdLists:
      entityList = contactGroupIdLists[user]
    user, people = buildGAPIServiceObject(API.PEOPLE, user, i, count)
    if not people:
      continue
    contactGroupIDs = contactGroupNames = None
    j = 0
    jcount = len(entityList)
    if not FJQC.formatJSON:
      entityPerformActionNumItems([entityType, user], jcount, Ent.CONTACT_GROUP, i, count)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      continue
    Ind.Increment()
    for contactGroup in entityList:
      j += 1
      try:
        groupId, contactGroupIDs, contactGroupNames = validatePeopleContactGroup(people, contactGroup,
                                                                                 contactGroupIDs, contactGroupNames, entityType, user, i, count)
        if not groupId:
          if contactGroupNames:
            entityActionFailedWarning([entityType, user, Ent.CONTACT_GROUP, contactGroup], Msg.DOES_NOT_EXIST, j, jcount)
            continue
          break
        group = callGAPI(people.contactGroups(), 'get',
                         throwReasons=[GAPI.NOT_FOUND]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                         resourceName=groupId, groupFields=fields)
        _showContactGroup(entityType, user, Ent.CONTACT_GROUP, group, j, jcount, FJQC)
      except GAPI.notFound as e:
        entityActionFailedWarning([entityType, user, Ent.CONTACT_GROUP, contactGroup], str(e), j, jcount)
      except (GAPI.forbidden, GAPI.permissionDenied):
        userPeopleServiceNotEnabledWarning(user, i, count)
        break
      except GAPI.serviceNotAvailable:
        entityUnknownWarning(entityType, user, i, count)
        break
    Ind.Decrement()

# gam <UserTypeEntity> print contactgroups [todrive <ToDriveAttribute>*]
#	[allfields|(fields <PeoplaContactGroupFieldList>)] [showmetadata]
#	[formatjson [quotechar <Character>]]
# gam <UserTypeEntity> show contactgroups
#	[allfields|(fields <PeoplaContactGroupFieldList>)] [showmetadata]
#	[formatjson]
def printShowUserPeopleContactGroups(users):
  entityType = Ent.USER
  entityTypeName = Ent.Singular(entityType)
  csvPF = CSVPrintFile([entityTypeName, 'resourceName'], 'sortall') if Act.csvFormat() else None
  if csvPF:
    csvPF.SetNoEscapeChar(True)
  FJQC = FormatJSONQuoteChar(csvPF)
  fieldsList = []
  parameters = _initPersonMetadataParameters()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'allfields':
      fieldsList = None
    elif getPersonFieldsList(myarg, PEOPLE_CONTACTGROUPS_FIELDS_CHOICE_MAP, fieldsList):
      pass
    elif myarg == 'showmetadata':
      parameters['strip'] = False
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  fields = _getPersonFields(PEOPLE_CONTACTGROUPS_FIELDS_CHOICE_MAP, PEOPLE_CONTACTGROUPS_DEFAULT_FIELDS, fieldsList, parameters)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, people = buildGAPIServiceObject(API.PEOPLE, user, i, count)
    if not people:
      continue
    printGettingAllEntityItemsForWhom(Ent.PEOPLE_CONTACT_GROUP, user, i, count)
    try:
      entityList = callGAPIpages(people.contactGroups(), 'list', 'contactGroups',
                                 pageMessage=getPageMessageForWhom(),
                                 throwReasons=GAPI.PEOPLE_ACCESS_THROW_REASONS,
                                 pageSize=GC.Values[GC.PEOPLE_MAX_RESULTS],
                                 groupFields=fields, fields='nextPageToken,contactGroups')
    except GAPI.permissionDenied as e:
      ClientAPIAccessDeniedExit(str(e))
    except (GAPI.serviceNotAvailable, GAPI.forbidden):
      ClientAPIAccessDeniedExit()
    _printPersonEntityList(Ent.PEOPLE_CONTACT_GROUP, entityList, entityType, user, i, count, csvPF, FJQC, parameters, None)
  if csvPF:
    csvPF.writeCSVfile('People Contact Groups')

# Delegate command utilities
def _validateUserGetDelegateList(cd, user, i, count, entity):
  if entity['dict']:
    entityList = entity['dict'][user]
  else:
    entityList = entity['list']
  user = checkUserExists(cd, user, Ent.USER, i, count)
  if not user:
    return (user, None, 0)
  jcount = len(entityList)
  entityPerformActionNumItems([Ent.USER, user], jcount, entity['item'], i, count)
  if jcount == 0:
    setSysExitRC(NO_ENTITIES_FOUND_RC)
  return (user, entityList, jcount)

def _getDelegateName(cd, delegateEmail, delegateNames):
  if delegateEmail in delegateNames:
    return delegateNames[delegateEmail]
  try:
    result = callGAPI(cd.users(), 'get',
                      throwReasons=GAPI.USER_GET_THROW_REASONS,
                      userKey=delegateEmail, fields='name(fullName)')
    delegateName = result.get('name', {'fullName': delegateEmail}).get('fullName', delegateEmail)
  except (GAPI.userNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.backendError, GAPI.systemError):
    delegateName = delegateEmail
  delegateNames[delegateEmail] = delegateName
  return delegateName

# gam <UserTypeEntity> create contactdelegate <UserEntity>
# gam <UserTypeEntity> delete contactdelegate <UserEntity>
def processContactDelegates(users):
  condel = buildGAPIObject(API.CONTACTDELEGATION)
  cd = buildGAPIObject(API.DIRECTORY)
  function = 'delete' if Act.Get() == Act.DELETE else 'create'
  delegateEntity = getUserObjectEntity(Cmd.OB_USER_ENTITY, Ent.DELEGATE)
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, delegates, jcount = _validateUserGetDelegateList(cd, user, i, count, delegateEntity)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for delegate in delegates:
      j += 1
      delegateEmail = checkUserExists(cd, delegate, Ent.CONTACT_DELEGATE, j, jcount)
      if not delegateEmail:
        continue
      try:
        if function == 'create':
          callGAPI(condel.delegates(), function,
                   throwReasons=GAPI.CONTACT_DELEGATE_THROW_REASONS,
                   user=user, body={'email': delegateEmail})
        else:
          callGAPI(condel.delegates(), function,
                   throwReasons=GAPI.CONTACT_DELEGATE_THROW_REASONS,
                   user=user, delegate=delegateEmail)
        entityActionPerformed([Ent.USER, user, Ent.CONTACT_DELEGATE, delegateEmail], j, jcount)
      except (GAPI.failedPrecondition, GAPI.permissionDenied, GAPI.forbidden, GAPI.invalidArgument) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.CONTACT_DELEGATE, delegateEmail], str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.badRequest):
        userContactDelegateServiceNotEnabledWarning(user, i, count)
    Ind.Decrement()

# gam <UserTypeEntity> print contactdelegates [todrive <ToDriveAttribute>*] [shownames]
# gam <UserTypeEntity> show contactdelegates [shownames] [csv]
def printShowContactDelegates(users):
  condel = buildGAPIObject(API.CONTACTDELEGATION)
  titlesList = ['User', 'delegateAddress']
  csvPF = CSVPrintFile() if Act.csvFormat() else None
  cd = buildGAPIObject(API.DIRECTORY)
  csvStyle = showNames = False
  delegateNames = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif not csvPF and myarg == 'csv':
      csvStyle = True
    elif myarg == 'shownames':
      titlesList = ['User', 'delegateName', 'delegateAddress']
      showNames = True
    else:
      unknownArgumentExit()
  if csvPF:
    csvPF.AddTitles(titlesList)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user = checkUserExists(cd, user, Ent.USER, i, count)
    if not user:
      continue
    printGettingAllEntityItemsForWhom(Ent.CONTACT_DELEGATE, user, i, count)
    try:
      delegates = callGAPIpages(condel.delegates(), 'list', 'delegates',
                                pageMessage=getPageMessageForWhom(),
                                throwReasons=GAPI.CONTACT_DELEGATE_THROW_REASONS,
                                user=user)
    except (GAPI.failedPrecondition, GAPI.permissionDenied, GAPI.forbidden, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.CONTACT_DELEGATE, None], str(e), i, count)
      continue
    except (GAPI.serviceNotAvailable, GAPI.badRequest):
      userContactDelegateServiceNotEnabledWarning(user, i, count)
      continue
    jcount = len(delegates)
    if not csvPF:
      if not csvStyle:
        entityPerformActionNumItems([Ent.USER, user], jcount, Ent.CONTACT_DELEGATE, i, count)
        Ind.Increment()
        j = 0
        for delegate in delegates:
          j += 1
          delegateEmail = delegate['email']
          if showNames:
            printEntity([Ent.CONTACT_DELEGATE, _getDelegateName(cd, delegateEmail, delegateNames)], j, jcount)
            Ind.Increment()
            printKeyValueList(['Delegate Email', delegateEmail])
            Ind.Decrement()
          else:
            printEntity([Ent.DELEGATE, delegateEmail], j, jcount)
        Ind.Decrement()
      else:
        j = 0
        for delegate in delegates:
          j += 1
          delegateEmail = delegate['email']
          if showNames:
            writeStdout(f'{user},{_getDelegateName(cd, delegateEmail, delegateNames)},{delegateEmail}\n')
          else:
            writeStdout(f'{user},{delegateEmail}\n')
    else:
      if delegates:
        if showNames:
          for delegate in delegates:
            csvPF.WriteRow({'User': user, 'delegateName': _getDelegateName(cd, delegate['email'], delegateNames),
                            'delegateAddress': delegate['email']})
        else:
          for delegate in delegates:
            csvPF.WriteRow({'User': user, 'delegateAddress': delegate['email']})
      elif GC.Values[GC.CSV_OUTPUT_USERS_AUDIT]:
        csvPF.WriteRowNoFilter({'User': user})
  if csvPF:
    csvPF.writeCSVfile('Contact Delegates')

# CrOS commands utilities
def getCrOSDeviceEntity():
  if checkArgumentPresent('crossn'):
    return getItemsToModify(Cmd.ENTITY_CROS_SN, getString(Cmd.OB_SERIAL_NUMBER_LIST))
  if checkArgumentPresent('query'):
    return getItemsToModify(Cmd.ENTITY_CROS_QUERY, getString(Cmd.OB_QUERY))
  deviceId = getString(Cmd.OB_CROS_DEVICE_ENTITY)
  if deviceId[:6].lower() == 'query:':
    query = deviceId[6:]
    if query[:12].lower() == 'orgunitpath:':
      return getItemsToModify(Cmd.ENTITY_CROS_OU, query[12:])
    return getItemsToModify(Cmd.ENTITY_CROS_QUERY, query)
  Cmd.Backup()
  return getEntityList(Cmd.OB_CROS_ENTITY)

UPDATE_CROS_ARGUMENT_TO_PROPERTY_MAP = {
  'annotatedassetid': 'annotatedAssetId',
  'annotatedlocation': 'annotatedLocation',
  'annotateduser': 'annotatedUser',
  'asset': 'annotatedAssetId',
  'assetid': 'annotatedAssetId',
  'location': 'annotatedLocation',
  'notes': 'notes',
  'org': 'orgUnitPath',
  'orgunitpath': 'orgUnitPath',
  'ou': 'orgUnitPath',
  'tag': 'annotatedAssetId',
  'updatenotes': 'notes',
  'user': 'annotatedUser',
  }

CROS_ACTION_CHOICE_MAP = {
  'deprovisiondifferentmodelreplace': ('CHANGE_CHROME_OS_DEVICE_STATUS_ACTION_DEPROVISION', 'DEPROVISION_REASON_DIFFERENT_MODEL_REPLACEMENT'),
  'deprovisiondifferentmodelreplacement': ('CHANGE_CHROME_OS_DEVICE_STATUS_ACTION_DEPROVISION', 'DEPROVISION_REASON_DIFFERENT_MODEL_REPLACEMENT'),
  'deprovisionretiringdevice': ('CHANGE_CHROME_OS_DEVICE_STATUS_ACTION_DEPROVISION', 'DEPROVISION_REASON_RETIRING_DEVICE'),
  'deprovisionsamemodelreplace': ('CHANGE_CHROME_OS_DEVICE_STATUS_ACTION_DEPROVISION', 'DEPROVISION_REASON_SAME_MODEL_REPLACEMENT'),
  'deprovisionsamemodelreplacement': ('CHANGE_CHROME_OS_DEVICE_STATUS_ACTION_DEPROVISION', 'DEPROVISION_REASON_SAME_MODEL_REPLACEMENT'),
  'deprovisionupgradetransfer': ('CHANGE_CHROME_OS_DEVICE_STATUS_ACTION_DEPROVISION', 'DEPROVISION_REASON_UPGRADE_TRANSFER'),
  'disable': ('CHANGE_CHROME_OS_DEVICE_STATUS_ACTION_DISABLE', None),
  'reenable': ('CHANGE_CHROME_OS_DEVICE_STATUS_ACTION_REENABLE', None),
#  'preprovisioneddisable': ('pre_provisioned_disable', None),
#  'preprovisionedreenable': ('pre_provisioned_reenable', None)
  }

CROS_ACTION_NAME_MAP = {
  'CHANGE_CHROME_OS_DEVICE_STATUS_ACTION_DEPROVISION': Act.DEPROVISION,
  'CHANGE_CHROME_OS_DEVICE_STATUS_ACTION_DISABLE': Act.DISABLE,
  'CHANGE_CHROME_OS_DEVICE_STATUS_ACTION_REENABLE': Act.REENABLE,
#  'pre_provisioned_disable': Act.PRE_PROVISIONED_DISABLE,
#  'pre_provisioned_reenable': Act.PRE_PROVISIONED_REENABLE
  }

# gam <CrOSTypeEntity> update <CrOSAttribute>+ [quickcrosmove [<Boolean>]] [nobatchupdate]
# gam <CrOSTypeEntity> update action <CrOSAction> [acknowledge_device_touch_requirement]
#	[actionbatchsize <Integer>]
def updateCrOSDevices(entityList):
  cd = buildGAPIObject(API.DIRECTORY)
  noBatchUpdate = False
  update_body = {}
  action_body = {}
  orgUnitPath = updateNotes = None
  ackWipe = False
  quickCrOSMove = GC.Values[GC.QUICK_CROS_MOVE]
  actionBatchSize = 10
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in UPDATE_CROS_ARGUMENT_TO_PROPERTY_MAP:
      up = UPDATE_CROS_ARGUMENT_TO_PROPERTY_MAP[myarg]
      if up == 'orgUnitPath':
        orgUnitPath = getOrgUnitItem()
      elif up == 'notes':
        update_body[up] = getStringWithCRsNLs()
        updateNotes = update_body[up] if myarg == 'updatenotes' and update_body[up].find('#notes#') != -1 else None
      else:
        update_body[up] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg == 'action':
      actionLocation = Cmd.Location()
      action_body['changeChromeOsDeviceStatusAction'], deprovisionReason = getChoice(CROS_ACTION_CHOICE_MAP, mapChoice=True)
      if deprovisionReason:
        action_body['deprovisionReason'] = deprovisionReason
      Act.Set(CROS_ACTION_NAME_MAP[action_body['changeChromeOsDeviceStatusAction']])
    elif myarg == 'acknowledgedevicetouchrequirement':
      ackWipe = True
    elif myarg == 'quickcrosmove':
      quickCrOSMove = getBoolean()
    elif myarg == 'nobatchupdate':
      noBatchUpdate = getBoolean()
    elif myarg == 'actionbatchsize':
      actionBatchSize = getInteger(minVal=10, maxVal=250)
    else:
      unknownArgumentExit()
  if action_body and update_body:
    Cmd.SetLocation(actionLocation-1)
    usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format('action', '<CrOSAttribute>'))
  if orgUnitPath:
    status, orgUnitPath, orgUnitId = checkOrgUnitPathExists(cd, orgUnitPath)
    if not status:
      entityActionFailedWarning([Ent.CROS_DEVICE, ''], f'{Ent.Singular(Ent.ORGANIZATIONAL_UNIT)}: {orgUnitPath}, {Msg.DOES_NOT_EXIST}')
      return
  i, count, entityList = getEntityArgument(entityList)
# Action
  if action_body:
    if action_body['changeChromeOsDeviceStatusAction'] == 'CHANGE_CHROME_OS_DEVICE_STATUS_ACTION_DEPROVISION' and not ackWipe:
      stderrWarningMsg(Msg.REFUSING_TO_DEPROVISION_DEVICES.format(count))
      systemErrorExit(ACTION_NOT_PERFORMED_RC, None)
    while i < count:
      bcount = min(count-i, actionBatchSize)
      action_body['deviceIds'] = entityList[i:i+bcount]
      try:
        result = callGAPI(cd.customer().devices().chromeos(), 'batchChangeStatus',
                          throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.CONDITION_NOT_MET, GAPI.INVALID_INPUT,
                                        GAPI.BAD_REQUEST, GAPI.FORBIDDEN],
                          customerId=GC.Values[GC.CUSTOMER_ID], body=action_body)
        for status in result['changeChromeOsDeviceStatusResults']:
          i += 1
          deviceId = status['deviceId']
          if 'error' not in status:
            entityActionPerformed([Ent.CROS_DEVICE, deviceId], i, count)
          else:
            entityActionFailedWarning([Ent.CROS_DEVICE, deviceId], status['error']['message'], i, count)
      except (GAPI.invalid, GAPI.invalidArgument, GAPI.conditionNotMet, GAPI.invalidInput, GAPI.badRequest, GAPI.forbidden) as e:
        entityActionFailedExit([Ent.CROS_DEVICE, None], str(e))
    return
# Update
  function = None
  if update_body or noBatchUpdate:
    if orgUnitPath and (not quickCrOSMove or noBatchUpdate):
      update_body['orgUnitPath'] = orgUnitPath
      if GC.Values[GC.UPDATE_CROS_OU_WITH_ID]:
        update_body['orgUnitId'] = orgUnitId
      orgUnitPath = None
    function = 'update'
    parmId = 'deviceId'
    kwargs = {parmId: None, 'body': update_body, 'fields': ''}
  if orgUnitPath:
    Act.Set(Act.ADD)
    _batchMoveCrOSesToOrgUnit(cd, orgUnitPath, orgUnitId, 0, 0, entityList, quickCrOSMove)
    Act.Set(Act.UPDATE)
  if function is None:
    return
  for deviceId in entityList:
    i += 1
    kwargs[parmId] = deviceId
    try:
      if updateNotes:
        oldNotes = callGAPI(cd.chromeosdevices(), 'get',
                            throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                            customerId=GC.Values[GC.CUSTOMER_ID], deviceId=deviceId, fields='notes').get('notes', '')
        update_body['notes'] = updateNotes.replace('#notes#', oldNotes)
      callGAPI(cd.chromeosdevices(), function,
               throwReasons=[GAPI.INVALID, GAPI.CONDITION_NOT_MET, GAPI.INVALID_INPUT, GAPI.INVALID_ORGUNIT,
                             GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
               customerId=GC.Values[GC.CUSTOMER_ID], **kwargs)
      entityActionPerformed([Ent.CROS_DEVICE, deviceId], i, count)
    except (GAPI.invalid, GAPI.conditionNotMet, GAPI.invalidInput) as e:
      entityActionFailedWarning([Ent.CROS_DEVICE, deviceId], str(e), i, count)
    except GAPI.invalidOrgunit:
      entityActionFailedWarning([Ent.CROS_DEVICE, deviceId], Msg.INVALID_ORGUNIT, i, count)
    except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
      checkEntityAFDNEorAccessErrorExit(cd, Ent.CROS_DEVICE, deviceId, i, count)

# gam update cros|croses <CrOSEntity> <CrOSAttribute>+ [quickcrosmove [<Boolean>]] [nobatchupdate]
# gam update cros|croses <CrOSEntity> action <CrOSAction> [acknowledge_device_touch_requirement]
def doUpdateCrOSDevices():
  updateCrOSDevices(getCrOSDeviceEntity())

CROS_COMMAND_CHOICE_MAP = {
  'reboot': 'REBOOT',
  'remotepowerwash': 'REMOTE_POWERWASH',
  'setvolume': 'SET_VOLUME',
  'takeascreenshot': 'TAKE_A_SCREENSHOT',
  'wipeusers': 'WIPE_USERS'
  }

CROS_DOIT_REQUIRED_COMMANDS = {'WIPE_USERS', 'REMOTE_POWERWASH'}
CROS_KIOSK_COMMANDS = {'REBOOT', 'SET_VOLUME', 'TAKE_A_SCREENSHOT'}
CROS_COMMAND_FINAL_STATES = {'EXPIRED', 'CANCELLED', 'EXECUTED_BY_CLIENT'}
CROS_COMMAND_TIME_OBJECTS = {'executeTime', 'issueTime', 'commandExpireTime'}

def displayCrOSCommandResult(cd, deviceId, commandId, checkResultRetries, i, count):
  Ind.Increment()
  try:
    for _ in range(0, checkResultRetries):
      time.sleep(2)
      result = callGAPI(cd.customer().devices().chromeos().commands(), 'get',
                        throwReasons=[GAPI.INVALID_ARGUMENT, GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                        customerId=GC.Values[GC.CUSTOMER_ID], deviceId=deviceId, commandId=commandId)
      showJSON(None, result, timeObjects=CROS_COMMAND_TIME_OBJECTS)
      state = result.get('state')
      if state in CROS_COMMAND_FINAL_STATES:
        break
  except (GAPI.invalidArgument, GAPI.badRequest, GAPI.notFound, GAPI.forbidden) as e:
    entityActionFailedWarning([Ent.CROS_DEVICE, deviceId, Ent.COMMAND_ID, commandId], str(e), i, count)
  Ind.Decrement()

# gam <CrOSTypeEntity> issuecommand command <CrOSCommand> [times_to_check_status <Integer>] [doit]
def issueCommandCrOSDevices(entityList):
  cd = buildGAPIObject(API.DIRECTORY)
  body = {}
  checkResultRetries = 1
  doit = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'command':
      body['commandType'] = getChoice(CROS_COMMAND_CHOICE_MAP, mapChoice=True)
      if body['commandType'] == 'SET_VOLUME':
        body['payload'] = json.dumps({'volume': getInteger(minVal=0, maxVal=100)})
    elif myarg == 'timestocheckstatus':
      checkResultRetries = getInteger(minVal=0)
    elif myarg == 'doit':
      doit = True
    else:
      unknownArgumentExit()
  if not body:
    missingArgumentExit('command <CrOSCommand>')
  i, count, entityList = getEntityArgument(entityList)
  if body['commandType'] in CROS_DOIT_REQUIRED_COMMANDS and not doit:
    actionNotPerformedNumItemsWarning(count, Ent.CROS_DEVICE, Msg.USE_DOIT_ARGUMENT_TO_PERFORM_ACTION)
    return
  for deviceId in entityList:
    i += 1
    try:
      result = callGAPI(cd.customer().devices().chromeos(), 'issueCommand',
                        throwReasons=[GAPI.INVALID_ARGUMENT, GAPI.NOT_FOUND],
                        customerId=GC.Values[GC.CUSTOMER_ID], deviceId=deviceId, body=body)
      commandId = result.get('commandId')
      entityActionPerformed([Ent.CROS_DEVICE, deviceId, Ent.ACTION, body['commandType'], Ent.COMMAND_ID, commandId], i, count)
      displayCrOSCommandResult(cd, deviceId, commandId, checkResultRetries, i, count)
    except GAPI.invalidArgument as e:
      errMsg = str(e)
      if body['commandType'] in CROS_KIOSK_COMMANDS:
        errMsg += Msg.KIOSK_MODE_REQUIRED.format(body['commandType'])
      entityActionFailedWarning([Ent.CROS_DEVICE, deviceId], errMsg, i, count)
    except GAPI.notFound as e:
      entityActionFailedWarning([Ent.CROS_DEVICE, deviceId], str(e), i, count)

# gam issuecommand <CrOSEntity> command <CrOSCommand> [times_to_check_status <Integer>] [doit]
def doIssueCommandCrOSDevices():
  issueCommandCrOSDevices(getCrOSDeviceEntity())

# gam <CrOSTypeEntity> getcommand commandid <CommandID> [times_to_check_status <Integer>]
def getCommandResultCrOSDevices(entityList):
  cd = buildGAPIObject(API.DIRECTORY)
  commandId = ''
  checkResultRetries = 1
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'commandid':
      commandId = getString(Cmd.OB_COMMAND_ID)
    elif myarg == 'timestocheckstatus':
      checkResultRetries = getInteger(minVal=0)
    else:
      unknownArgumentExit()
  if not commandId:
    missingArgumentExit('commandid <CommandID>')
  i, count, entityList = getEntityArgument(entityList)
  for deviceId in entityList:
    i += 1
    printEntity([Ent.CROS_DEVICE, deviceId, Ent.COMMAND_ID, commandId], i, count)
    displayCrOSCommandResult(cd, deviceId, commandId, checkResultRetries, i, count)

# gam getcommand <CrOSEntity> commandid <CommandID> [times_to_check_status <Integer>]
def doGetCommandResultCrOSDevices():
  getCommandResultCrOSDevices(getCrOSDeviceEntity())

# From https://www.chromium.org/chromium-os/tpm_firmware_update
CROS_TPM_VULN_VERSIONS = ['41f', '420', '628', '8520']
CROS_TPM_FIXED_VERSIONS = ['422', '62b', '8521']

def checkTPMVulnerability(cros):
  if 'tpmVersionInfo' in cros and 'firmwareVersion' in cros['tpmVersionInfo']:
    if cros['tpmVersionInfo']['firmwareVersion'] in CROS_TPM_VULN_VERSIONS:
      cros['tpmVersionInfo']['tpmVulnerability'] = 'VULNERABLE'
    elif cros['tpmVersionInfo']['firmwareVersion'] in CROS_TPM_FIXED_VERSIONS:
      cros['tpmVersionInfo']['tpmVulnerability'] = 'UPDATED'
    else:
      cros['tpmVersionInfo']['tpmVulnerability'] = 'NOT IMPACTED'

def _filterActiveTimeRanges(cros, selected, listLimit, startDate, endDate, activeTimeRangesOrder):
  if not selected:
    cros.pop('activeTimeRanges', None)
    return []
  filteredItems = []
  activeTimeRanges = cros.get('activeTimeRanges', [])
  if activeTimeRangesOrder == 'DESCENDING':
    activeTimeRanges.reverse()
  i = 0
  for item in activeTimeRanges:
    activityDate = datetime.datetime.strptime(item['date'], YYYYMMDD_FORMAT)
    if ((startDate is None) or (activityDate >= startDate)) and ((endDate is None) or (activityDate <= endDate)):
      item['duration'] = formatMilliSeconds(item['activeTime'])
      item['minutes'] = item['activeTime']//60000
      item['activeTime'] = str(item['activeTime'])
      filteredItems.append(item)
      i += 1
      if listLimit and i == listLimit:
        break
  cros['activeTimeRanges'] = filteredItems
  return cros['activeTimeRanges']

def _filterDeviceFiles(cros, selected, listLimit, startTime, endTime):
  if not selected:
    cros.pop('deviceFiles', None)
    return []
  filteredItems = []
  i = 0
  for item in cros.get('deviceFiles', []):
    timeValue, _ = iso8601.parse_date(item['createTime'])
    if ((startTime is None) or (timeValue >= startTime)) and ((endTime is None) or (timeValue <= endTime)):
      item['createTime'] = formatLocalTime(item['createTime'])
      filteredItems.append(item)
      i += 1
      if listLimit and i == listLimit:
        break
  cros['deviceFiles'] = filteredItems
  return cros['deviceFiles']

def _filterCPUStatusReports(cros, selected, listLimit, startTime, endTime):
  if not selected:
    cros.pop('cpuStatusReports', None)
    return []
  filteredItems = []
  i = 0
  for item in cros.get('cpuStatusReports', []):
    timeValue, _ = iso8601.parse_date(item['reportTime'])
    if ((startTime is None) or (timeValue >= startTime)) and ((endTime is None) or (timeValue <= endTime)):
      item['reportTime'] = formatLocalTime(item['reportTime'])
      for tempInfo in item.get('cpuTemperatureInfo', []):
        tempInfo['label'] = tempInfo['label'].strip()
      if 'cpuUtilizationPercentageInfo' in item:
        item['cpuUtilizationPercentageInfo'] = ','.join([str(x) for x in item['cpuUtilizationPercentageInfo']])
      filteredItems.append(item)
      i += 1
      if listLimit and i == listLimit:
        break
  cros['cpuStatusReports'] = filteredItems
  return cros['cpuStatusReports']

def _filterSystemRamFreeReports(cros, selected, listLimit, startTime, endTime):
  if not selected:
    cros.pop('systemRamFreeReports', None)
    return []
  filteredItems = []
  i = 0
  for item in cros.get('systemRamFreeReports', []):
    timeValue, _ = iso8601.parse_date(item['reportTime'])
    if ((startTime is None) or (timeValue >= startTime)) and ((endTime is None) or (timeValue <= endTime)):
      item['reportTime'] = formatLocalTime(item['reportTime'])
      item['systemRamFreeInfo'] = ','.join([str(x) for x in item['systemRamFreeInfo']])
      filteredItems.append(item)
      i += 1
      if listLimit and i == listLimit:
        break
  cros['systemRamFreeReports'] = filteredItems
  return cros['systemRamFreeReports']

def _filterRecentUsers(cros, selected, listLimit):
  if not selected:
    cros.pop('recentUsers', None)
    return []
  filteredItems = []
  i = 0
  for item in cros.get('recentUsers', []):
    item['email'] = item.get('email', [UNKNOWN, 'UnmanagedUser'][item['type'] == 'USER_TYPE_UNMANAGED'])
    filteredItems.append(item)
    i += 1
    if listLimit and i == listLimit:
      break
  cros['recentUsers'] = filteredItems
  return cros['recentUsers']

def _filterScreenshotFiles(cros, selected, listLimit, startTime, endTime):
  if not selected:
    cros.pop('screenshotFiles', None)
    return []
  filteredItems = []
  i = 0
  for item in cros.get('screenshotFiles', []):
    timeValue, _ = iso8601.parse_date(item['createTime'])
    if ((startTime is None) or (timeValue >= startTime)) and ((endTime is None) or (timeValue <= endTime)):
      item['createTime'] = formatLocalTime(item['createTime'])
      filteredItems.append(item)
      i += 1
      if listLimit and i == listLimit:
        break
  cros['screenshotFiles'] = filteredItems
  return cros['screenshotFiles']

def _filterBasicList(cros, field, selected, listLimit):
  if not selected:
    cros.pop(field, None)
    return []
  if listLimit:
    filteredItems = []
    i = 0
    for item in cros.get(field, []):
      filteredItems.append(item)
      i += 1
      if listLimit and i == listLimit:
        break
    cros[field] = filteredItems
    return cros[field]
  return cros.get(field, [])

def _computeDVRstorageFreePercentage(cros):
  for diskVolumeReport in cros.get('diskVolumeReports', []):
    volumeInfo = diskVolumeReport['volumeInfo']
    for volume in volumeInfo:
      if volume['storageTotal'] != '0':
        volume['storageFreePercentage'] = str(int(int(volume['storageFree'])/int(volume['storageTotal'])*100))
      else:
        volume['storageFreePercentage'] = '0'

def _getFilterDateTime():
  filterDate = getYYYYMMDD(returnDateTime=True)
  return (filterDate, filterDate.replace(tzinfo=iso8601.UTC))

CROS_FIELDS_CHOICE_MAP = {
  'activetimeranges': ['activeTimeRanges.activeTime', 'activeTimeRanges.date'],
  'annotatedassetid': 'annotatedAssetId',
  'annotatedlocation': 'annotatedLocation',
  'annotateduser': 'annotatedUser',
  'asset': 'annotatedAssetId',
  'assetid': 'annotatedAssetId',
  'autoupdateexpiration': 'autoUpdateExpiration',
  'autoupdatethrough': 'autoUpdateThrough',
  'backlightinfo': 'backlightInfo',
  'bootmode': 'bootMode',
  'chromeostype': 'chromeOsType',
  'cpuinfo': 'cpuInfo',
  'cpustatusreports': 'cpuStatusReports',
  'deprovisionreason': 'deprovisionReason',
  'devicefiles': ['deviceFiles.type', 'deviceFiles.createTime'],
  'deviceid': 'deviceId',
  'devicelicensetype': 'deviceLicenseType',
  'diskspaceusage': 'diskSpaceUsage',
  'diskvolumereports': 'diskVolumeReports',
  'dockmacaddress': 'dockMacAddress',
  'ethernetmacaddress': 'ethernetMacAddress',
  'ethernetmacaddress0': 'ethernetMacAddress0',
  'extendedsupporteligible': 'extendedSupportEligible',
  'extendedsupportstart': 'extendedSupportStart',
  'extendedsupportenabled': 'extendedSupportEnabled',
  'faninfo': 'fanInfo',
  'firmwareversion': 'firmwareVersion',
  'firstenrollmenttime': 'firstEnrollmentTime',
  'lastdeprovisiontimestamp': 'lastDeprovisionTimestamp',
  'lastenrollmenttime': 'lastEnrollmentTime',
  'lastknownnetwork': 'lastKnownNetwork',
  'lastsync': 'lastSync',
  'location': 'annotatedLocation',
  'macaddress': 'macAddress',
  'manufacturedate': 'manufactureDate',
  'meid': 'meid',
  'model': 'model',
  'notes': 'notes',
  'ordernumber': 'orderNumber',
  'org': 'orgUnitPath',
  'orgunitid': 'orgUnitId',
  'orgunitpath': 'orgUnitPath',
  'osupdatestatus': 'osUpdateStatus',
  'osversion': 'osVersion',
  'ou': 'orgUnitPath',
  'platformversion': 'platformVersion',
  'recentusers': ['recentUsers.email', 'recentUsers.type'],
  'screenshotfiles': 'screenshotFiles',
  'serialnumber': 'serialNumber',
  'status': 'status',
  'supportenddate': 'supportEndDate',
  'systemramfreereports': 'systemRamFreeReports',
  'systemramtotal': 'systemRamTotal',
  'tag': 'annotatedAssetId',
  'timeranges': ['activeTimeRanges.activeTime', 'activeTimeRanges.date'],
  'times': ['activeTimeRanges.activeTime', 'activeTimeRanges.date'],
  'tpmversioninfo': 'tpmVersionInfo',
  'user': 'annotatedUser',
  'users': ['recentUsers.email', 'recentUsers.type'],
  'willautorenew': 'willAutoRenew',
  }
CROS_BASIC_FIELDS_LIST = ['deviceId', 'annotatedAssetId', 'annotatedLocation', 'annotatedUser', 'lastSync', 'notes', 'serialNumber', 'status']

CROS_SCALAR_PROPERTY_PRINT_ORDER = [
  'orgUnitId',
  'orgUnitPath',
  'annotatedAssetId',
  'annotatedLocation',
  'annotatedUser',
  'lastSync',
  'notes',
  'serialNumber',
  'status',
  'chromeOsType',
  'deviceLicenseType',
  'model',
  'firmwareVersion',
  'platformVersion',
  'osVersion',
  'bootMode',
  'meid',
  'dockMacAddress',
  'ethernetMacAddress',
  'ethernetMacAddress0',
  'macAddress',
  'systemRamTotal',
  'firstEnrollmentTime',
  'lastEnrollmentTime',
  'deprovisionReason',
  'lastDeprovisionTimestamp',
  'orderNumber',
  'manufactureDate',
  'supportEndDate',
  'autoUpdateExpiration',
  'autoUpdateThrough',
  'willAutoRenew',
  ]

CROS_LIST_FIELDS_CHOICE_MAP = {
  'activetimeranges': 'activeTimeRanges',
  'cpustatusreports': 'cpuStatusReports',
  'devicefiles': 'deviceFiles',
  'diskvolumereports': 'diskVolumeReports',
  'files': 'deviceFiles',
  'lastknownnetwork': 'lastKnownNetwork',
  'recentusers': 'recentUsers',
  'screenshotfiles': 'screenshotFiles',
  'systemramfreereports': 'systemRamFreeReports',
  'timeranges': 'activeTimeRanges',
  'times': 'activeTimeRanges',
  'users': 'recentUsers',
  }

CROS_TIME_OBJECTS = {
  'createTime',
  'firstEnrollmentTime',
  'lastDeprovisionTimestamp',
  'lastEnrollmentTime',
  'lastSync',
  'rebootTime',
  'reportTime',
  'supportEndDate',
  'updateTime',
  'updateCheckTime',
  }
CROS_FIELDS_WITH_CRS_NLS = {'notes'}
CROS_START_ARGUMENTS = ['start', 'startdate', 'oldestdate']
CROS_END_ARGUMENTS = ['end', 'enddate']

# gam info cros <CrOSEntity>
# gam <CrOSTypeEntity> info
#	[basic|full|allfields] <CrOSFieldName>* [fields <CrOSFieldNameList>]
#	[nolists]
#	[start <Date>] [end <Date>] [listlimit <Number>]
#	[reverselists <CrOSListFieldNameList>]
#	[timerangeorder ascending|descending] [showdvrsfp]
#	[downloadfile latest|<Time>] [targetfolder <FilePath>]
#	[formatjson]
def infoCrOSDevices(entityList):
  cd = buildGAPIObject(API.DIRECTORY)
  downloadfile = None
  targetFolder = GC.Values[GC.DRIVE_DIR]
  projection = None
  fieldsList = []
  reverseLists = []
  noLists = showDVRstorageFreePercentage = False
  FJQC = FormatJSONQuoteChar()
  listLimit = 0
  startDate = endDate = startTime = endTime = None
  activeTimeRangesOrder = 'ASCENDING'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'nolists':
      noLists = True
    elif myarg == 'listlimit':
      listLimit = getInteger(minVal=0)
    elif myarg in CROS_START_ARGUMENTS:
      startDate, startTime = _getFilterDateTime()
    elif myarg in CROS_END_ARGUMENTS:
      endDate, endTime = _getFilterDateTime()
    elif myarg == 'timerangeorder':
      activeTimeRangesOrder = getChoice(SORTORDER_CHOICE_MAP, mapChoice=True)
    elif myarg == 'allfields':
      projection = 'FULL'
      fieldsList = []
    elif myarg in PROJECTION_CHOICE_MAP:
      projection = PROJECTION_CHOICE_MAP[myarg]
      if projection == 'FULL':
        fieldsList = []
      else:
        fieldsList = CROS_BASIC_FIELDS_LIST[:]
    elif myarg in CROS_FIELDS_CHOICE_MAP:
      addFieldToFieldsList(myarg, CROS_FIELDS_CHOICE_MAP, fieldsList)
    elif myarg == 'fields':
      for field in _getFieldsList():
        if field in CROS_FIELDS_CHOICE_MAP:
          addFieldToFieldsList(field, CROS_FIELDS_CHOICE_MAP, fieldsList)
          if field in CROS_LIST_FIELDS_CHOICE_MAP:
            projection = 'FULL'
            noLists = False
        else:
          invalidChoiceExit(field, CROS_FIELDS_CHOICE_MAP, True)
    elif myarg == 'reverselists':
      for field in _getFieldsList():
        if field in CROS_LIST_FIELDS_CHOICE_MAP:
          reverseLists.append(CROS_LIST_FIELDS_CHOICE_MAP[field])
        else:
          invalidChoiceExit(field, CROS_LIST_FIELDS_CHOICE_MAP, True)
    elif myarg == 'downloadfile':
      downloadfile = getString(Cmd.OB_STRING).lower()
      if downloadfile != 'latest':
        Cmd.Backup()
        downloadfile = formatLocalTime(getTimeOrDeltaFromNow())
    elif myarg == 'targetfolder':
      targetFolder = os.path.expanduser(getString(Cmd.OB_FILE_PATH))
      if not os.path.isdir(targetFolder):
        os.makedirs(targetFolder)
    elif myarg == 'showdvrsfp':
      showDVRstorageFreePercentage = True
    else:
      FJQC.GetFormatJSON(myarg)
  if fieldsList:
    fieldsList.append('deviceId')
    if downloadfile:
      fieldsList.append('deviceFiles.downloadUrl')
  fields = getFieldsFromFieldsList(fieldsList)
  i, count, entityList = getEntityArgument(entityList)
  for deviceId in entityList:
    i += 1
    try:
      cros = callGAPI(cd.chromeosdevices(), 'get',
                      throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                      customerId=GC.Values[GC.CUSTOMER_ID], deviceId=deviceId, projection=projection, fields=fields)
    except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
      checkEntityAFDNEorAccessErrorExit(cd, Ent.CROS_DEVICE, deviceId, i, count)
      continue
    checkTPMVulnerability(cros)
    if 'autoUpdateExpiration' in cros:
      cros['autoUpdateExpiration'] = formatLocalDatestamp(cros['autoUpdateExpiration'])
    if showDVRstorageFreePercentage:
      _computeDVRstorageFreePercentage(cros)
    for field in reverseLists:
      if field in cros:
        cros[field].reverse()
    if 'orgUnitId' in cros:
      cros['orgUnitId'] = f"id:{cros['orgUnitId']}"
    if FJQC.formatJSON:
      printLine(json.dumps(cleanJSON(cros, timeObjects=CROS_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
      continue
    printEntity([Ent.CROS_DEVICE, deviceId], i, count)
    Ind.Increment()
    for up in CROS_SCALAR_PROPERTY_PRINT_ORDER:
      if up in cros:
        if up not in CROS_TIME_OBJECTS:
          if up not in CROS_FIELDS_WITH_CRS_NLS:
            printKeyValueList([up, cros[up]])
          else:
            printKeyValueWithCRsNLs(up, cros[up])
        else:
          printKeyValueList([up, formatLocalTime(cros[up])])
    for up in ['diskSpaceUsage', 'osUpdateStatus', 'tpmVersionInfo']:
      if up in cros:
        printKeyValueList([up, ''])
        Ind.Increment()
        for key, value in sorted(iter(cros[up].items())):
          printKeyValueList([key, value])
        Ind.Decrement()
    if not noLists:
      activeTimeRanges = _filterActiveTimeRanges(cros, True, listLimit, startDate, endDate, activeTimeRangesOrder)
      if activeTimeRanges:
        printKeyValueList(['activeTimeRanges'])
        Ind.Increment()
        for activeTimeRange in activeTimeRanges:
          printKeyValueList(['date', activeTimeRange['date']])
          Ind.Increment()
          for key in ['activeTime', 'duration', 'minutes']:
            printKeyValueList([key, activeTimeRange[key]])
          Ind.Decrement()
        Ind.Decrement()
      recentUsers = _filterRecentUsers(cros, True, listLimit)
      if recentUsers:
        printKeyValueList(['recentUsers'])
        Ind.Increment()
        for recentUser in recentUsers:
          printKeyValueList(['type', recentUser['type']])
          Ind.Increment()
          printKeyValueList(['email', recentUser['email']])
          Ind.Decrement()
        Ind.Decrement()
      deviceFiles = _filterDeviceFiles(cros, True, listLimit, startTime, endTime)
      if deviceFiles:
        printKeyValueList(['deviceFiles'])
        Ind.Increment()
        for deviceFile in deviceFiles:
          printKeyValueList([deviceFile['type'], deviceFile['createTime']])
        Ind.Decrement()
        if downloadfile:
          if downloadfile == 'latest':
            deviceFile = deviceFiles[-1]
          else:
            for deviceFile in deviceFiles:
              if deviceFile['createTime'] == downloadfile:
                break
            else:
              deviceFile = None
          if deviceFile:
            downloadfilename = os.path.join(targetFolder, f'cros-logs-{deviceId}-{deviceFile["createTime"]}.zip')
            _, content = cd._http.request(deviceFile['downloadUrl'])
            writeFile(downloadfilename, content, mode='wb', continueOnError=True)
            printKeyValueList(['Downloaded', downloadfilename])
          else:
            Act.Set(Act.DOWNLOAD)
            entityActionFailedWarning([Ent.CROS_DEVICE, deviceId, Ent.DEVICE_FILE, downloadfile],
                                      Msg.DOES_NOT_EXIST, i, count)
            Act.Set(Act.INFO)
      elif downloadfile:
        Act.Set(Act.DOWNLOAD)
        entityActionNotPerformedWarning([Ent.CROS_DEVICE, deviceId, Ent.DEVICE_FILE, downloadfile],
                                        Msg.NO_ENTITIES_FOUND.format(Ent.Plural(Ent.DEVICE_FILE)), i, count)
        Act.Set(Act.INFO)
      cpuInfo = _filterBasicList(cros, 'cpuInfo', True, listLimit)
      if cpuInfo:
        showJSON('cpuInfo', cpuInfo, dictObjectsKey={'cpuInfo': 'model'})
      cpuStatusReports = _filterCPUStatusReports(cros, True, listLimit, startTime, endTime)
      if cpuStatusReports:
        printKeyValueList(['cpuStatusReports'])
        Ind.Increment()
        for cpuStatusReport in cpuStatusReports:
          printKeyValueList(['reportTime', formatLocalTime(cpuStatusReport['reportTime'])])
          Ind.Increment()
          if 'cpuTemperatureInfo' in cpuStatusReport:
            printKeyValueList(['cpuTemperatureInfo'])
            Ind.Increment()
            for tempInfo in cpuStatusReport.get('cpuTemperatureInfo', []):
              printKeyValueList([tempInfo['label'], tempInfo['temperature']])
            Ind.Decrement()
          if 'cpuUtilizationPercentageInfo' in cpuStatusReport:
            printKeyValueList(['cpuUtilizationPercentageInfo', cpuStatusReport['cpuUtilizationPercentageInfo']])
          Ind.Decrement()
        Ind.Decrement()
      backlightInfo = _filterBasicList(cros, 'backLightInfo', True, listLimit)
      if backlightInfo:
        showJSON('backlightInfo', backlightInfo, dictObjectsKey={'backlightInfo': 'path'})
      fanInfo = _filterBasicList(cros, 'fanInfo', True, listLimit)
      if fanInfo:
        showJSON('fanInfo', fanInfo)
      diskVolumeReports = _filterBasicList(cros, 'diskVolumeReports', True, listLimit)
      if diskVolumeReports:
        printKeyValueList(['diskVolumeReports'])
        Ind.Increment()
        printKeyValueList(['volumeInfo'])
        for diskVolumeReport in diskVolumeReports:
          volumeInfo = diskVolumeReport['volumeInfo']
          Ind.Increment()
          for volume in volumeInfo:
            printKeyValueList(['volumeId', volume['volumeId']])
            Ind.Increment()
            printKeyValueList(['storageFree', volume['storageFree']])
            printKeyValueList(['storageTotal', volume['storageTotal']])
            if showDVRstorageFreePercentage:
              printKeyValueList(['storageFreePercentage', volume['storageFreePercentage']])
            Ind.Decrement()
          Ind.Decrement()
        Ind.Decrement()
      lastKnownNetworks = _filterBasicList(cros, 'lastKnownNetwork', True, listLimit)
      if lastKnownNetworks:
        printKeyValueList(['lastKnownNetwork'])
        Ind.Increment()
        for lastKnownNetwork in lastKnownNetworks:
          printKeyValueList(['ipAddress', lastKnownNetwork['ipAddress']])
          Ind.Increment()
          printKeyValueList(['wanIpAddress', lastKnownNetwork['wanIpAddress']])
          Ind.Decrement()
        Ind.Decrement()
      screenshotFiles = _filterScreenshotFiles(cros, True, listLimit, startTime, endTime)
      if screenshotFiles:
        printKeyValueList(['screenshotFiles'])
        Ind.Increment()
        for screenshotFile in screenshotFiles:
          printKeyValueList(['name', screenshotFile['name']])
          Ind.Increment()
          printKeyValueList(['type', screenshotFile['type']])
          printKeyValueList(['downloadUrl', screenshotFile['downloadUrl']])
          printKeyValueList(['createTime', screenshotFile['createTime']])
          Ind.Decrement()
        Ind.Decrement()
      systemRamFreeReports = _filterSystemRamFreeReports(cros, True, listLimit, startTime, endTime)
      if systemRamFreeReports:
        printKeyValueList(['systemRamFreeReports'])
        Ind.Increment()
        for systemRamFreeReport in systemRamFreeReports:
          printKeyValueList(['reportTime', systemRamFreeReport['reportTime']])
          Ind.Increment()
          printKeyValueList(['systemRamFreeInfo', systemRamFreeReport['systemRamFreeInfo']])
          Ind.Decrement()
        Ind.Decrement()
    Ind.Decrement()

def doInfoCrOSDevices():
  infoCrOSDevices(getCrOSDeviceEntity())

def getDeviceFilesEntity():
  deviceFilesEntity = {'list': [], 'dict': None, 'count': None, 'time': None, 'range': None}
  startEndTime = StartEndTime()
  entitySelector = getEntitySelector()
  if entitySelector:
    entityList = getEntitySelection(entitySelector, False)
    if isinstance(entityList, dict):
      deviceFilesEntity['dict'] = entityList
    else:
      deviceFilesEntity['list'] = entityList
  else:
    myarg = getString(Cmd.OB_DEVICE_FILE_ENTITY, checkBlank=True)
    mycmd = myarg.lower()
    if mycmd in {'first', 'last', 'allexceptfirst', 'allexceptlast'}:
      deviceFilesEntity['count'] = (mycmd, getInteger(minVal=1))
    elif mycmd in {'before', 'after'}:
      dateTime, _, _ = getTimeOrDeltaFromNow(True)
      deviceFilesEntity['time'] = (mycmd, dateTime)
    elif mycmd == 'range':
      startEndTime.Get(mycmd)
      deviceFilesEntity['range'] = (mycmd, startEndTime.startDateTime, startEndTime.endDateTime)
    else:
      for timeItem in myarg.split(','):
        try:
          timestamp, _ = iso8601.parse_date(timeItem)
          deviceFilesEntity['list'].append(ISOformatTimeStamp(timestamp.astimezone(GC.Values[GC.TIMEZONE])))
        except (iso8601.ParseError, OverflowError):
          Cmd.Backup()
          invalidArgumentExit(YYYYMMDDTHHMMSS_FORMAT_REQUIRED)
  return deviceFilesEntity

def _selectDeviceFiles(deviceId, deviceFiles, deviceFilesEntity):
  numDeviceFiles = len(deviceFiles)
  if numDeviceFiles == 0:
    return deviceFiles
  for deviceFile in deviceFiles:
    deviceFile['createTime'] = formatLocalTime(deviceFile['createTime'])
  if deviceFilesEntity['count']:
    countType = deviceFilesEntity['count'][0]
    count = deviceFilesEntity['count'][1]
    if countType == 'first':
      return deviceFiles[:count]
    if countType == 'last':
      return deviceFiles[-count:]
    if countType == 'allexceptfirst':
      return deviceFiles[count:]
#   if countType == 'allexceptlast':
    return deviceFiles[:-count]
  if deviceFilesEntity['time']:
    dateTime = deviceFilesEntity['time'][1]
    count = 0
    if deviceFilesEntity['time'][0] == 'before':
      for deviceFile in deviceFiles:
        createTime, _ = iso8601.parse_date(deviceFile['createTime'])
        if createTime >= dateTime:
          break
        count += 1
      return deviceFiles[:count]
#   if deviceFilesEntity['time'][0] == 'after':
    for deviceFile in deviceFiles:
      createTime, _ = iso8601.parse_date(deviceFile['createTime'])
      if createTime >= dateTime:
        break
      count += 1
    return deviceFiles[count:]
  if deviceFilesEntity['range']:
    dateTime = deviceFilesEntity['range'][1]
    spos = 0
    for deviceFile in deviceFiles:
      createTime, _ = iso8601.parse_date(deviceFile['createTime'])
      if createTime >= dateTime:
        break
      spos += 1
    dateTime = deviceFilesEntity['range'][2]
    epos = spos
    for deviceFile in deviceFiles[spos:]:
      createTime, _ = iso8601.parse_date(deviceFile['createTime'])
      if createTime >= dateTime:
        break
      epos += 1
    return deviceFiles[spos:epos]
#  if deviceFilesEntity['range'] or deviceFilesEntity['dict']:
  if deviceFilesEntity['dict']:
    deviceFileCreateTimes = deviceFilesEntity['dict'][deviceId]
  else:
    deviceFileCreateTimes = deviceFilesEntity['list']
  return [deviceFile for deviceFile in deviceFiles if deviceFile['createTime'] in deviceFileCreateTimes]

# gam <CrOSTypeEntity> get devicefile [select <DeviceFileEntity>] [targetfolder <FilePath>]
def getCrOSDeviceFiles(entityList):
  cd = buildGAPIObject(API.DIRECTORY)
  targetFolder = GC.Values[GC.DRIVE_DIR]
  deviceFilesEntity = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'select':
      deviceFilesEntity = getDeviceFilesEntity()
    elif myarg == 'targetfolder':
      targetFolder = os.path.expanduser(getString(Cmd.OB_FILE_PATH))
      if not os.path.isdir(targetFolder):
        os.makedirs(targetFolder)
    else:
      unknownArgumentExit()
  fields = 'deviceFiles(type,createTime,downloadUrl)'
  i, count, entityList = getEntityArgument(entityList)
  for deviceId in entityList:
    i += 1
    try:
      deviceFiles = callGAPIitems(cd.chromeosdevices(), 'get', 'deviceFiles',
                                  throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                                  customerId=GC.Values[GC.CUSTOMER_ID], deviceId=deviceId, fields=fields)
      if deviceFilesEntity:
        deviceFiles = _selectDeviceFiles(deviceId, deviceFiles, deviceFilesEntity)
      jcount = len(deviceFiles)
      entityPerformActionNumItems([Ent.CROS_DEVICE, deviceId], jcount, Ent.DEVICE_FILE, i, count)
      Ind.Increment()
      j = 0
      for deviceFile in deviceFiles:
        j += 1
        downloadfilename = os.path.join(targetFolder, f'cros-logs-{deviceId}-{deviceFile["createTime"]}.zip')
        _, content = cd._http.request(deviceFile['downloadUrl'])
        writeFile(downloadfilename, content, mode='wb', continueOnError=True)
        entityActionPerformed([Ent.DEVICE_FILE, downloadfilename], j, jcount)
      Ind.Decrement()
    except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
      checkEntityAFDNEorAccessErrorExit(cd, Ent.CROS_DEVICE, deviceId, i, count)

# gam get devicefile <CrOSEntity> [select <DeviceFileEntity>] [targetfolder <FilePath>]
def doGetCrOSDeviceFiles():
  getCrOSDeviceFiles(getCrOSDeviceEntity())

def substituteQueryTimes(queries, queryTimes):
  if queryTimes:
    for i, query in enumerate(queries):
      if query is not None:
        for queryTimeName, queryTimeValue in iter(queryTimes.items()):
          query = query.replace(f'#{queryTimeName}#', queryTimeValue)
        queries[i] = query

# Get CrOS devices from gam.cfg print_cros_ous and print_cros_ous_and_children
def getCfgCrOSEntities():
  if GC.Values[GC.PRINT_CROS_OUS]:
    entityList = getItemsToModify(Cmd.ENTITY_CROS_OUS, GC.Values[GC.PRINT_CROS_OUS])
  else:
    entityList = []
  if GC.Values[GC.PRINT_CROS_OUS_AND_CHILDREN]:
    entityList.extend(getItemsToModify(Cmd.ENTITY_CROS_OUS_AND_CHILDREN, GC.Values[GC.PRINT_CROS_OUS_AND_CHILDREN]))
  return entityList

CROS_ORDERBY_CHOICE_MAP = {
  'lastsync': 'lastSync',
  'location': 'annotatedLocation',
  'notes': 'notes',
  'serialnumber': 'serialNumber',
  'status': 'status',
  'supportenddate': 'supportEndDate',
  'user': 'annotatedUser',
  }

CROS_ENTITIES_MAP = {
  'crosorg': Cmd.ENTITY_CROS_OU,
  'crosorgandchildren': Cmd.ENTITY_CROS_OU_AND_CHILDREN,
  'crosorgs': Cmd.ENTITY_CROS_OUS,
  'crosorgsandchildren': Cmd.ENTITY_CROS_OUS_AND_CHILDREN,
  'crosou': Cmd.ENTITY_CROS_OU,
  'crosouandchildren': Cmd.ENTITY_CROS_OU_AND_CHILDREN,
  'crosous': Cmd.ENTITY_CROS_OUS,
  'crosousandchildren': Cmd.ENTITY_CROS_OUS_AND_CHILDREN
  }

CROS_INDEXED_TITLES = ['activeTimeRanges', 'recentUsers', 'deviceFiles',
                       'cpuStatusReports', 'cpuInfo', 'backlightInfo', 'fanInfo', 'diskVolumeReports', 'lastKnownNetwork', 'screenshotFiles', 'systemRamFreeReports']

# gam print cros [todrive <ToDriveAttribute>*]
#	[(query <QueryCrOS>)|(queries <QueryCrOSList>) [querytime<String> <Time>]
#	 [(limittoou|cros_ou <OrgUnitItem>)|(cros_ou_and_children <OrgUnitItem>)|
#	  (cros_ous <OrgUnitList>)|(cros_ous_and_children <OrgUnitList>)]]
# 	[showitemcountonly]
# gam print cros [todrive <ToDriveAttribute>*] select <CrOSTypeEntity>
# gam <CrOSTypeEntity> print cros [todrive <ToDriveAttribute>*]
#	[orderby <CrOSOrderByFieldName> [ascending|descending]]
#	[basic|full|allfields] <CrOSFieldName>* [fields <CrOSFieldNameList>]
#	[nolists|(<CrOSListFieldName>* [onerow])]
#	[start <Date>] [end <Date>] [listlimit <Number>]
#	[reverselists <CrOSListFieldNameList>]
#	[timerangeorder ascending|descending] [showdvrsfp]
#	(addcsvdata <FieldName> <String>)*
#	[sortheaders]
#	[formatjson [quotechar <Character>]]
# 	[showitemcountonly]
def doPrintCrOSDevices(entityList=None):
  def _printCrOS(cros):
    checkTPMVulnerability(cros)
    if 'autoUpdateExpiration' in cros:
      cros['autoUpdateExpiration'] = formatLocalDatestamp(cros['autoUpdateExpiration'])
    if showDVRstorageFreePercentage:
      _computeDVRstorageFreePercentage(cros)
    for field in reverseLists:
      if field in cros:
        cros[field].reverse()
    if 'orgUnitId' in cros:
      cros['orgUnitId'] = f"id:{cros['orgUnitId']}"
    if addCSVData:
      cros.update(addCSVData)
    if FJQC.formatJSON:
      if (not csvPF.rowFilter and not csvPF.rowDropFilter) or csvPF.CheckRowTitles(flattenJSON(cros, listLimit=listLimit, timeObjects=CROS_TIME_OBJECTS)):
        csvPF.WriteRowNoFilter({'deviceId': cros['deviceId'],
                                'JSON': json.dumps(cleanJSON(cros, listLimit=listLimit, timeObjects=CROS_TIME_OBJECTS),
                                                   ensure_ascii=False, sort_keys=True)})
      return
    if 'notes' in cros:
      cros['notes'] = escapeCRsNLs(cros['notes'])
    for cpuStatusReport in cros.get('cpuStatusReports', []):
      for tempInfo in cpuStatusReport.get('cpuTemperatureInfo', []):
        tempInfo['label'] = tempInfo['label'].strip()
    if not noLists and not selectedLists:
      csvPF.WriteRowTitles(flattenJSON(cros, listLimit=listLimit, timeObjects=CROS_TIME_OBJECTS))
      return
    for attrib in ['diskSpaceUsage', 'osUpdateStatus', 'tpmVersionInfo']:
      if attrib in cros:
        for key, value in sorted(iter(cros[attrib].items())):
          attribKey = f'{attrib}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{key}'
          if key not in CROS_TIME_OBJECTS:
            cros[attribKey] = value
          else:
            cros[attribKey] = formatLocalTime(value)
        cros.pop(attrib)
    activeTimeRanges = _filterActiveTimeRanges(cros, selectedLists.get('activeTimeRanges', False), listLimit, startDate, endDate, activeTimeRangesOrder)
    recentUsers = _filterRecentUsers(cros, selectedLists.get('recentUsers', False), listLimit)
    deviceFiles = _filterDeviceFiles(cros, selectedLists.get('deviceFiles', False), listLimit, startTime, endTime)
    cpuStatusReports = _filterCPUStatusReports(cros, selectedLists.get('cpuStatusReports', False), listLimit, startTime, endTime)
    diskVolumeReports = _filterBasicList(cros, 'diskVolumeReports', selectedLists.get('diskVolumeReports', False), listLimit)
    lastKnownNetworks = _filterBasicList(cros, 'lastKnownNetwork', selectedLists.get('lastKnownNetwork', False), listLimit)
    screenshotFiles = _filterScreenshotFiles(cros, selectedLists.get('screenshotFiles', False), listLimit, startTime, endTime)
    systemRamFreeReports = _filterSystemRamFreeReports(cros, selectedLists.get('systemRamFreeReports', False), listLimit, startTime, endTime)
    if oneRow:
      csvPF.WriteRowTitles(flattenJSON(cros, listLimit=listLimit, timeObjects=CROS_TIME_OBJECTS))
      return
    row = {}
    for attrib in cros:
      if attrib in {'cpuInfo', 'backlightInfo', 'fanInfo'}:
        flattenJSON({attrib: cros[attrib]}, flattened=row)
      elif attrib not in {'kind', 'etag', 'diskSpaceUsage', 'osUpdateStatus', 'tpmVersionInfo', 'activeTimeRanges', 'recentUsers',
                          'deviceFiles', 'cpuStatusReports', 'diskVolumeReports', 'lastKnownNetwork', 'screenshotFiles', 'systemRamFreeReports'}:
        if attrib not in CROS_TIME_OBJECTS:
          row[attrib] = cros[attrib]
        else:
          row[attrib] = formatLocalTime(cros[attrib])
    if addCSVData:
      row.update(addCSVData)
    if noLists or (not activeTimeRanges and not recentUsers and not deviceFiles and
                   not cpuStatusReports and not diskVolumeReports and not lastKnownNetworks and not screenshotFiles and not systemRamFreeReports):
      csvPF.WriteRowTitles(row)
      return
    lenATR = len(activeTimeRanges)
    lenRU = len(recentUsers)
    lenDF = len(deviceFiles)
    lenCSR = len(cpuStatusReports)
    lenDVR = len(diskVolumeReports)
    lenLKN = len(lastKnownNetworks)
    lenSSF = len(screenshotFiles)
    lenSRFR = len(systemRamFreeReports)
    new_row = row
    for i in range(min(max(lenATR, lenRU, lenDF, lenCSR, lenDVR, lenLKN, lenSSF, lenSRFR), listLimit or max(lenATR, lenRU, lenDF, lenCSR, lenDVR, lenLKN, lenSSF, lenSRFR))):
      new_row = row.copy()
      if i < lenATR:
        for key in ['date', 'activeTime', 'duration', 'minutes']:
          new_row[f'activeTimeRanges{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{key}'] = activeTimeRanges[i][key]
      if i < lenRU:
        for key in ['email', 'type']:
          new_row[f'recentUsers{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{key}'] = recentUsers[i][key]
      if i < lenDF:
        for key in ['type', 'createTime']:
          new_row[f'deviceFiles{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{key}'] = deviceFiles[i][key]
      if i < lenCSR:
        new_row[f'cpuStatusReports{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}reportTime'] = cpuStatusReports[i]['reportTime']
        for tempInfo in cpuStatusReports[i].get('cpuTemperatureInfo', []):
          new_row[f'cpuStatusReports{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}cpuTemperatureInfo{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{tempInfo["label"]}'] = tempInfo['temperature']
        if 'cpuUtilizationPercentageInfo' in cpuStatusReports[i]:
          new_row[f'cpuStatusReports{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}cpuUtilizationPercentageInfo'] = cpuStatusReports[i]['cpuUtilizationPercentageInfo']
      if i < lenDVR:
        j = 0
        for volume in diskVolumeReports[i]['volumeInfo']:
          new_row[f'diskVolumeReports{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}volumeInfo{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{j}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}volumeId'] = volume['volumeId']
          new_row[f'diskVolumeReports{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}volumeInfo{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{j}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}storageFree'] = volume['storageFree']
          new_row[f'diskVolumeReports{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}volumeInfo{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{j}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}storageTotal'] = volume['storageTotal']
          if showDVRstorageFreePercentage:
            new_row[f'diskVolumeReports{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}volumeInfo{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{j}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}storageFreePercentage'] = volume['storageFreePercentage']
          j += 1
      if i < lenLKN:
        for key in ['ipAddress', 'wanIpAddress']:
          if key in lastKnownNetworks[i]:
            new_row[f'lastKnownNetwork{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{key}'] = lastKnownNetworks[i][key]
      if i < lenSSF:
        for key in ['name', 'type', 'downloadUrl', 'createTime']:
          if key in screenshotFiles[i]:
            new_row[f'screenshotFiles{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{key}'] = screenshotFiles[i][key]
      if i < lenSRFR:
        for key in ['reportTime', 'systemRamFreeInfo']:
          new_row[f'systemRamFreeReports{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{key}'] = systemRamFreeReports[i][key]
      csvPF.WriteRowTitles(new_row)

  def _callbackPrintCrOS(request_id, response, exception):
    ri = request_id.splitlines()
    if exception is None:
      _printCrOS(response)
    else:
      http_status, reason, message = checkGAPIError(exception)
      if reason in [GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN]:
        checkEntityAFDNEorAccessErrorExit(cd, Ent.CROS_DEVICE, ri[RI_ITEM], int(ri[RI_J]), int(ri[RI_JCOUNT]))
      else:
        errMsg = getHTTPError({}, http_status, reason, message)
        entityActionFailedWarning([Ent.CROS_DEVICE, ri[RI_ITEM]], errMsg, int(ri[RI_J]), int(ri[RI_JCOUNT]))

  cd = buildGAPIObject(API.DIRECTORY)
  fieldsList = ['deviceId']
  reverseLists = []
  csvPF = CSVPrintFile(fieldsList, indexedTitles=CROS_INDEXED_TITLES)
  FJQC = FormatJSONQuoteChar(csvPF)
  projection = orderBy = sortOrder = None
  ous = ['/']
  includeChildOrgunits = True
  queries = [None]
  listLimit = 0
  startDate = endDate = startTime = endTime = None
  selectedLists = {}
  queryTimes = {}
  selectionAllowed = entityList is None
  if selectionAllowed and (GC.Values[GC.PRINT_CROS_OUS] or GC.Values[GC.PRINT_CROS_OUS_AND_CHILDREN]):
    entityList = getCfgCrOSEntities()
    selectionAllowed = False
  allFields = noLists = oneRow = showDVRstorageFreePercentage = sortHeaders = False
  activeTimeRangesOrder = 'ASCENDING'
  showItemCountOnly = False
  addCSVData = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif selectionAllowed and myarg == 'limittoou':
      ous = [getOrgUnitItem()]
      selectionAllowed = False
      includeChildOrgunits = False
    elif selectionAllowed and myarg in CROS_ENTITIES_MAP:
      myarg = CROS_ENTITIES_MAP[myarg]
      ous = convertEntityToList(getString(Cmd.OB_CROS_ENTITY, minLen=0), shlexSplit=True, nonListEntityType=myarg in [Cmd.ENTITY_CROS_OU, Cmd.ENTITY_CROS_OU_AND_CHILDREN])
      selectionAllowed = False
      includeChildOrgunits = myarg in {Cmd.ENTITY_CROS_OU_AND_CHILDREN, Cmd.ENTITY_CROS_OUS_AND_CHILDREN}
    elif (selectionAllowed or queries == [None]) and myarg in {'query', 'queries'}:
      queries = getQueries(myarg)
    elif myarg.startswith('querytime'):
      queryTimes[myarg] = getTimeOrDeltaFromNow()[0:19]
    elif selectionAllowed and myarg == 'select':
      _, entityList = getEntityToModify(defaultEntityType=Cmd.ENTITY_CROS, crosAllowed=True, userAllowed=False)
      selectionAllowed = False
    elif myarg == 'orderby':
      orderBy, sortOrder = getOrderBySortOrder(CROS_ORDERBY_CHOICE_MAP, 'DESCENDING', True)
    elif myarg == 'onerow':
      oneRow = True
    elif myarg == 'nolists':
      noLists = True
      selectedLists = {}
    elif myarg == 'listlimit':
      listLimit = getInteger(minVal=0)
    elif myarg in CROS_START_ARGUMENTS:
      startDate, startTime = _getFilterDateTime()
    elif myarg in CROS_END_ARGUMENTS:
      endDate, endTime = _getFilterDateTime()
    elif myarg == 'timerangeorder':
      activeTimeRangesOrder = getChoice(SORTORDER_CHOICE_MAP, mapChoice=True)
    elif myarg in PROJECTION_CHOICE_MAP:
      projection = PROJECTION_CHOICE_MAP[myarg]
      sortHeaders = True
      if projection == 'FULL':
        fieldsList = []
      else:
        fieldsList = CROS_BASIC_FIELDS_LIST[:]
    elif myarg == 'allfields':
      projection = 'FULL'
      allFields = sortHeaders = True
      fieldsList = []
    elif myarg == 'sortheaders':
      sortHeaders = getBoolean()
    elif myarg in CROS_LIST_FIELDS_CHOICE_MAP:
      selectedLists[CROS_LIST_FIELDS_CHOICE_MAP[myarg]] = True
    elif myarg in CROS_FIELDS_CHOICE_MAP:
      csvPF.AddField(myarg, CROS_FIELDS_CHOICE_MAP, fieldsList)
    elif myarg == 'fields':
      for field in _getFieldsList():
        if field in CROS_FIELDS_CHOICE_MAP:
          if field in CROS_LIST_FIELDS_CHOICE_MAP:
            selectedLists[CROS_LIST_FIELDS_CHOICE_MAP[field]] = True
          else:
            csvPF.AddField(field, CROS_FIELDS_CHOICE_MAP, fieldsList)
        else:
          invalidChoiceExit(field, CROS_FIELDS_CHOICE_MAP, True)
    elif myarg == 'reverselists':
      for field in _getFieldsList():
        if field in CROS_LIST_FIELDS_CHOICE_MAP:
          reverseLists.append(CROS_LIST_FIELDS_CHOICE_MAP[field])
        else:
          invalidChoiceExit(field, CROS_LIST_FIELDS_CHOICE_MAP, True)
    elif myarg == 'showdvrsfp':
      showDVRstorageFreePercentage = True
    elif myarg == 'showitemcountonly':
      showItemCountOnly = True
    elif csvPF and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  if selectedLists:
    noLists = False
    projection = 'FULL'
    for selectList in selectedLists:
      addFieldToFieldsList(selectList, CROS_FIELDS_CHOICE_MAP, fieldsList)
  if fieldsList:
    fieldsList.append('deviceId')
  _, _, entityList = getEntityArgument(entityList)
  if FJQC.formatJSON:
    sortHeaders = False
    csvPF.SetJSONTitles(['deviceId', 'JSON'])
  substituteQueryTimes(queries, queryTimes)
  if entityList is None:
    sortRows = False
    fields = getItemFieldsFromFieldsList('chromeosdevices', fieldsList)
    for ou in ous:
      ou = makeOrgUnitPathAbsolute(ou)
      oneQualifier = Msg.DIRECTLY_IN_THE.format(Ent.Singular(Ent.ORGANIZATIONAL_UNIT)) if not includeChildOrgunits else Msg.IN_THE.format(Ent.Singular(Ent.ORGANIZATIONAL_UNIT))
      for query in queries:
        printGettingAllEntityItemsForWhom(Ent.CROS_DEVICE, ou,
                                          query=query, qualifier=oneQualifier, entityType=Ent.ORGANIZATIONAL_UNIT)
        pageMessage = getPageMessageForWhom()
        pageToken = None
        totalItems = 0
        tokenRetries = 0
        while True:
          try:
            feed = callGAPI(cd.chromeosdevices(), 'list',
                            throwReasons=[GAPI.INVALID_INPUT, GAPI.INVALID_ORGUNIT,
                                          GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                            pageToken=pageToken,
                            customerId=GC.Values[GC.CUSTOMER_ID], query=query, projection=projection,
                            orgUnitPath=ou, includeChildOrgunits=includeChildOrgunits,
                            orderBy=orderBy, sortOrder=sortOrder, fields=fields, maxResults=GC.Values[GC.DEVICE_MAX_RESULTS])
            tokenRetries = 0
            pageToken, totalItems = _processGAPIpagesResult(feed, 'chromeosdevices', None, totalItems, pageMessage, None, Ent.CROS_DEVICE)
            if feed:
              if not showItemCountOnly:
                for cros in feed.get('chromeosdevices', []):
                  _printCrOS(cros)
              del feed
            if not pageToken:
              _finalizeGAPIpagesResult(pageMessage)
              printGotAccountEntities(totalItems)
              break
          except GAPI.invalidInput as e:
            message = str(e)
# Invalid Input: xyz - Check for invalid pageToken!!
# 0123456789012345
            if message[15:] == pageToken:
              tokenRetries += 1
              if tokenRetries <= 2:
                writeStderr(f'{WARNING_PREFIX}{Msg.LIST_CHROMEOS_INVALID_INPUT_PAGE_TOKEN_RETRY}')
                time.sleep(tokenRetries*5)
                continue
              entityActionFailedWarning([Ent.CROS_DEVICE, None], message)
              return
            entityActionFailedWarning([Ent.CROS_DEVICE, None], invalidQuery(query) if query is not None else message)
            return
          except GAPI.invalidOrgunit as e:
            entityActionFailedWarning([Ent.CROS_DEVICE, None], str(e))
            return
          except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
            accessErrorExit(cd)
    if showItemCountOnly:
      writeStdout(f'{totalItems}\n')
      return
  else:
    if showItemCountOnly:
      writeStdout(f'{len(entityList)}\n')
      return
    sortRows = True
    if allFields or len(set(fieldsList)) > 1:
      jcount = len(entityList)
      fields = getFieldsFromFieldsList(fieldsList)
      svcargs = dict([('customerId', GC.Values[GC.CUSTOMER_ID]), ('deviceId', None), ('projection', projection), ('fields', fields)]+GM.Globals[GM.EXTRA_ARGS_LIST])
      method = getattr(cd.chromeosdevices(), 'get')
      dbatch = cd.new_batch_http_request(callback=_callbackPrintCrOS)
      bcount = 0
      j = 0
      for deviceId in entityList:
        j += 1
        svcparms = svcargs.copy()
        svcparms['deviceId'] = deviceId
        dbatch.add(method(**svcparms), request_id=batchRequestID('', 0, 0, j, jcount, deviceId))
        bcount += 1
        if bcount >= GC.Values[GC.BATCH_SIZE]:
          executeBatch(dbatch)
          dbatch = cd.new_batch_http_request(callback=_callbackPrintCrOS)
          bcount = 0
      if bcount > 0:
        dbatch.execute()
# The only field specified was deviceId, just list the CrOS devices
    else:
      for cros in entityList:
        _printCrOS({'deviceId': cros})
  if sortRows and orderBy:
    csvPF.SortRows(orderBy, reverse=sortOrder == 'DESCENDING')
  if sortHeaders:
    csvPF.SetSortTitles(['deviceId'])
  csvPF.writeCSVfile('CrOS')

CROS_ACTIVITY_LIST_FIELDS_CHOICE_MAP = {
  'activetimeranges': 'activeTimeRanges',
  'devicefiles': 'deviceFiles',
  'files': 'deviceFiles',
  'recentusers': 'recentUsers',
  'timeranges': 'activeTimeRanges',
  'times': 'activeTimeRanges',
  'users': 'recentUsers',
  }
CROS_ACTIVITY_TIME_OBJECTS = {'createTime'}

# gam print crosactivity [todrive <ToDriveAttribute>*]
#	[(query <QueryCrOS>)|(queries <QueryCrOSList>) [querytime<String> <Time>]
#	 [(limittoou|cros_ou <OrgUnitItem>)|(cros_ou_and_children <OrgUnitItem>)|
#	  (cros_ous <OrgUnitList>)|(cros_ous_and_children <OrgUnitList>)]]
# gam print crosactivity [todrive <ToDriveAttribute>*] select <CrOSTypeEntity>
# gam <CrOSTypeEntity> print crosactivity [todrive <ToDriveAttribute>*]
#	[orderby <CrOSOrderByFieldName> [ascending|descending]]
#	[recentusers] [timeranges] [both] [devicefiles] [all] [oneuserperrow]
#	[start <Date>] [end <Date>] [listlimit <Number>]
#	[reverselists <CrOSActivityListFieldNameList>]
#	[timerangeorder ascending|descending]
#	[delimiter <Character>]
#	[formatjson [quotechar <Character>]]
def doPrintCrOSActivity(entityList=None):
  def _printCrOS(cros):
    row = {}
    for field in reverseLists:
      if field in cros:
        cros[field].reverse()
    if 'orgUnitId' in cros:
      cros['orgUnitId'] = f"id:{cros['orgUnitId']}"
    if FJQC.formatJSON:
      if (not csvPF.rowFilter and not csvPF.rowDropFilter) or csvPF.CheckRowTitles(flattenJSON(cros, listLimit=listLimit, timeObjects=CROS_ACTIVITY_TIME_OBJECTS)):
        csvPF.WriteRowNoFilter({'deviceId': cros['deviceId'],
                                'JSON': json.dumps(cleanJSON(cros, timeObjects=CROS_ACTIVITY_TIME_OBJECTS),
                                                   ensure_ascii=False, sort_keys=True)})
      return
    for attrib in cros:
      if attrib not in {'recentUsers', 'activeTimeRanges', 'deviceFiles'}:
        row[attrib] = cros[attrib]
    for activeTimeRange in _filterActiveTimeRanges(cros, selectedLists.get('activeTimeRanges', False), listLimit, startDate, endDate, activeTimeRangesOrder):
      new_row = row.copy()
      for key in ['date', 'duration', 'minutes']:
        new_row[f'activeTimeRanges{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{key}'] = activeTimeRange[key]
      csvPF.WriteRow(new_row)
    recentUsers = _filterRecentUsers(cros, selectedLists.get('recentUsers', False), listLimit)
    if recentUsers:
      if not oneUserPerRow:
        new_row = row.copy()
        new_row[f'recentUsers{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}email'] = delimiter.join([recentUser['email'] for recentUser in recentUsers])
        csvPF.WriteRow(new_row)
      else:
        for recentUser in recentUsers:
          new_row = row.copy()
          new_row[f'recentUsers{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}email'] = recentUser['email']
          csvPF.WriteRow(new_row)
    for deviceFile in _filterDeviceFiles(cros, selectedLists.get('deviceFiles', False), listLimit, startTime, endTime):
      new_row = row.copy()
      for key in ['type', 'createTime']:
        new_row[f'deviceFiles{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{key}'] = deviceFile[key]
      csvPF.WriteRow(new_row)

  def _callbackPrintCrOS(request_id, response, exception):
    ri = request_id.splitlines()
    if exception is None:
      _printCrOS(response)
    else:
      http_status, reason, message = checkGAPIError(exception)
      if reason in [GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN]:
        checkEntityAFDNEorAccessErrorExit(cd, Ent.CROS_DEVICE, ri[RI_ITEM], int(ri[RI_J]), int(ri[RI_JCOUNT]))
      else:
        errMsg = getHTTPError({}, http_status, reason, message)
        entityActionFailedWarning([Ent.CROS_DEVICE, ri[RI_ITEM]], errMsg, int(ri[RI_J]), int(ri[RI_JCOUNT]))

  cd = buildGAPIObject(API.DIRECTORY)
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  fieldsList = ['deviceId', 'annotatedAssetId', 'annotatedLocation', 'serialNumber', 'orgUnitId', 'orgUnitPath']
  reverseLists = []
  csvPF = CSVPrintFile(fieldsList)
  FJQC = FormatJSONQuoteChar(csvPF)
  projection = 'FULL'
  orderBy = sortOrder = None
  ous = [None]
  queries = [None]
  listLimit = 0
  startDate = endDate = startTime = endTime = None
  selectedLists = {}
  queryTimes = {}
  selectionAllowed = entityList is None
  if selectionAllowed and (GC.Values[GC.PRINT_CROS_OUS] or GC.Values[GC.PRINT_CROS_OUS_AND_CHILDREN]):
    entityList = getCfgCrOSEntities()
    selectionAllowed = False
  oneUserPerRow = False
  directlyInOU = True
  activeTimeRangesOrder = 'ASCENDING'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif selectionAllowed and myarg == 'limittoou':
      ous = [getOrgUnitItem()]
      selectionAllowed = False
      directlyInOU = True
    elif selectionAllowed and myarg in CROS_ENTITIES_MAP:
      myarg = CROS_ENTITIES_MAP[myarg]
      ous = convertEntityToList(getString(Cmd.OB_CROS_ENTITY, minLen=0), shlexSplit=True, nonListEntityType=myarg in [Cmd.ENTITY_CROS_OU, Cmd.ENTITY_CROS_OU_AND_CHILDREN])
      selectionAllowed = False
      directlyInOU = myarg in {Cmd.ENTITY_CROS_OU, Cmd.ENTITY_CROS_OUS}
    elif (selectionAllowed or queries == [None]) and myarg in {'query', 'queries'}:
      queries = getQueries(myarg)
    elif myarg.startswith('querytime'):
      queryTimes[myarg] = getTimeOrDeltaFromNow()[0:19]
    elif selectionAllowed and myarg == 'select':
      _, entityList = getEntityToModify(defaultEntityType=Cmd.ENTITY_CROS, crosAllowed=True, userAllowed=False)
      selectionAllowed = False
    elif myarg == 'oneuserperrow':
      oneUserPerRow = True
    elif myarg == 'listlimit':
      listLimit = getInteger(minVal=0)
    elif myarg in CROS_START_ARGUMENTS:
      startDate, startTime = _getFilterDateTime()
    elif myarg in CROS_END_ARGUMENTS:
      endDate, endTime = _getFilterDateTime()
    elif myarg == 'timerangeorder':
      activeTimeRangesOrder = getChoice(SORTORDER_CHOICE_MAP, mapChoice=True)
    elif myarg in CROS_ACTIVITY_LIST_FIELDS_CHOICE_MAP:
      selectedLists[CROS_ACTIVITY_LIST_FIELDS_CHOICE_MAP[myarg]] = True
    elif myarg == 'both':
      selectedLists['activeTimeRanges'] = selectedLists['recentUsers'] = True
    elif myarg == 'all':
      selectedLists['activeTimeRanges'] = selectedLists['recentUsers'] = selectedLists['deviceFiles'] = True
    elif myarg == 'reverselists':
      for field in _getFieldsList():
        if field in CROS_ACTIVITY_LIST_FIELDS_CHOICE_MAP:
          reverseLists.append(CROS_ACTIVITY_LIST_FIELDS_CHOICE_MAP[field])
        else:
          invalidChoiceExit(field, CROS_ACTIVITY_LIST_FIELDS_CHOICE_MAP, True)
    elif myarg == 'orderby':
      orderBy, sortOrder = getOrderBySortOrder(CROS_ORDERBY_CHOICE_MAP, 'DESCENDING', True)
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  if not selectedLists:
    selectedLists['activeTimeRanges'] = selectedLists['recentUsers'] = True
  if selectedLists.get('recentUsers', False):
    fieldsList.append('recentUsers')
    csvPF.AddTitles(f'recentUsers{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}email')
  if selectedLists.get('activeTimeRanges', False):
    fieldsList.append('activeTimeRanges')
    csvPF.AddTitles([f'activeTimeRanges{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}date',
                     f'activeTimeRanges{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}duration',
                     f'activeTimeRanges{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}minutes'])
  if selectedLists.get('deviceFiles', False):
    fieldsList.append('deviceFiles')
    csvPF.AddTitles([f'deviceFiles{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}type',
                     f'deviceFiles{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}createTime'])
  _, _, entityList = getEntityArgument(entityList)
  if FJQC.formatJSON:
    csvPF.SetJSONTitles(['deviceId', 'JSON'])
  substituteQueryTimes(queries, queryTimes)
  if entityList is None:
    sortRows = False
    fields = getItemFieldsFromFieldsList('chromeosdevices', fieldsList)
    for ou in ous:
      if ou is not None:
        ou = makeOrgUnitPathAbsolute(ou)
      ouList = [ou]
      if not directlyInOU:
        try:
          orgs = callGAPI(cd.orgunits(), 'list',
                          throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                          customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=makeOrgUnitPathRelative(ou),
                          type='all', fields='organizationUnits(orgUnitPath)')
        except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError, GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
          checkEntityDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, ou)
          return
        ouList.extend([subou['orgUnitPath'] for subou in sorted(orgs.get('organizationUnits', []), key=lambda k: k['orgUnitPath'])])
      for subou in ouList:
        if subou is not None:
          orgUnitPath = makeOrgUnitPathAbsolute(subou)
        else:
          orgUnitPath = subou
        for query in queries:
          if orgUnitPath is not None:
            oneQualifier = Msg.DIRECTLY_IN_THE.format(Ent.Singular(Ent.ORGANIZATIONAL_UNIT))
            printGettingAllEntityItemsForWhom(Ent.CROS_DEVICE, orgUnitPath, qualifier=oneQualifier, entityType=Ent.ORGANIZATIONAL_UNIT)
            pageMessage = getPageMessageForWhom()
          else:
            printGettingAllAccountEntities(Ent.CROS_DEVICE, query)
            pageMessage = getPageMessage()
          pageToken = None
          totalItems = 0
          tokenRetries = 0
          while True:
            try:
              feed = callGAPI(cd.chromeosdevices(), 'list',
                              throwReasons=[GAPI.INVALID_INPUT, GAPI.INVALID_ORGUNIT,
                                            GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                              retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                              pageToken=pageToken,
                              customerId=GC.Values[GC.CUSTOMER_ID], query=query, projection=projection, orgUnitPath=orgUnitPath,
                              orderBy=orderBy, sortOrder=sortOrder, fields=fields, maxResults=GC.Values[GC.DEVICE_MAX_RESULTS])
              tokenRetries = 0
              pageToken, totalItems = _processGAPIpagesResult(feed, 'chromeosdevices', None, totalItems, pageMessage, None, Ent.CROS_DEVICE)
              if feed:
                for cros in feed.get('chromeosdevices', []):
                  _printCrOS(cros)
                del feed
              if not pageToken:
                _finalizeGAPIpagesResult(pageMessage)
                printGotAccountEntities(totalItems)
                break
            except GAPI.invalidInput as e:
              message = str(e)
# Invalid Input: xyz - Check for invalid pageToken!!
# 0123456789012345
              if message[15:] == pageToken:
                tokenRetries += 1
                if tokenRetries <= 2:
                  writeStderr(f'{WARNING_PREFIX}{Msg.LIST_CHROMEOS_INVALID_INPUT_PAGE_TOKEN_RETRY}')
                  time.sleep(tokenRetries*5)
                  continue
                entityActionFailedWarning([Ent.CROS_DEVICE, None], message)
                return
              entityActionFailedWarning([Ent.CROS_DEVICE, None], invalidQuery(query) if query is not None else message)
              return
            except GAPI.invalidOrgunit as e:
              entityActionFailedWarning([Ent.CROS_DEVICE, None], str(e))
              return
            except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
              accessErrorExit(cd)
  else:
    sortRows = True
    jcount = len(entityList)
    fields = getFieldsFromFieldsList(fieldsList)
    svcargs = dict([('customerId', GC.Values[GC.CUSTOMER_ID]), ('deviceId', None), ('projection', projection), ('fields', fields)]+GM.Globals[GM.EXTRA_ARGS_LIST])
    method = getattr(cd.chromeosdevices(), 'get')
    dbatch = cd.new_batch_http_request(callback=_callbackPrintCrOS)
    bcount = 0
    j = 0
    for deviceId in entityList:
      j += 1
      svcparms = svcargs.copy()
      svcparms['deviceId'] = deviceId
      dbatch.add(method(**svcparms), request_id=batchRequestID('', 0, 0, j, jcount, deviceId))
      bcount += 1
      if bcount >= GC.Values[GC.BATCH_SIZE]:
        executeBatch(dbatch)
        dbatch = cd.new_batch_http_request(callback=_callbackPrintCrOS)
        bcount = 0
    if bcount > 0:
      dbatch.execute()
  if sortRows and orderBy:
    csvPF.SortRows(orderBy, reverse=sortOrder == 'DESCENDING')
  csvPF.writeCSVfile('CrOS Activity')

# gam <CrOSTypeEntity> print [cros|croses|crosactivity]
def doPrintCrOSEntity(entityList):
  if getChoice([Cmd.ARG_CROS, Cmd.ARG_CROSES, Cmd.ARG_CROSACTIVITY], defaultChoice=None) != Cmd.ARG_CROSACTIVITY:
    if not Cmd.ArgumentsRemaining():
      writeEntityNoHeaderCSVFile(Ent.CROS_DEVICE, entityList)
    else:
      doPrintCrOSDevices(entityList)
  else:
    doPrintCrOSActivity(entityList)

CROS_TELEMETRY_FIELDS_CHOICE_MAP = {
  'audiostatusreport': 'audioStatusReport',
  'batteryinfo': 'batteryInfo',
  'batterystatusreport': 'batteryStatusReport',
  'bootperformancereport': 'bootPerformanceReport',
  'cpuinfo': 'cpuInfo',
  'cpustatusreport': 'cpuStatusReport',
  'customer': 'customer',
  'deviceid': 'deviceId',
  'graphicsinfo': 'graphicsInfo',
  'graphicsstatusreport': 'graphicsStatusReport',
  'memoryinfo': 'memoryInfo',
  'memorystatusreport': 'memoryStatusReport',
  'name': 'name',
  'networkinfo': 'networkInfo',
  'networkdiagnosticsreport': 'networkDiagnosticsReport',
  'networkstatusreport': 'networkStatusReport',
  'orgunitid': 'orgUnitId',
  'osupdatestatus': 'osUpdateStatus',
  'peripheralsreport': 'peripheralsReport',
  'serialnumber': 'serialNumber',
  'storageinfo': 'storageInfo',
  'storagestatusreport': 'storageStatusReport',
  'thunderboltinfo': 'thunderboltInfo',
  }
CROS_TELEMETRY_LIST_FIELDS_CHOICE_MAP = {
  'audiostatusreport': 'audioStatusReport',
  'batteryinfo': 'batteryInfo',
  'batterystatusreport': 'batteryStatusReport',
  'bootperformancereport': 'bootPerformanceReport',
  'cpuinfo': 'cpuInfo',
  'cpustatusreport': 'cpuStatusReport',
  'graphicsstatusreport': 'graphicsStatusReport',
  'memorystatusreport': 'memoryStatusReport',
  'networkdiagnosticsreport': 'networkDiagnosticsReport',
  'networkstatusreport': 'networkStatusReport',
  'osupdatestatus': 'osUpdateStatus',
  'peripheralsreport': 'peripheralsReport',
  'storagestatusreport': 'storageStatusReport',
  'thunderboltinfo': 'thunderboltInfo',
  }

CROS_TELEMETRY_SCALAR_FIELDS = ['deviceId', 'serialNumber', 'customer', 'name', 'orgUnitId', 'orgUnitPath']
CROS_TELEMETRY_SCALAR_FIELDS_SET = set(CROS_TELEMETRY_SCALAR_FIELDS)
CROS_TELEMETRY_LIST_FIELDS = list(CROS_TELEMETRY_LIST_FIELDS_CHOICE_MAP.values())
CROS_TELEMETRY_TIME_OBJECTS = {'reportTime', 'lastUpdateTime', 'lastUpdateCheckTime', 'lastRebootTime'}

# gam info crostelemetry <SerialNumber>
#	<CrOSTelemetryFieldName>* [fields <CrOSTelemetryFieldNameList>]
#	[start <Date>] [end <Date>] [listlimit <Number>]
#	[reverselists <CrOSTelemetryListFieldNameList>]
#	[formatjson [quotechar <Character>]]
# gam show crostelemetry
#	[(ou|org|orgunit|ou_and_children <OrgUnitItem>)|(cros_sn <SerialNumber>)|(filter <String>)]
#	<CrOSTelemetryFieldName>* [fields <CrOSTelemetryFieldNameList>]
#	[start <Date>] [end <Date>] [listlimit <Number>]
#	[reverselists <CrOSTelemetryListFieldNameList>]
#	[formatjson [quotechar <Character>]]
# gam print crostelemetry [todrive <ToDriveAttribute>*]
#	[(ou|org|orgunit|ou_and_children <OrgUnitItem>)|(cros_sn <SerialNumber>)|(filter <String>)]
#	<CrOSTelemetryFieldName>* [fields <CrOSTelemetryFieldNameList>]
#	[reverselists <CrOSTelemetryListFieldNameList>]
#	[start <Date>] [end <Date>] [listlimit <Number>]
#	[formatjson [quotechar <Character>]]
def doInfoPrintShowCrOSTelemetry():
  def _cleanDevice(device):
    for field in reverseLists:
      if field in device:
        device[field].reverse()
    if listLimit or startTime or endTime:
      for field in CROS_TELEMETRY_LIST_FIELDS:
        if field in device:
          listItems = device.pop(field)
          device[field] = []
          i = 0
          for item in listItems:
            if 'reportTime' in item:
              timeValue, _ = iso8601.parse_date(item['reportTime'])
            else:
              timeValue = None
            if (timeValue is None) or (((startTime is None) or (timeValue >= startTime)) and ((endTime is None) or (timeValue <= endTime))):
              device[field].append(item)
              i += 1
              if listLimit and i == listLimit:
                break
    storageInfo = device.get('storageInfo', {})
    if 'totalDiskBytes' in storageInfo and 'availableDiskBytes' in storageInfo:
      disk_avail = int(storageInfo['availableDiskBytes'])
      disk_size = int(storageInfo['totalDiskBytes'])
      if diskPercentOnly:
        device['storageInfo'] = {}
      device['storageInfo']['percentDiskFree'] = int((disk_avail / disk_size) * 100)
      device['storageInfo']['percentDiskUsed'] = 100 - device['storageInfo']['percentDiskFree']
    for cpuStatusReport in device.get('cpuStatusReport', []):
      for tempInfo in cpuStatusReport.pop('cpuTemperatureInfo', []):
        if 'temperatureCelsius' in tempInfo:
          cpuStatusReport[f"cpuTemperatureInfo.{tempInfo['label'].strip()}"] = tempInfo['temperatureCelsius']
    if showOrgUnitPath:
      device['orgUnitPath'] = convertOrgUnitIDtoPath(cd, device['orgUnitId'])

  def _printDevice(device):
    _cleanDevice(device)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(flattenJSON(device, timeObjects=CROS_TELEMETRY_TIME_OBJECTS))
    else:
      if (not csvPF.rowFilter and not csvPF.rowDropFilter) or csvPF.CheckRowTitles(flattenJSON(device, timeObjects=CROS_TELEMETRY_TIME_OBJECTS)):
        csvPF.WriteRowNoFilter({'deviceId': device['deviceId'],
                                'JSON': json.dumps(cleanJSON(device, timeObjects=CROS_TELEMETRY_TIME_OBJECTS),
                                                   ensure_ascii=False, sort_keys=True)})

  def _showDevice(device, i=0, count=0):
    _cleanDevice(device)
    if FJQC.formatJSON:
      printLine(json.dumps(cleanJSON(device), ensure_ascii=False, sort_keys=True))
      return
    printEntity([Ent.CROS_DEVICE, device['deviceId']], i, count)
    Ind.Increment()
    for up in CROS_TELEMETRY_SCALAR_FIELDS:
      if up in device:
        printKeyValueList([up, device[up]])
    showJSON(None, device, skipObjects=CROS_TELEMETRY_SCALAR_FIELDS_SET, timeObjects=CROS_TELEMETRY_TIME_OBJECTS)
    Ind.Decrement()

  cm = buildGAPIObject(API.CHROMEMANAGEMENT)
  cd = None
  parent = _getCustomersCustomerIdWithC()
  fieldsList = []
  reverseLists = []
  action = Act.Get()
  if action == Act.INFO:
    sn = getString(Cmd.OB_SERIAL_NUMBER)
    pfilters = [(f'serialNumber={sn}', f'serialNumber={sn}')]
    Act.Set(Act.SHOW)
  else:
    pfilters = []
  csvPF = CSVPrintFile(['deviceId'], CROS_TELEMETRY_SCALAR_FIELDS, CROS_TELEMETRY_LIST_FIELDS) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  diskPercentOnly = showOrgUnitPath = False
  listLimit = 0
  startTime = endTime = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'ou', 'org', 'orgunit', 'limittoou', 'ouandchildren', 'crossn', 'filter'}:
      if pfilters:
        Cmd.Backup()
        usageErrorExit(Msg.ONLY_ONE_DEVICE_SELECTION_ALLOWED.format(pfilters[0][1]))
      if myarg == 'crossn':
        sn = getString(Cmd.OB_SERIAL_NUMBER)
        pfilters = [(f'serialNumber={sn}', f'serialNumber={sn}')]
      elif myarg == 'filter':
        pf = getString(Cmd.OB_STRING)
        pfilters = [(pf, pf)]
      else:
        if cd is None:
          cd = buildGAPIObject(API.DIRECTORY)
        orgUnitPath, orgUnitId = getOrgUnitId(cd)
        pfilters = [(f'orgUnitId={orgUnitId[3:]}', f'orgUnitPath={orgUnitPath}')]
        if myarg == 'ouandchildren':
          try:
            subous = callGAPI(cd.orgunits(), 'list',
                              throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                              customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=orgUnitId,
                              type='all', fields='organizationUnits(orgUnitPath,orgUnitId)')
          except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError, GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
            checkEntityDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, orgUnitId)
            return
          pfilters.extend([(f'orgUnitId={subou["orgUnitId"][3:]}', f'orgUnitPath={subou["orgUnitPath"]}') for subou in subous.get('organizationUnits', [])])
    elif myarg == 'listlimit':
      listLimit = getInteger()
    elif myarg in CROS_START_ARGUMENTS:
      _, startTime = _getFilterDateTime()
    elif myarg in CROS_END_ARGUMENTS:
      _, endTime = _getFilterDateTime()
    elif myarg in CROS_TELEMETRY_FIELDS_CHOICE_MAP:
      fieldsList.append(CROS_TELEMETRY_FIELDS_CHOICE_MAP[myarg])
    elif myarg == 'fields':
      for field in _getFieldsList():
        if field in CROS_TELEMETRY_FIELDS_CHOICE_MAP:
          fieldsList.append(CROS_TELEMETRY_FIELDS_CHOICE_MAP[field])
        else:
          invalidChoiceExit(field, CROS_TELEMETRY_FIELDS_CHOICE_MAP, True)
    elif myarg == 'reverselists':
      for field in _getFieldsList():
        if field in CROS_TELEMETRY_LIST_FIELDS_CHOICE_MAP:
          reverseLists.append(CROS_TELEMETRY_LIST_FIELDS_CHOICE_MAP[field])
        else:
          invalidChoiceExit(field, CROS_TELEMETRY_LIST_FIELDS_CHOICE_MAP, True)
    elif myarg == 'showorgunitpath':
      showOrgUnitPath = True
      if cd is None:
        cd = buildGAPIObject(API.DIRECTORY)
    elif myarg == 'storagepercentonly':
      diskPercentOnly = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  if fieldsList:
    fieldsList.append('deviceId')
    if showOrgUnitPath:
      fieldsList.append('orgUnitId')
  else:
    fieldsList = list(CROS_TELEMETRY_FIELDS_CHOICE_MAP.values())
  readMask = ','.join(set(fieldsList))
  if csvPF and FJQC.formatJSON:
    csvPF.SetJSONTitles(['deviceId', 'JSON'])
  if not pfilters:
    pfilters = [(None, 'All')]
  for pfilter in pfilters:
    printGettingAllAccountEntities(Ent.CROS_DEVICE, pfilter[1])
    pageMessage = getPageMessage()
    try:
      devices = callGAPIpages(cm.customers().telemetry().devices(), 'list', 'devices',
                              pageMessage=pageMessage,
                              throwReasons=[GAPI.PERMISSION_DENIED, GAPI.INVALID_ARGUMENT, GAPI.INVALID_INPUT],
                              parent=parent, filter=pfilter[0],
                              readMask=readMask, pageSize=GC.Values[GC.DEVICE_MAX_RESULTS])
    except (GAPI.invalidArgument, GAPI.invalidInput) as e:
      message = str(e).replace('\n', ',')
      entityActionFailedWarning([Ent.CROS_DEVICE, None], message)
      return
    except GAPI.permissionDenied as e:
      accessErrorExitNonDirectory(API.CHROMEMANAGEMENT, str(e))
    if csvPF:
      for device in devices:
        _printDevice(device)
    else:
      jcount = len(devices)
      performActionNumItems(jcount, Ent.CROS_DEVICE)
      j = 0
      for device in devices:
        j += 1
        _showDevice(device, j, jcount)
  if csvPF:
    csvPF.writeCSVfile('CrOS Devices Telemetry')

# gam delete browser <DeviceID>
def doDeleteBrowsers():
  cbcm = buildGAPIObject(API.CBCM)
  customerId = _getCustomerIdNoC()
  deviceId = getString(Cmd.OB_DEVICE_ID)
  checkForExtraneousArguments()
  try:
    callGAPI(cbcm.chromebrowsers(), 'delete',
             throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
             customer=customerId, deviceId=deviceId)
    entityActionPerformed([Ent.CHROME_BROWSER, deviceId])
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
    checkEntityAFDNEorAccessErrorExit(None, Ent.CHROME_BROWSER, deviceId)

BROWSER_TIME_OBJECTS = {'firstRecordTime', 'lastActivityTime', 'lastPolicyFetchTime', 'lastRegistrationTime', 'lastStatusReportTime', 'safeBrowsingWarningsResetTime'}

def _showBrowser(browser, FJQC, i=0, count=0):
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(browser), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.CHROME_BROWSER, browser['deviceId']], i, count)
  Ind.Increment()
  showJSON(None, browser, timeObjects=BROWSER_TIME_OBJECTS, dictObjectsKey={'machinePolicies': 'name'})
  Ind.Decrement()

BROWSER_FIELDS_CHOICE_MAP = {
  'annotatedassetid': 'annotatedAssetId',
  'annotatedlocation': 'annotatedLocation',
  'annotatednotes': 'annotatedNotes',
  'annotateduser': 'annotatedUser',
  'asset': 'annotatedAssetId',
  'assetid': 'annotatedAssetId',
  'browsers': 'browsers',
  'browserversions': 'browserVersions',
  'deviceid': 'deviceId',
  'deviceidentifiershistory': 'deviceIdentifiersHistory',
  'extensioncount': 'extensionCount',
  'lastactivitytime': 'lastActivityTime',
  'lastdeviceuser': 'lastDeviceUser',
  'lastdeviceusers': 'lastDeviceUsers',
  'lastpolicyfetchtime': 'lastPolicyFetchTime',
  'lastregistrationtime': 'lastRegistrationTime',
  'laststatusreporttime': 'lastStatusReportTime',
  'location': 'annotatedLocation',
  'machineextensionpolicies': 'machineExtensionPolicies',
  'machinename': 'machineName',
  'machinepolicies': 'machinePolicies',
  'notes': 'annotatedNotes',
  'org': 'orgUnitPath',
  'orgunit': 'orgUnitPath',
  'orgunitpath': 'orgUnitPath',
  'osarchitecture':  'osArchitecture',
  'osplatform': 'osPlatform',
  'osplatformversion': 'osPlatformVersion',
  'osversion': 'osVersion',
  'ou': 'orgUnitPath',
  'policycount': 'policyCount',
  'safebrowsingclickthroughcount': 'safeBrowsingClickThroughCount',
  'serialnumber': 'serialNumber',
  'user': 'annotatedUser',
  'virtualdeviceid': 'virtualDeviceId',
  }
BROWSER_ANNOTATED_FIELDS_LIST = ['annotatedAssetId', 'annotatedLocation', 'annotatedNotes', 'annotatedUser', 'deviceId']
BROWSER_FULL_ACCESS_FIELDS = {'browsers', 'lastDeviceUsers', 'lastStatusReportTime', 'machinePolicies'}

# gam info browser <DeviceID>
#	(basic|full|annotated |
#	 (<BrowserFieldName>* [fields <BrowserFieldNameList>]) |
#	 (rawfields <BrowserFieldNameList>))
#	[formatjson]
def doInfoBrowsers():
  cbcm = buildGAPIObject(API.CBCM)
  customerId = _getCustomerIdNoC()
  deviceId = getString(Cmd.OB_DEVICE_ID)
  projection = 'BASIC'
  fieldsList = []
  rawFields = None
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'annotated':
      projection = 'BASIC'
      fieldsList = BROWSER_ANNOTATED_FIELDS_LIST
    elif myarg in PROJECTION_CHOICE_MAP:
      projection = PROJECTION_CHOICE_MAP[myarg]
      fieldsList = []
    elif getFieldsList(myarg, BROWSER_FIELDS_CHOICE_MAP, fieldsList, initialField='deviceId'):
      pass
    elif myarg == 'rawfields':
      projection = 'FULL'
      rawFields = _getRawFields('deviceId')
    else:
      FJQC.GetFormatJSON(myarg)
  if projection == 'BASIC' and set(fieldsList).intersection(BROWSER_FULL_ACCESS_FIELDS):
    projection = 'FULL'
  fields = getFieldsFromFieldsList(fieldsList) if not rawFields else rawFields
  try:
    browser = callGAPI(cbcm.chromebrowsers(), 'get',
                       throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.FORBIDDEN],
                       customer=customerId, deviceId=deviceId, projection=projection, fields=fields)
    _showBrowser(browser, FJQC)
  except GAPI.invalidArgument as e:
    entityActionFailedWarning([Ent.CHROME_BROWSER, deviceId], str(e))
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
    checkEntityAFDNEorAccessErrorExit(None, Ent.CHROME_BROWSER, deviceId)

# gam move browsers ou|org|orgunit <OrgUnitPath>
#	((ids <DeviceIDList>) |
#	 (queries <QueryBrowserList> [querytime<String> <Time>]) |
#	 (browserou <OrgUnitItem>) | (browserous <OrgUnitList>) |
#	 <FileSelector> | <CSVFileSelector>)
#	[batchsize <Integer>]
def doMoveBrowsers():
  cbcm = buildGAPIObject(API.CBCM)
  customerId = _getCustomerIdNoC()
  deviceIds = []
  batch_size = GC.Values[GC.BATCH_SIZE]
  orgUnitPath = ''
  queries = []
  queryTimes = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'ou', 'org', 'orgunit'}:
      orgUnitPath = getOrgUnitItem()
    elif myarg == 'ids':
      deviceIds.extend(convertEntityToList(getString(Cmd.OB_DEVICE_ID_LIST, minLen=0)))
    elif myarg == 'file':
      deviceIds.extend(getEntitiesFromFile(False))
    elif myarg in {'csv', 'csvfile'}:
      deviceIds.extend(getEntitiesFromCSVFile(False))
    elif myarg in {'query', 'queries'}:
      queries = getQueries(myarg)
    elif myarg.startswith('querytime'):
      queryTimes[myarg] = getTimeOrDeltaFromNow()[0:19]
    elif myarg == 'browserou':
      deviceIds.extend(getItemsToModify(Cmd.ENTITY_BROWSER_OU, getOrgUnitItem(pathOnly=True, absolutePath=True)))
    elif myarg == 'browserous':
      deviceIds.extend(getItemsToModify(Cmd.ENTITY_BROWSER_OUS, getEntityList(Cmd.OB_ORGUNIT_ENTITY, shlexSplit=True)))
    elif myarg == 'batchsize':
      batch_size = getInteger(minVal=1, maxVal=600)
    else:
      unknownArgumentExit()
  if not orgUnitPath:
    missingArgumentExit('orgunit')
  substituteQueryTimes(queries, queryTimes)
  if queries:
    deviceIds.extend(getItemsToModify(Cmd.ENTITY_BROWSER_QUERIES, queries))
  body = {'org_unit_path': orgUnitPath}
  bcount = 0
  jcount = len(deviceIds)
  j = 0
  while bcount < jcount:
    kcount = min(jcount-bcount, batch_size)
    try:
      body['resource_ids'] = deviceIds[bcount:bcount+kcount]
      callGAPI(cbcm.chromebrowsers(), 'moveChromeBrowsersToOu',
               mapNotFound=False,
               throwReasons=[GAPI.INVALID_ORGUNIT, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
               customer=customerId, body=body)
      for deviceId in deviceIds:
        j += 1
        entityActionPerformed([Ent.ORGANIZATIONAL_UNIT, orgUnitPath, Ent.CHROME_BROWSER, deviceId], j, jcount)
      bcount += kcount
    except GAPI.invalidOrgunit:
      entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], Msg.INVALID_ORGUNIT)
      break
    except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden) as e:
      entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitPath, Ent.CHROME_BROWSER, f'IDs: {deviceIds[bcount]} - {deviceIds[bcount+kcount-1]}'], str(e))
      bcount += kcount

UPDATE_BROWSER_ARGUMENT_TO_PROPERTY_MAP = {
  'annotatedassetid': 'annotatedAssetId',
  'annotatedlocation': 'annotatedLocation',
  'annotatednotes': 'annotatedNotes',
  'annotateduser': 'annotatedUser',
  'asset': 'annotatedAssetId',
  'assetid': 'annotatedAssetId',
  'location': 'annotatedLocation',
  'notes': 'annotatedNotes',
  'updatenotes': 'annotatedNotes',
  'user': 'annotatedUser',
  }

BROWSER_DEVICEID_ANNOTATED_FIELDS = 'deviceId,annotatedAssetId,annotatedLocation,annotatedNotes,annotatedUser'

# gam update browser <BrowserEntity> <BrowserAttibute>+ [updatenotes <String>]
def doUpdateBrowsers():
  cbcm = buildGAPIObject(API.CBCM)
  customerId = _getCustomerIdNoC()
  _, entityList = getEntityToModify(defaultEntityType=Cmd.ENTITY_BROWSER, browserAllowed=True, crosAllowed=False, userAllowed=False)
  body = {}
  updateNotes = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in UPDATE_BROWSER_ARGUMENT_TO_PROPERTY_MAP:
      up = UPDATE_BROWSER_ARGUMENT_TO_PROPERTY_MAP[myarg]
      if up == 'annotatedNotes':
        body[up] = getStringWithCRsNLs()
        updateNotes = body[up] if myarg == 'updatenotes' and body[up].find('#notes#') != -1 else None
      else:
        body[up] = getString(Cmd.OB_STRING)
    else:
      unknownArgumentExit()
  i = 0
  count = len(entityList)
  for deviceId in entityList:
    i += 1
    try:
      browser = callGAPI(cbcm.chromebrowsers(), 'get',
                         throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                         customer=customerId, deviceId=deviceId,
                         projection='BASIC', fields=BROWSER_DEVICEID_ANNOTATED_FIELDS)
      if updateNotes:
        body['annotatedNotes'] = updateNotes.replace('#notes#', browser['annotatedNotes'])
      browser.update(body)
      callGAPI(cbcm.chromebrowsers(), 'update',
               throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
               customer=customerId, deviceId=deviceId,
               body=browser, projection='BASIC', fields="deviceId")
      entityActionPerformed([Ent.CHROME_BROWSER, deviceId], i, count)
    except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
      checkEntityAFDNEorAccessErrorExit(None, Ent.CHROME_BROWSER, deviceId, i, count)

def _getChromeProfileName():
  profileName = getString(Cmd.OB_CHROMEPROFILE_ID)
  if not profileName.startswith('customers'):
    customerId = _getCustomerId()
    profileName = f'customers/{customerId}/profiles/{profileName}'
  return profileName

# gam delete chromeprofile <ChromeProfileName>
def doDeleteChromeProfile():
  cm = buildGAPIObject(API.CHROMEMANAGEMENT)
  profileName = _getChromeProfileName()
  checkForExtraneousArguments()
  try:
    callGAPI(cm.customers().profiles(), 'delete',
             throwReasons=[GAPI.INVALID_ARGUMENT, GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED],
             name=profileName)
    entityActionPerformed([Ent.CHROME_PROFILE, profileName])
  except (GAPI.invalidArgument, GAPI.notFound, GAPI.permissionDenied) as e:
    entityActionFailedExit([Ent.CHROME_PROFILE, profileName], str(e))

CHROMEPROFILE_TIME_OBJECTS = {
  'firstEnrollmentTime',
  'lastActivityTime',
  'lastPolicyFetchTime',
  'lastPolicySyncTime',
  'lastStatusReportTime',
  }

def _showChromeProfile(profile, FJQC, i=0, count=0):
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(profile, timeObjects=CHROMEPROFILE_TIME_OBJECTS),
              ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.CHROME_PROFILE, profile['name']], i, count)
  Ind.Increment()
  showJSON(None, profile, timeObjects=CHROMEPROFILE_TIME_OBJECTS)
  Ind.Decrement()

CHROMEPROFILE_FIELDS_CHOICE_MAP = {
  'affiliationstate': 'affiliationState',
  'annotatedlocation': 'annotatedLocation',
  'annotateduser': 'annotatedUser',
  'attestationcredential': 'attestationCredential',
  'browserchannel': 'browserChannel',
  'browserversion': 'browserVersion',
  'deviceinfo': 'deviceInfo',
  'displayname': 'displayName',
  'extensioncount': 'extensionCount',
  'firstenrollmenttime': 'firstEnrollmentTime',
  'identityprovider':'identityProvider',
  'lastactivitytime': 'lastActivityTime',
  'lastpolicyfetchtime': 'lastPolicyFetchTime',
  'lastpolicysynctime': 'lastPolicySyncTime',
  'laststatusreporttime': 'lastStatusReportTime',
  'name': 'name',
  'osplatformtype': 'osPlatformType',
  'osplatformversion':'osPlatformVersion',
  'osversion': 'osVersion',
  'policycount': 'policyCount',
  'profileid': 'profileId',
  'profilepermanentid': 'profilePermanentId',
  'reportingdata': 'reportingData',
  'useremail': 'userEmail',
  'userid': 'userId',
   }

# gam info chromeprofile <ChromeProfileName>
#	<ChromeProfileFieldName>* [fields <ChromeProfileFieldNameList>]
#	[formatjson]
def doInfoChromeProfile():
  cm = buildGAPIObject(API.CHROMEMANAGEMENT)
  profileName = _getChromeProfileName()
  fieldsList = []
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if getFieldsList(myarg, CHROMEPROFILE_FIELDS_CHOICE_MAP, fieldsList, initialField='name'):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  fields = getFieldsFromFieldsList(fieldsList)
  try:
    profile = callGAPI(cm.customers().profiles(), 'get',
                       throwReasons=[GAPI.INVALID_ARGUMENT, GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED],
                       name=profileName, fields=fields)
    _showChromeProfile(profile, FJQC)
  except (GAPI.invalidArgument, GAPI.notFound, GAPI.permissionDenied) as e:
    entityActionFailedExit([Ent.CHROME_PROFILE, profileName], str(e))

CHROMEPROFILE_ORDERBY_CHOICE_MAP = {
  'affiliationstate': 'affiliationState',
  'browserchannel': 'browserChannel',
  'browserversion': 'browserVersion',
  'displayname': 'displayName',
  'extensioncount': 'extensionCount',
  'firstenrollmenttime': 'firstEnrollmentTime',
  'identityprovider': 'identityProvider',
  'lastactivitytime': 'lastActivityTime',
  'lastpolicysynctime': 'lastPolicySyncTime',
  'laststatusreporttime': 'lastStatusReportTime',
  'osplatformtype': 'osPlatformType',
  'osversion': 'osVersion',
  'policycount': 'policyCount',
  'profileid': 'profileId',
  'useremail': 'userEmail',
  }

# gam show chromeprofiles
#	[filtertime.* <Time>] [filter <String>]
#	[orderby <ChromeProfileOrderByFieldName> [ascending|descending]]
#	<ChromeProfileFieldName>* [fields <ChromeProfileFieldNameList>]
#	[formatjson]
# gam print chromeprofiles [todrive <ToDriveAttribute>*]
#	[filtertime.* <Time>] [filter <String>]
#	[orderby <ChromeProfileOrderByFieldName> [ascending|descending]]
#	<ChromeProfileFieldName>* [fields <ChromeProfileFieldNameList>]
#	[[formatjson [quotechar <Character>]]
def doPrintShowChromeProfiles():
  def _printProfile(profile):
    row = flattenJSON(profile, timeObjects=CHROMEPROFILE_TIME_OBJECTS)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'name': profile['name'], 'profileId': profile['profileId'],
                              'JSON': json.dumps(cleanJSON(profile, timeObjects=CHROMEPROFILE_TIME_OBJECTS),
                                                 ensure_ascii=False, sort_keys=True)})

  cm = buildGAPIObject(API.CHROMEMANAGEMENT)
  csvPF = CSVPrintFile(['name', 'profileId']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  OBY = OrderBy(CHROMEPROFILE_ORDERBY_CHOICE_MAP)
  sortHeaders = False
  fieldsList = []
  cbfilter = None
  filterTimes = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif getFieldsList(myarg, CHROMEPROFILE_FIELDS_CHOICE_MAP, fieldsList, initialField=['name', 'profileId']):
      pass
    elif myarg == 'orderby':
      OBY.GetChoice()
    elif myarg.startswith('filtertime'):
      filterTimes[myarg] = getTimeOrDeltaFromNow()
    elif myarg in {'filter', 'filters'}:
      cbfilter = getString(Cmd.OB_STRING)
    elif myarg == 'sortheaders':
      sortHeaders = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if filterTimes and filter is not None:
    for filterTimeName, filterTimeValue in iter(filterTimes.items()):
      cbfilter = cbfilter.replace(f'#{filterTimeName}#', filterTimeValue)
  fields = getItemFieldsFromFieldsList('chromeBrowserProfiles', fieldsList)
  customerId = _getCustomerId()
  parent = f'customers/{customerId}'
  printGettingAllAccountEntities(Ent.CHROME_PROFILE, cbfilter)
  pageMessage = getPageMessage()
  try:
    feed = yieldGAPIpages(cm.customers().profiles(), 'list', 'chromeBrowserProfiles',
                          pageMessage=pageMessage,
                          throwReasons=[GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                          parent=parent, pageSize=200,
                          filter=cbfilter, orderBy=OBY.orderBy, fields=fields)
    for profiles in feed:
      if not csvPF:
        jcount = len(profiles)
        if not FJQC.formatJSON:
          performActionNumItems(jcount, Ent.CHROME_PROFILE)
        Ind.Increment()
        j = 0
        for profile in profiles:
          j += 1
          _showChromeProfile(profile, FJQC, j, jcount)
        Ind.Decrement()
      else:
        for profile in profiles:
          _printProfile(profile)
  except (GAPI.invalidArgument, GAPI.permissionDenied) as e:
    entityActionFailedExit([Ent.CHROME_PROFILE, cbfilter], str(e))
  if csvPF:
    if sortHeaders:
      csvPF.SetSortTitles(['name', 'profileId'])
    csvPF.writeCSVfile('Chrome Profiles')

BROWSER_ORDERBY_CHOICE_MAP = {
  'annotatedassetid': 'annotated_asset_id', 'asset': 'annotated_asset_id', 'assetid': 'annotated_asset_id',
  'annotatedlocation': 'annotated_location', 'location': 'annotated_location',
  'annotatednotes': 'notes', 'notes': 'notes',
  'annotateduser': 'annotated_user', 'user': 'annotated_user',
  'browserversionchannel': 'browser_version_channel',
  'browserversionsortable': 'browser_version_sortable',
  'deviceid': 'id', 'id': 'id',
  'enrollmentdate': 'enrollment_date',
  'extensioncount': 'extension_count',
  'lastactivity': 'last_activity',
  'lastsignedinuser': 'last_signed_in_user',
  'lastsync': 'last_sync',
  'machinename': 'machine_name',
  'orgunit': 'org_unit', 'ou': 'org_unit', 'org': 'org_unit',
  'osversion': 'os_version',
  'osversionsortable': 'os_version_sortable',
  'platformmajorversion': 'platform_major_version',
  'policycount': 'policy_count',
  #  'safebrowsingclickthrough': 'safe_browsing_clickthrough',
  }

# gam show browsers
#	([ou|org|orgunit|browserou <OrgUnitPath>] [(query <QueryBrowser)|(queries <QueryBrowserList>))|(select <BrowserEntity>))
#	[querytime<String> <Time>]
#	[orderby <BrowserOrderByFieldName> [ascending|descending]]
#	(basic|full|annotated |
#	 (<BrowserFieldName>* [fields <BrowserFieldNameList>]) |
#	 (rawfields <BrowserFieldNameList>))
#	(<BrowserFieldName>* [fields <BrowserFieldNameList>]|(rawfields <BrowserFieldNameList>)
#	[formatjson]
# gam print browsers [todrive <ToDriveAttribute>*]
#	([ou|org|orgunit|browserou <OrgUnitPath>] [(query <QueryBrowser)|(queries <QueryBrowserList>))|(select <BrowserEntity>))
#	[querytime<String> <Time>]
#	[orderby <BrowserOrderByFieldName> [ascending|descending]]
#	(basic|full|annotated |
#	 (<BrowserFieldName>* [fields <BrowserFieldNameList>]) |
#	 (rawfields <BrowserFieldNameList>))
#	(<BrowserFieldName>* [fields <BrowserFieldNameList>]|(rawfields <BrowserFieldNameList>)
#	[sortheaders] [formatjson [quotechar <Character>]]
def doPrintShowBrowsers():
  def _printBrowser(browser):
    row = flattenJSON(browser, timeObjects=BROWSER_TIME_OBJECTS)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'deviceId': browser['deviceId'],
                              'JSON': json.dumps(cleanJSON(browser, timeObjects=BROWSER_TIME_OBJECTS),
                                                 ensure_ascii=False, sort_keys=True)})

  cbcm = buildGAPIObject(API.CBCM)
  customerId = _getCustomerIdNoC()
  csvPF = CSVPrintFile(['deviceId']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  fieldsList = []
  rawFields = None
  projection = 'BASIC'
  orderBy = 'id'
  sortOrder = 'ASCENDING'
  entityList = orgUnitPath = None
  queries = [None]
  queryTimes = {}
  sortHeaders = sortRows = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'query', 'queries'}:
      queries = getQueries(myarg)
    elif myarg.startswith('querytime'):
      queryTimes[myarg] = getTimeOrDeltaFromNow()[0:19]
    elif myarg in {'ou', 'org', 'orgunit', 'browserou'}:
      orgUnitPath = getOrgUnitItem(pathOnly=True, absolutePath=True)
    elif myarg == 'select':
      _, entityList = getEntityToModify(defaultEntityType=Cmd.ENTITY_BROWSER, browserAllowed=True, crosAllowed=False, userAllowed=False)
    elif myarg == 'orderby':
      orderBy, sortOrder = getOrderBySortOrder(BROWSER_ORDERBY_CHOICE_MAP, 'DESCENDING', True)
    elif myarg == 'annotated':
      projection = 'BASIC'
      fieldsList = BROWSER_ANNOTATED_FIELDS_LIST
    elif (myarg == 'projection') or myarg in PROJECTION_CHOICE_MAP:
      if myarg == 'projection':
        projection = getChoice(PROJECTION_CHOICE_MAP, mapChoice=True)
      else:
        projection = PROJECTION_CHOICE_MAP[myarg]
      fieldsList = []
    elif myarg == 'allfields':
      projection = 'FULL'
      sortHeaders = True
      fieldsList = []
    elif myarg == 'sortheaders':
      sortHeaders = True
    elif getFieldsList(myarg, BROWSER_FIELDS_CHOICE_MAP, fieldsList, initialField='deviceId'):
      pass
    elif myarg == 'rawfields':
      projection = 'FULL'
      rawFields = _getRawFields('deviceId')
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if projection == 'BASIC' and set(fieldsList).intersection(BROWSER_FULL_ACCESS_FIELDS):
    projection = 'FULL'
  if FJQC.formatJSON:
    sortHeaders = False
  substituteQueryTimes(queries, queryTimes)
  if entityList is None:
    fields = getItemFieldsFromFieldsList('browsers', fieldsList) if not rawFields else f'nextPageToken,browsers({rawFields})'
    for query in queries:
      printGettingAllAccountEntities(Ent.CHROME_BROWSER, query)
      pageMessage = getPageMessage()
      try:
        feed = yieldGAPIpages(cbcm.chromebrowsers(), 'list', 'browsers',
                              pageMessage=pageMessage, messageAttribute='deviceId',
                              throwReasons=[GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.INVALID_ARGUMENT, GAPI.INVALID_ORGUNIT, GAPI.FORBIDDEN],
                              retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                              customer=customerId, orgUnitPath=orgUnitPath, query=query, projection=projection,
                              orderBy=orderBy, sortOrder=sortOrder, fields=fields)
        for browsers in feed:
          if not csvPF:
            jcount = len(browsers)
            if not FJQC.formatJSON:
              performActionNumItems(jcount, Ent.CHROME_BROWSER)
            Ind.Increment()
            j = 0
            for browser in browsers:
              j += 1
              _showBrowser(browser, FJQC, j, jcount)
            Ind.Decrement()
          else:
            for browser in browsers:
              _printBrowser(browser)
      except GAPI.invalidInput as e:
        if query:
          entityActionFailedWarning([Ent.CHROME_BROWSER, None], invalidQuery(query))
        else:
          entityActionFailedWarning([Ent.CHROME_BROWSER, None], str(e))
        return
      except (GAPI.invalidArgument, GAPI.invalidOrgunit, GAPI.forbidden) as e:
        entityActionFailedWarning([Ent.CHROME_BROWSER, None], str(e))
        return
      except (GAPI.badRequest, GAPI.resourceNotFound):
        accessErrorExit(None)
  else:
    sortRows = True
    jcount = len(entityList)
    fields = getFieldsFromFieldsList(fieldsList) if not rawFields else rawFields
    j = 0
    for deviceId in entityList:
      j += 1
      try:
        browser = callGAPI(cbcm.chromebrowsers(), 'get',
                           throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.FORBIDDEN],
                           customer=customerId, deviceId=deviceId, projection=projection, fields=fields)
        _printBrowser(browser)
      except GAPI.invalidArgument as e:
        entityActionFailedWarning([Ent.CHROME_BROWSER, deviceId], str(e))
      except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
        checkEntityAFDNEorAccessErrorExit(None, Ent.CHROME_BROWSER, deviceId)
  if csvPF:
    if sortRows and orderBy:
      csvPF.SortRows(orderBy, reverse=sortOrder == 'DESCENDING')
    if sortHeaders:
      csvPF.SetSortTitles(['deviceId'])
    csvPF.writeCSVfile('Browsers')

BROWSER_TOKEN_TIME_OBJECTS = {'createTime', 'expireTime', 'revokeTime'}

def _showBrowserToken(browser, FJQC, i=0, count=0):
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(browser), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.CHROME_BROWSER_ENROLLMENT_TOKEN, browser['token']], i, count)
  Ind.Increment()
  showJSON(None, browser, timeObjects=BROWSER_TOKEN_TIME_OBJECTS)
  Ind.Decrement()

# gam create browsertoken
#	[ou|org|orgunit|browserou <OrgUnitPath>] [expire|expires <Time>]
#	[formatjson]
def doCreateBrowserToken():
  cbcm = buildGAPIObject(API.CBCM)
  customerId = _getCustomerIdNoC()
  FJQC = FormatJSONQuoteChar()
  body = {'token_type': 'CHROME_BROWSER'}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'ou', 'org', 'orgunit', 'browserou'}:
      body['org_unit_path'] = getOrgUnitItem(pathOnly=True, absolutePath=True)
    elif myarg in ['expire', 'expires']:
      body['expire_time'] = getTimeOrDeltaFromNow()
    else:
      FJQC.GetFormatJSON(myarg)
  try:
    browser = callGAPI(cbcm.enrollmentTokens(), 'create',
                       throwReasons=[GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.INVALID_ORGUNIT, GAPI.FORBIDDEN],
                       customer=customerId, body=body)
    if not FJQC.formatJSON:
      entityActionPerformed([Ent.CHROME_BROWSER_ENROLLMENT_TOKEN, browser['token']])
    Ind.Increment()
    _showBrowserToken(browser, FJQC, 0, 0)
    Ind.Decrement()
  except (GAPI.invalidInput, GAPI.invalidOrgunit) as e:
    entityActionFailedWarning([Ent.CHROME_BROWSER_ENROLLMENT_TOKEN, None], str(e))
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
    accessErrorExit(None)

# gam revoke browsertoken <BrowserTokenPermanentID>
def doRevokeBrowserToken():
  cbcm = buildGAPIObject(API.CBCM)
  customerId = _getCustomerIdNoC()
  tokenPermanentId = getString(Cmd.OB_BROWSER_ENROLLEMNT_TOKEN_ID)
  checkForExtraneousArguments()
  try:
    callGAPI(cbcm.enrollmentTokens(), 'revoke',
             throwReasons=[GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.INVALID_ORGUNIT, GAPI.FORBIDDEN],
             customer=customerId, tokenPermanentId=tokenPermanentId)
    entityActionPerformed([Ent.CHROME_BROWSER_ENROLLMENT_TOKEN, tokenPermanentId])
  except (GAPI.invalid, GAPI.invalidInput, GAPI.badRequest, GAPI.resourceNotFound, GAPI.invalidOrgunit) as e:
    entityActionFailedWarning([Ent.CHROME_BROWSER_ENROLLMENT_TOKEN, tokenPermanentId], str(e))
  except GAPI.forbidden:
    accessErrorExit(None)

BROWSER_TOKEN_FIELDS_CHOICE_MAP = {
  'createtime': 'createTime',
  'creatorid': 'creatorId',
  'customerid': 'customerId',
  'expiretime': 'expireTime',
  'org': 'orgUnitPath',
  'orgunit': 'orgUnitPath',
  'orgunitpath': 'orgUnitPath',
  'ou': 'orgUnitPath',
  'revoketime': 'revokeTime',
  'revokerid': 'revokerId',
  'state': 'state',
  'token': 'token',
  'tokenpermanentid': 'tokenPermanentId',
  }

# gam show browsertokens
#	([ou|org|orgunit|browserou <OrgUnitPath>] [(query <QueryBrowserToken)|(queries <QueryBrowserTokenList>)))
#	[querytime<String> <Time>]
#	[orderby <BrowserTokenFieldName> [ascending|descending]]
#	[allfields] <BrowserTokenFieldName>* [fields <BrowserTokenFieldNameList>]
#	[formatjson]
# gam print browsertokens [todrive <ToDriveAttribute>*]
#	([ou|org|orgunit|browserou <OrgUnitPath>] [(query <QueryBrowserToken)|(queries <QueryBrowserTokenList>)))
#	[querytime<String> <Time>]
#	[orderby <BrowserTokenFieldName> [ascending|descending]]
#	[allfields] <BrowserTokenFieldName>* [fields <BrowserTokenFieldNameList>]
#	[sortheaders] [formatjson [quotechar <Character>]]
def doPrintShowBrowserTokens():
  def _printBrowserToken(browser):
    row = flattenJSON(browser, timeObjects=BROWSER_TOKEN_TIME_OBJECTS)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'token': browser['token'],
                              'JSON': json.dumps(cleanJSON(browser, timeObjects=BROWSER_TOKEN_TIME_OBJECTS),
                                                 ensure_ascii=False, sort_keys=True)})

  cbcm = buildGAPIObject(API.CBCM)
  customerId = _getCustomerIdNoC()
  csvPF = CSVPrintFile(['token']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  fieldsList = []
  orderBy = 'token'
  sortOrder = 'ASCENDING'
  orgUnitPath = None
  queries = [None]
  queryTimes = {}
  sortHeaders = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'query', 'queries'}:
      queries = getQueries(myarg)
    elif myarg.startswith('querytime'):
      queryTimes[myarg] = getTimeOrDeltaFromNow()[0:19]
    elif myarg in {'ou', 'org', 'orgunit', 'browserou'}:
      orgUnitPath = getOrgUnitItem(pathOnly=True, absolutePath=True)
    elif myarg == 'orderby':
      orderBy, sortOrder = getOrderBySortOrder(BROWSER_TOKEN_FIELDS_CHOICE_MAP, 'DESCENDING', True)
    elif myarg == 'allfields':
      sortHeaders = True
      fieldsList = []
    elif myarg == 'sortheaders':
      sortHeaders = True
    elif getFieldsList(myarg, BROWSER_TOKEN_FIELDS_CHOICE_MAP, fieldsList, initialField='token'):
      pass
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  fields = getItemFieldsFromFieldsList('chromeEnrollmentTokens', fieldsList)
  if FJQC.formatJSON:
    sortHeaders = False
  substituteQueryTimes(queries, queryTimes)
  for query in queries:
    printGettingAllAccountEntities(Ent.CHROME_BROWSER_ENROLLMENT_TOKEN, query)
    pageMessage = getPageMessage()
    try:
      browsers = callGAPIpages(cbcm.enrollmentTokens(), 'list', 'chromeEnrollmentTokens',
                               pageMessage=pageMessage,
                               throwReasons=[GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.INVALID_ORGUNIT, GAPI.FORBIDDEN],
                               customer=customerId, orgUnitPath=orgUnitPath, query=query,
                               fields=fields)
      if not csvPF:
        jcount = len(browsers)
        performActionNumItems(jcount, Ent.CHROME_BROWSER_ENROLLMENT_TOKEN)
        Ind.Increment()
        j = 0
        for browser in browsers:
          j += 1
          _showBrowserToken(browser, FJQC, j, jcount)
        Ind.Decrement()
      else:
        for browser in browsers:
          _printBrowserToken(browser)
    except GAPI.invalidInput as e:
      if query:
        entityActionFailedWarning([Ent.CHROME_BROWSER_ENROLLMENT_TOKEN, None], invalidQuery(query))
      else:
        entityActionFailedWarning([Ent.CHROME_BROWSER_ENROLLMENT_TOKEN, None], str(e))
    except GAPI.invalidOrgunit as e:
      entityActionFailedWarning([Ent.CHROME_BROWSER_ENROLLMENT_TOKEN, None], str(e))
    except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
      accessErrorExit(None)
  if csvPF:
    if orderBy:
      csvPF.SortRows(orderBy, reverse=sortOrder == 'DESCENDING')
    if sortHeaders:
      csvPF.SetSortTitles(['token'])
    csvPF.writeCSVfile('Chrome Browser Enrollment Tokens')

def buildChatServiceObject(api=API.CHAT, user=None, i=0, count=0, entityTypeList=None, useAdminAccess=False):
  if user is None:
    _, chat = buildGAPIServiceObject(API.CHAT, user)
    kvList = [Ent.CHAT_BOT, None]
  else:
    user, chat = buildGAPIServiceObject(api, user, i, count)
    if not useAdminAccess:
      kvList = [Ent.USER, user]
    else:
      kvList = [Ent.CHAT_ADMIN, f'{user}(asadmin)']
  if entityTypeList is not None:
    kvList.extend(entityTypeList)
  return user, chat, kvList

def setupChatURL(chat):
  return f'https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat?project={chat._http.credentials.project_id}'

def exitIfChatNotConfigured(chat, kvList, errMsg, i, count):
  if (('No bot associated with this project.' in errMsg) or
      ('Invalid project number.' in errMsg) or
      ('Google Chat app not found.' in errMsg)):
    systemErrorExit(API_ACCESS_DENIED_RC, Msg.TO_SET_UP_GOOGLE_CHAT.format(setupChatURL(chat)))
  entityActionFailedWarning(kvList, errMsg, i, count)

def _getChatAdminAccess(adminAPI, userAPI):
  if checkArgumentPresent(ADMIN_ACCESS_OPTIONS) or GC.Values[GC.USE_CHAT_ADMIN_ACCESS]:
    return (True, adminAPI, {'useAdminAccess': True})
  return (False, userAPI, {})

def _chkChatAdminAccess(count):
  if count != 1:
    usageErrorExit(Msg.CHAT_ADMIN_ACCESS_LIMITED_TO_ONE_USER.format(count))

def _cleanChatSpace(space):
  space.pop('type', None)
  space.pop('threaded', None)

def _cleanChatMessage(message):
  message.pop('cards', None)

CHAT_TIME_OBJECTS = {'createTime', 'deleteTime', 'eventTime', 'lastActiveTime', 'lastUpdateTime'}

def _showChatItem(citem, entityType, FJQC, i=0, count=0):
  if entityType == Ent.CHAT_SPACE:
    _cleanChatSpace(citem)
  elif entityType == Ent.CHAT_MESSAGE:
    _cleanChatMessage(citem)
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(citem, timeObjects=CHAT_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  printEntity([entityType, citem['name']], i, count)
  Ind.Increment()
  showJSON(None, citem, timeObjects=CHAT_TIME_OBJECTS)
  Ind.Decrement()

def _printChatItem(user, citem, parent, entityType, csvPF, FJQC, addCSVData=None):
  if entityType == Ent.CHAT_SPACE:
    _cleanChatSpace(citem)
    baserow = {'User': user} if user is not None else {}
  else:
    if user is not None:
      baserow = {'User': user, 'space.name': parent['name'], 'space.displayName': parent['displayName']}
    else:
      baserow = {'space.name': parent['name'], 'space.displayName': parent['displayName']}
    if entityType == Ent.CHAT_MEMBER:
      if addCSVData:
        baserow.update(addCSVData)
    elif entityType == Ent.CHAT_MESSAGE:
      _cleanChatMessage(citem)
  row = flattenJSON(citem, flattened=baserow.copy(), timeObjects=CHAT_TIME_OBJECTS)
  if not FJQC.formatJSON:
    csvPF.WriteRowTitles(row)
  elif csvPF.CheckRowTitles(row):
    row = baserow.copy()
    row.update({'name': citem['name'],
                'JSON': json.dumps(cleanJSON(citem, timeObjects=CHAT_TIME_OBJECTS), ensure_ascii=False, sort_keys=True)})
    csvPF.WriteRowNoFilter(row)

# gam setup chat
def doSetupChat():
  checkForExtraneousArguments()
  _, chat , _ = buildChatServiceObject()
  writeStdout(Msg.TO_SET_UP_GOOGLE_CHAT.format(setupChatURL(chat), GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['project_id']))

def getSpaceName(myarg):
  if myarg == 'space':
    chatSpace = getString(Cmd.OB_CHAT_SPACE)
    if chatSpace.startswith('spaces/'):
      return chatSpace
    if not chatSpace.startswith('space/'):
      return 'spaces/'+chatSpace
    _, chatSpace = chatSpace.split('/', 1)
  else: # myarg.startswith('spaces/') or myarg.startswith('space/')
    _, chatSpace = Cmd.Previous().split('/', 1)
  return 'spaces/'+chatSpace

def  getChatSpaceParameters(myarg, body, typeChoicesMap, updateMask):
  if myarg == 'displayname':
    body['displayName'] = getString(Cmd.OB_STRING, minLen=0, maxLen=128)
    updateMask.add('displayName')
  elif myarg == 'type':
    body['spaceType'] = getChoice(typeChoicesMap, mapChoice=True)
    updateMask.add('spaceType')
  elif myarg == 'description':
    body.setdefault('spaceDetails', {})
    body['spaceDetails']['description'] = getString(Cmd.OB_STRING, minLen=0, maxLen=150)
    updateMask.add('spaceDetails')
  elif myarg in {'guidelines', 'rules'}:
    body.setdefault('spaceDetails', {})
    body['spaceDetails']['guidelines'] = getString(Cmd.OB_STRING, minLen=0, maxLen=5000)
    updateMask.add('spaceDetails')
  elif myarg == 'history':
    body['spaceHistoryState'] = 'HISTORY_ON' if getBoolean() else 'HISTORY_OFF'
    updateMask.add('spaceHistoryState')
  elif myarg in {'audience', 'restricted'}:
    body['accessSettings']= {'audience': None}
    if myarg == 'audience':
      body['accessSettings']['audience'] = getString(Cmd.OB_STRING, minLen=1, maxLen=100)
    updateMask.add('accessSettings.audience')
  else:
    return False
  return True

CHAT_MEMBER_ROLE_MAP = {
  'member': 'ROLE_MEMBER',
  'manager': 'ROLE_MANAGER'
  }

CHAT_MEMBER_TYPE_MAP = {
  'bot': 'BOT',
  'human': 'HUMAN'
  }

CHAT_SPACE_TYPE_MAP = {
  'space': 'SPACE',
  'groupchat': 'GROUP_CHAT',
  'directmessage': 'DIRECT_MESSAGE',
  }

CHAT_SPACE_PREDEFINED_PERMS_MAP = {
  'announcement': 'ANNOUNCEMENT_SPACE',
  'collaboration': 'COLLABORATION_SPACE',
  }

CHAT_SPACE_MIN_MAX_MEMBERS = {
  'SPACE': {'min': 0, 'max': 20},
  'GROUP_CHAT': {'min': 2, 'max': 20},
  'DIRECT_MESSAGE': {'min': 1, 'max': 1},
  }
# gam <UserTypeEntity> create chatspace
#       [type <ChatSpaceType>] [announcement|collaboration]
#	[restricted|(audience <String>)]
#	[externalusersallowed <Boolean>]
#	[members <UserTypeEntity>]
#	[displayname <String>]
#	[description <String>] [guidelines|rules <String>]
#	[history <Boolean>]
#	[<ChatContent>]
#	[formatjson|returnidonly]
def createChatSpace(users):
  FJQC = FormatJSONQuoteChar()
  body = {'space': {'spaceType': CHAT_SPACE_TYPE_MAP['space'], 'displayName': ''}, 'requestId': str(uuid.uuid4())}
  members = []
  tbody = {}
  returnIdOnly = False
  updateMask = set()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if getChatSpaceParameters(myarg, body['space'], CHAT_SPACE_TYPE_MAP, updateMask):
      pass
    elif myarg in CHAT_SPACE_PREDEFINED_PERMS_MAP:
      body['space']['predefinedPermissionSettings'] = CHAT_SPACE_PREDEFINED_PERMS_MAP[myarg]
    elif myarg == 'externalusersallowed':
      body['space']['externalUserAllowed'] = getBoolean()
    elif myarg == 'members':
      _, members = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)
    elif myarg == 'returnidonly':
      returnIdOnly = True
    elif myarg in SORF_TEXT_ARGUMENTS:
      tbody['text'] = getStringOrFile(myarg, minLen=0, unescapeCRLF=True)[0]
    else:
      FJQC.GetFormatJSON(myarg)
  spaceType = body['space']['spaceType']
  jcount = len(members)
  if (jcount < CHAT_SPACE_MIN_MAX_MEMBERS[spaceType]['min'] or
      jcount > CHAT_SPACE_MIN_MAX_MEMBERS[spaceType]['max']):
    systemErrorExit(USAGE_ERROR_RC,
                    Msg.INVALID_NUMBER_OF_CHAT_SPACE_MEMBERS.format(Ent.Singular(Ent.CHAT_SPACE),
                                                                    spaceType, jcount,
                                                                    CHAT_SPACE_MIN_MAX_MEMBERS[spaceType]['min'],
                                                                    CHAT_SPACE_MIN_MAX_MEMBERS[spaceType]['max']))
  mtype = CHAT_MEMBER_TYPE_MAP['human']
  if members:
    body['memberships'] = []
    for member in members:
      name = normalizeEmailAddressOrUID(member)
      body['memberships'].append({'member': {'name': f'users/{name}', 'type': mtype}})
  if spaceType == 'SPACE':
    if not body['space']['displayName']:
      missingArgumentExit('displayname')
  elif spaceType == 'GROUP_CHAT':
    body['space'].pop('displayName', None)
    body['space'].pop('predefinedPermissionSettings', None)
  else: # DIRECT_MESSAGE
    body['space'].pop('displayName', None)
    body['space'].pop('spaceDetails', None)
    body['space'].pop('predefinedPermissionSettings', None)
    body['space']['singleUserBotDm'] = False
  if tbody:
    trimChatMessageIfRequired(tbody)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(API.CHAT_SPACES, user, i, count,
                                                [Ent.CHAT_SPACE, body['space'].get('displayName', spaceType)])
    if not chat:
      continue
    try:
      space = callGAPI(chat.spaces(), 'setup',
                       throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                       body=body)
      if not returnIdOnly:
        kvList[-1] = space['name']
        if not FJQC.formatJSON:
          entityActionPerformed(kvList, i, count)
        Ind.Increment()
        _showChatItem(space, Ent.CHAT_SPACE, FJQC)
        Ind.Decrement()
      else:
        writeStdout(f'{space["name"]}\n')
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      exitIfChatNotConfigured(chat, kvList, str(e), i, count)
      continue
    except GAPI.failedPrecondition:
      userChatServiceNotEnabledWarning(user, i, count)
      continue
    if tbody:
      parent = space['name']
      _, chat, kvList = buildChatServiceObject(API.CHAT_MESSAGES, user, i, count,
                                               [Ent.CHAT_SPACE, body['space'].get('displayName', parent)])
      if not chat:
        continue
      try:
        resp = callGAPI(chat.spaces().messages(), 'create',
                        throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                        parent=parent, requestId=str(uuid.uuid4()), body=tbody)
        if not returnIdOnly:
          kvList.extend([Ent.CHAT_MESSAGE, resp['name']])
          entityActionPerformed(kvList, i, count)
        else:
          writeStdout(f'{resp["name"]}\n')
      except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
        exitIfChatNotConfigured(chat, kvList, str(e), i, count)

CHAT_UPDATE_SPACE_TYPE_MAP = {
  'space': 'SPACE',
  }

CHAT_SPACE_ROLE_PERMISSIONS_MAP = {
  'managers': 'managersAllowed',
  'members': 'membersAllowed',
  }

CHAT_UPDATE_SPACE_PERMISSIONS_MAP = {
  'managemembersandgroups': 'manageMembersAndGroups',
  'modifyspacedetails': 'modifySpaceDetails',
  'togglehistory': 'toggleHistory',
  'useatmentionall': 'useAtMentionAll',
  'manageapps': 'manageApps',
  'managewebhooks': 'manageWebhooks',
  'replymessages': 'replyMessages',
  }

# gam <UserTypeEntity> update chatspace <ChatSpace>
#	[restricted|(audience <String>)]|
#	([displayname <String>]
#	 [type space]
#	 [description <String>] [guidelines|rules <String>]
#	 [history <Boolean>])
#	managemembersandgroups managers|members
#	modifyspacedetails managers|members
#	togglehistory managers|members
#	useatmentionall managers|members
#	manageapps managers|members
#	managewebhooks managers|members
#	replymessages managers|members
#	[formatjson]
def updateChatSpace(users):
  FJQC = FormatJSONQuoteChar()
  useAdminAccess, api, kwargsUAA = _getChatAdminAccess(API.CHAT_SPACES_ADMIN, API.CHAT_SPACES)
  name = None
  body = {}
  updateMask = set()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'space' or myarg.startswith('spaces/') or myarg.startswith('space/'):
      name = getSpaceName(myarg)
    elif getChatSpaceParameters(myarg, body, CHAT_UPDATE_SPACE_TYPE_MAP, updateMask):
      pass
    elif myarg in CHAT_UPDATE_SPACE_PERMISSIONS_MAP:
      body.setdefault('permissionSettings', {})
      permissionSetting = CHAT_UPDATE_SPACE_PERMISSIONS_MAP[myarg]
      role = getChoice(CHAT_SPACE_ROLE_PERMISSIONS_MAP, mapChoice=True)
      body['permissionSettings'][permissionSetting] = {'managersAllowed': True}
      if role == 'membersAllowed':
        body['permissionSettings'][permissionSetting].update({'membersAllowed': True})
      updateMask.add(f'permissionSettings.{permissionSetting}')
    else:
      FJQC.GetFormatJSON(myarg)
  if not name:
    missingArgumentExit('space')
  if 'accessSettings.audience' in updateMask:
    tempMask = updateMask-{'accessSettings.audience'}
    if tempMask:
      usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format('restricted/audience', 'displayname,type,description,guidelines,history'))
  i, count, users = getEntityArgument(users)
  if useAdminAccess:
    _chkChatAdminAccess(count)
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(api, user, i, count, [Ent.CHAT_SPACE, name], useAdminAccess)
    if not chat:
      continue
    try:
      space = callGAPI(chat.spaces(), 'patch',
                       throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                       name=name, updateMask=','.join(updateMask), body=body, **kwargsUAA)
      if not FJQC.formatJSON:
        entityActionPerformed(kvList, i, count)
      Ind.Increment()
      _showChatItem(space, Ent.CHAT_SPACE, FJQC)
      Ind.Decrement()
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      exitIfChatNotConfigured(chat, kvList, str(e), i, count)
    except GAPI.failedPrecondition:
      userChatServiceNotEnabledWarning(user, i, count)

# gam <UserTypeEntity> delete chatspace <ChatSpace>
# gam <UserItem> delete chatspace asadmin <ChatSpace>
def deleteChatSpace(users):
  name = None
  useAdminAccess, api, kwargsUAA = _getChatAdminAccess(API.CHAT_SPACES_DELETE_ADMIN, API.CHAT_SPACES_DELETE)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'space' or myarg.startswith('spaces/') or myarg.startswith('space/'):
      name = getSpaceName(myarg)
    else:
      unknownArgumentExit()
  if not name:
    missingArgumentExit('space')
  i, count, users = getEntityArgument(users)
  if useAdminAccess:
    _chkChatAdminAccess(count)
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(api, user, i, count, [Ent.CHAT_SPACE, name], useAdminAccess)
    if not chat:
      continue
    try:
      callGAPI(chat.spaces(), 'delete',
               throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
               name=name, **kwargsUAA)
      entityActionPerformed(kvList, i, count)
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      exitIfChatNotConfigured(chat, kvList, str(e), i, count)
    except GAPI.failedPrecondition:
      userChatServiceNotEnabledWarning(user, i, count)

CHAT_SPACES_FIELDS_CHOICE_MAP = {
  "accesssettings": "accessSettings",
  "admininstalled": "adminInstalled",
  "createtime": "createTime",
  "displayname": "displayName",
  "externaluserallowed": "externalUserAllowed",
  "importmode": "importMode",
  "lastactivetime": "lastActiveTime",
  "membershipcount": "membershipCount",
  "name": "name",
  "permissionsettings": "permissionSettings",
  "singleuserbotdm": "singleUserBotDm",
  "spacedetails": "spaceDetails",
  "spacehistorystate": "spaceHistoryState",
  "spacethreadingstate": "spaceThreadingState",
  "spacetype": "spaceType",
  "spaceuri": "spaceUri",
  "threaded": "spaceThreadingState",
  "type": "spaceType",
  }

# gam [<UserTypeEntity>] info chatspace <ChatSpace>
#	[fields <ChatSpaceFieldNameList>]
#	[formatjson]
# gam <UserItem> info chatspace asadmin <ChatSpace>
#	[fields <ChatSpaceFieldNameList>]
#	[formatjson]
def infoChatSpace(users, name=None):
  FJQC = FormatJSONQuoteChar()
  if name is None:
    function = 'get'
    useAdminAccess, api, kwargsUAA = _getChatAdminAccess(API.CHAT_SPACES_ADMIN, API.CHAT_SPACES)
  else:
    function = 'findDirectMessage'
    useAdminAccess = None
    kwargsUAA = {}
    api = API.CHAT_SPACES
  fieldsList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if function == 'get' and (myarg == 'space' or myarg.startswith('spaces/') or myarg.startswith('space/')):
      name = getSpaceName(myarg)
    elif getFieldsList(myarg, CHAT_SPACES_FIELDS_CHOICE_MAP, fieldsList, initialField='name', onlyFieldsArg=True):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  if function == 'get' and not name:
    missingArgumentExit('space')
  fields = getFieldsFromFieldsList(fieldsList)
  i, count, users = getEntityArgument(users)
  if useAdminAccess:
    _chkChatAdminAccess(count)
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(api, user, i, count, [Ent.CHAT_SPACE, name], useAdminAccess)
    if not chat:
      continue
    try:
      space = callGAPI(chat.spaces(), function,
                       throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                       name=name, fields=fields, **kwargsUAA)
      if not FJQC.formatJSON:
        entityPerformAction(kvList, i, count)
      Ind.Increment()
      _showChatItem(space, Ent.CHAT_SPACE, FJQC)
      Ind.Decrement()
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      exitIfChatNotConfigured(chat, kvList, str(e), i, count)
    except GAPI.failedPrecondition:
      userChatServiceNotEnabledWarning(user, i, count)

def doInfoChatSpace():
  infoChatSpace([None])

# gam [<UserTypeEntity>] info chatspacedm <UserItem>
#	[fields <ChatSpaceFieldNameList>]
#	[formatjson]
def infoChatSpaceDM(users):
  cd = buildGAPIObject(API.DIRECTORY)
  name = convertEmailAddressToUID(getEmailAddress(returnUIDprefix='uid:'), cd, 'user')
  infoChatSpace(users, f'users/{name}')

def _getChatPageMessage(entityType, user, i, count, pfilter, useAdminAccess=False):
  if user is not None:
    printGettingAllEntityItemsForWhom(entityType, user if not useAdminAccess else f'{user}(asadmin)', i, count, pfilter)
    return getPageMessageForWhom()
  printGettingAllAccountEntities(entityType, pfilter)
  return getPageMessage()

def _getChatSpaceListParms(myarg, kwargs):
  if myarg in {'type', 'types'}:
    for ctype in getString(Cmd.OB_GROUP_ROLE_LIST).lower().replace(',', ' ').split():
      if ctype in CHAT_SPACE_TYPE_MAP:
        kwargs.setdefault('filter', '')
        if kwargs['filter']:
          kwargs['filter'] += ' OR '
        kwargs['filter'] += f'spaceType = "{CHAT_SPACE_TYPE_MAP[ctype]}"'
      else:
        invalidChoiceExit(ctype, CHAT_SPACE_TYPE_MAP, True)
  else:
    return False
  return True

def _getChatSpaceSearchParms(myarg, queries, queryTimes, OBY):
  if myarg == 'orderby':
    OBY.GetChoice()
  elif  myarg == 'query':
    queries[0] += ' AND '+getString(Cmd.OB_QUERY)
  elif myarg.startswith('querytime'):
    queryTimes[myarg] = getTimeOrDeltaFromNow()
  else:
    return False
  return True

CHAT_PAGE_SIZE = 1000
CHAT_SPACES_ADMIN_ORDERBY_CHOICE_MAP = {
  'createtime': 'createTime',
  'lastactivetime': 'lastActiveTime',
  'membershipcount': 'membershipCount.joined_direct_human_user_count'
  }

# gam [<UserTypeEntity>] show chatspaces
#	[types <ChatSpaceTypeList>]
#	[fields <ChatSpaceFieldNameList>]
#	[formatjson]
# gam [<UserTypeEntity>] print chatspaces [todrive <ToDriveAttribute>*]
#	[types <ChatSpaceTypeList>]
#	[fields <ChatSpaceFieldNameList>]
#	[formatjson [quotechar <Character>]]
# gam <UserItem> show chatspaces asadmin
#	[query <String>] [querytime<String> <Time>]
#	[orderby <ChatSpaceAdminOrderByFieldName> [ascending|descending]]
#	[fields <ChatSpaceFieldNameList>]
#	[formatjson]
# gam <UserItem> print chatspaces asadmin [todrive <ToDriveAttribute>*]
#	[query <String>] [querytime<String> <Time>]
#	[orderby <ChatSpaceAdminOrderByFieldName> [ascending|descending]]
#	[fields <ChatSpaceFieldNameList>]
#	[formatjson [quotechar <Character>]]
def printShowChatSpaces(users):
  csvPF = CSVPrintFile(['User', 'name'] if not isinstance(users, list) else ['name']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  OBY = OrderBy(CHAT_SPACES_ADMIN_ORDERBY_CHOICE_MAP)
  useAdminAccess, api, kwargsCS = _getChatAdminAccess(API.CHAT_SPACES_ADMIN, API.CHAT_SPACES)
  fieldsList = []
  queries = []
  queryTimes = {}
  pfilter = ''
  if useAdminAccess:
    function = 'search'
    queries = ['customer = "customers/my_customer" AND spaceType = "SPACE"']
  else:
    function = 'list'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif getFieldsList(myarg, CHAT_SPACES_FIELDS_CHOICE_MAP, fieldsList, initialField='name', onlyFieldsArg=True):
      pass
    elif not useAdminAccess and _getChatSpaceListParms(myarg, kwargsCS):
      pass
    elif useAdminAccess and _getChatSpaceSearchParms(myarg, queries, queryTimes, OBY):
      pass
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  fields = getItemFieldsFromFieldsList('spaces', fieldsList)
  i, count, users = getEntityArgument(users)
  if useAdminAccess:
    _chkChatAdminAccess(count)
    kwargsCS['orderBy'] = OBY.orderBy
    substituteQueryTimes(queries, queryTimes)
    pfilter = kwargsCS['query'] = queries[0]
    kwargsCS['useAdminAccess'] = True
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(api, user, i, count, None, useAdminAccess)
    if not chat:
      continue
    try:
      spaces = callGAPIpages(chat.spaces(), function, 'spaces',
                             pageMessage=_getChatPageMessage(Ent.CHAT_SPACE, user, i, count, pfilter, useAdminAccess),
                             bailOnInternalError=True,
                             throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.INTERNAL_ERROR,
                                           GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                             retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                             fields=fields, pageSize=CHAT_PAGE_SIZE, **kwargsCS)
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.internalError, GAPI.permissionDenied) as e:
      exitIfChatNotConfigured(chat, kvList, str(e), i, count)
      continue
    except GAPI.failedPrecondition:
      userChatServiceNotEnabledWarning(user, i, count)
      continue
    jcount = len(spaces)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
    if not csvPF:
      if not FJQC.formatJSON:
        entityPerformActionNumItems(kvList, jcount, Ent.CHAT_SPACE, i, count)
      Ind.Increment()
      j = 0
      for space in spaces:
        j += 1
        _showChatItem(space, Ent.CHAT_SPACE, FJQC, j, jcount)
      Ind.Decrement()
    else:
      for space in spaces:
        _printChatItem(user, space, None, Ent.CHAT_SPACE, csvPF, FJQC)
  if csvPF:
    csvPF.writeCSVfile('Chat Spaces')

def doPrintShowChatSpaces():
  printShowChatSpaces([None])

def _getChatMemberEmail(cd, member):
  if 'member' in member:
    if member['member']['type'] == 'HUMAN':
      _, memberUid = member['member']['name'].split('/')
      member['member']['email'], _ = convertUIDtoEmailAddressWithType(f'uid:{memberUid}', cd, None, emailTypes=['user'])
  elif 'groupMember' in member:
    _, memberUid = member['groupMember']['name'].split('/')
    member['groupMember']['email'], _ = convertUIDtoEmailAddressWithType(f'uid:{memberUid}', cd, None, emailTypes=['group'])

def _getChatSpaceMembers(cd, chatSpace, ciGroupName):
  if chatSpace.startswith('space/'):
    _, chatSpace = chatSpace.split('/', 1)
    chatSpace = 'spaces/'+chatSpace
  kwargsUAA = {'useAdminAccess': True, 'filter': 'member.type != "BOT"'}
  user, chat, kvList = buildChatServiceObject(API.CHAT_MEMBERSHIPS_ADMIN, _getAdminEmail(), 0, 0, [Ent.CHAT_SPACE, chatSpace], True)
  memberList = []
  if not chat:
    return memberList
  fields = getItemFieldsFromFieldsList('memberships', [])
  qfilter = f'{Ent.Singular(Ent.CHAT_SPACE)}: {chatSpace}, {kwargsUAA["filter"]}'
  try:
    members = callGAPIpages(chat.spaces().members(), 'list', 'memberships',
                            pageMessage=_getChatPageMessage(Ent.CHAT_MEMBER, user, 0, 0, qfilter),
                            throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                            parent=chatSpace, fields=fields, pageSize=CHAT_PAGE_SIZE, **kwargsUAA)
    for member in members:
      _getChatMemberEmail(cd, member)
      gmember = {}
      if 'member' in member:
        if member['member']['type'] == 'HUMAN':
          _, memberUid = member['member']['name'].split('/')
          gmember['type'] = Ent.TYPE_USER
          email, _ = convertUIDtoEmailAddressWithType(f'uid:{memberUid}', cd, None, emailTypes=['user'])
          role = Ent.ROLE_MANAGER if member['role'] == 'ROLE_MANAGER' else Ent.ROLE_MEMBER
          if not ciGroupName:
            gmember['id'] = memberUid
            gmember['email'] = email
            gmember['role'] = role
            gmember['status'] = member['state']
          else:
            gmember['name'] = f'{ciGroupName}/memberships/{memberUid}'
            gmember['preferredMemberKey'] = {'id': email}
            gmember['roles'] = [{'name': role}]
            gmember['createTime'] = member['createTime']
          memberList.append(gmember)
      elif 'groupMember' in member:
        _, memberUid = member['groupMember']['name'].split('/')
        gmember['type'] = Ent.TYPE_GROUP
        role = Ent.ROLE_MANAGER if member['role'] == 'ROLE_MANAGER' else Ent.ROLE_MEMBER
        email, _ = convertUIDtoEmailAddressWithType(f'uid:{memberUid}', cd, None, emailTypes=['group'])
        if not ciGroupName:
          gmember['id'] = memberUid
          gmember['email'] = email
          gmember['role'] = role
          gmember['status'] = member['state']
        else:
          gmember['name'] = f'{ciGroupName}/memberships/{memberUid}'
          gmember['preferredMemberKey'] = {'id': email}
          gmember['roles'] = [{'name': role}]
          gmember['createTime'] = member['createTime']
        memberList.append(gmember)
    return memberList
  except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
    exitIfChatNotConfigured(chat, kvList, str(e), 0, 0)
    return memberList

def normalizeUserMember(user, userList):
  userList.append(normalizeEmailAddressOrUID(user))

def getUserMemberID(cd, user, userList):
  userList.append(convertEmailAddressToUID(user, cd, emailType='user'))

def getGroupMemberID(cd, group, groupList):
  groupList.append(convertEmailAddressToUID(group, cd, emailType='group'))

# gam <UserTypeEntity> create chatmember <ChatSpace>
#	[type human|bot] [role member|manager]
#	(user <UserItem>)* (members <UserTypeEntity>)*
#	(group <GroupItem>)* (groups <GroupEntity>)*
#	[formatjson|returnidonly]
# gam <UserItem> create chatmember asadmin <ChatSpace>
#	[type human|bot] [role member|manager]
#	(user <UserItem>)* (members <UserTypeEntity>)*
#	(group <GroupItem>)* (groups <GroupEntity>)*
#	[formatjson|returnidonly]
def createChatMember(users):
  def addMembers(members, field, entityType, i, count):
    jcount = len(members)
    entityPerformActionNumItems(kvList, jcount, entityType, i, count)
    if jcount == 0:
      return
    kvList.extend([entityType, ''])
    Ind.Increment()
    j = 0
    for body in members:
      j += 1
      kvList[-1] = body[field]['name']
      try:
        member = callGAPI(chat.spaces().members(), 'create',
                          bailOnInternalError=True,
                          throwReasons=[GAPI.ALREADY_EXISTS, GAPI.NOT_FOUND, GAPI.INVALID, GAPI.INVALID_ARGUMENT,
                                        GAPI.INTERNAL_ERROR, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                          parent=parent, body=body, **kwargsUAA)
        if role != 'ROLE_MEMBER' and entityType == Ent.CHAT_MANAGER_USER:
          member = callGAPI(chat.spaces().members(), 'patch',
                            bailOnInternalError=True,
                            throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.INTERNAL_ERROR,
                                          GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                            name=member['name'], updateMask='role', body={'role': role}, **kwargsUAA)
        if not returnIdOnly:
          kvList[-1] = member['name']
          _getChatMemberEmail(cd, member)
          if not FJQC.formatJSON:
            entityActionPerformed(kvList, j, jcount)
          Ind.Increment()
          _showChatItem(member, Ent.CHAT_MEMBER, FJQC)
          Ind.Decrement()
        else:
          writeStdout(f'{member["name"]}\n')
      except (GAPI.alreadyExists, GAPI.notFound, GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied, GAPI.internalError) as e:
        entityActionFailedWarning(kvList, str(e))
      except GAPI.failedPrecondition:
        userChatServiceNotEnabledWarning(user, i, count)
  Ind.Decrement()

  cd = buildGAPIObject(API.DIRECTORY)
  FJQC = FormatJSONQuoteChar()
  parent = None
  role = CHAT_MEMBER_ROLE_MAP['member']
  mtype = CHAT_MEMBER_TYPE_MAP['human']
  userList = []
  groupList = []
  returnIdOnly = False
  useAdminAccess, api, kwargsUAA = _getChatAdminAccess(API.CHAT_MEMBERSHIPS_ADMIN, API.CHAT_MEMBERSHIPS)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'space' or myarg.startswith('spaces/') or myarg.startswith('space/'):
      parent = getSpaceName(myarg)
    elif myarg == 'user':
      normalizeUserMember(getEmailAddress(returnUIDprefix='uid:'), userList)
    elif myarg in {'member', 'members'}:
      _, members = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)
      for user in members:
        normalizeUserMember(user, userList)
    elif myarg == 'group':
      getGroupMemberID(cd, getEmailAddress(returnUIDprefix='uid:'), groupList)
    elif myarg == 'groups':
      for group in getEntityList(Cmd.OB_GROUP_ENTITY):
        getGroupMemberID(cd, group, groupList)
    elif myarg == 'role':
      role = getChoice(CHAT_MEMBER_ROLE_MAP, mapChoice=True)
    elif myarg == 'type':
      mtype = getChoice(CHAT_MEMBER_TYPE_MAP, mapChoice=True)
    elif myarg == 'returnidonly':
      returnIdOnly = True
    else:
      FJQC.GetFormatJSON(myarg)
  if not parent:
    missingArgumentExit('space')
  if not userList and not groupList:
    missingArgumentExit('user|members|group|groups')
  userEntityType = Ent.CHAT_MEMBER_USER if role == 'ROLE_MEMBER' else Ent.CHAT_MANAGER_USER
  userMembers = []
  for user in userList:
    userMembers.append({'member': {'name': f'users/{user}', 'type': mtype}})
  groupMembers = []
  for group in groupList:
    groupMembers.append({'groupMember': {'name': f'groups/{group}'}})
  i, count, users = getEntityArgument(users)
  if useAdminAccess:
    _chkChatAdminAccess(count)
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(api, user, i, count, [Ent.CHAT_SPACE, parent], useAdminAccess)
    if not chat:
      continue
    Ind.Increment()
    if userMembers:
      addMembers(userMembers, 'member', userEntityType, i, count)
    if groupMembers:
      addMembers(groupMembers, 'groupMember', Ent.CHAT_MEMBER_GROUP, i, count)
    Ind.Decrement()

def _deleteChatMembers(chat, kvList, jcount, memberNames, i, count, kwargsUAA):
  j = 0
  for name in memberNames:
    j += 1
    kvList[-1] = name
    try:
      callGAPI(chat.spaces().members(), 'delete',
               bailOnInternalError=True,
               throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.INTERNAL_ERROR],
               name=name, **kwargsUAA)
      entityActionPerformed(kvList, j, jcount)
    except GAPI.notFound as e:
      entityActionFailedWarning(kvList, str(e), j, jcount)
    except (GAPI.invalidArgument, GAPI.permissionDenied, GAPI.internalError) as e:
      exitIfChatNotConfigured(chat, kvList, str(e), i, count)

# gam <UserTypeEntity> delete chatmember <ChatSpace>
#	((user <UserItem>)|(members <UserTypeEntity>)|
#	 (group <GroupItem>)|(groups <GroupEntity>))+
# gam <UserItem> delete chatmember asadmin <ChatSpace>
#	((user <UserItem>)|(members <UserTypeEntity>)|
#	 (group <GroupItem>)|(groups <GroupEntity>))+
# gam <UserTypeEntity> remove chatmember
#	members <ChatMemberList>
# gam <UserItem> remove chatmember asadmin
#	members <ChatMemberList>
# gam <UserTypeEntity> update chatmember <ChatSpace>
#	role member|manager
#	((user <UserItem>)|(members <UserTypeEntity>))+
# gam <UserTypeEntity> modify chatmember
#	role member|manager
#	members <ChatMemberList>
# gam <UserItem> update chatmember asadmin<ChatSpace>
#	role member|manager
#	((user <UserItem>)|(members <UserTypeEntity>))+
# gam <UserItem> modify chatmember asadmin
#	role member|manager
#	members <ChatMemberList>
def deleteUpdateChatMember(users):
  cd = buildGAPIObject(API.DIRECTORY)
  FJQC = FormatJSONQuoteChar()
  action = Act.Get()
  deleteMode =  action in {Act.DELETE, Act.REMOVE}
  parent = None
  body = {}
  memberNames = []
  userList = []
  groupList = []
  useAdminAccess, api, kwargsUAA = _getChatAdminAccess(API.CHAT_MEMBERSHIPS_ADMIN, API.CHAT_MEMBERSHIPS)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if action in {Act.UPDATE, Act.MODIFY} and myarg == 'role':
      body['role'] = getChoice(CHAT_MEMBER_ROLE_MAP, mapChoice=True)
      continue
    if action in {Act.REMOVE, Act.MODIFY}:
      if myarg in {'member', 'members'}:
        memberNames.extend(getString(Cmd.OB_CHAT_MEMBER).replace(',', ' ').split())
      else:
        unknownArgumentExit()
    else: # {Act.DELETE, Act.UPDATE}
      if myarg == 'space' or myarg.startswith('spaces/') or myarg.startswith('space/'):
        parent = getSpaceName(myarg)
      elif myarg == 'user':
        normalizeUserMember(getEmailAddress(returnUIDprefix='uid:'), userList)
      elif myarg in {'member', 'members'}:
        _, members = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)
        for user in members:
          normalizeUserMember(user, userList)
      elif deleteMode and myarg == 'group':
        getGroupMemberID(cd, getEmailAddress(returnUIDprefix='uid:'), groupList)
      elif deleteMode and myarg == 'groups':
        for group in getEntityList(Cmd.OB_GROUP_ENTITY):
          getGroupMemberID(cd, group, groupList)
      else:
        unknownArgumentExit()
  if not deleteMode and 'role' not in body:
    missingArgumentExit('role')
  if action in {Act.REMOVE, Act.MODIFY}:
    if not memberNames:
      missingArgumentExit('members')
  else: # {Act.DELETE, Act.UPDATE}
    if not parent:
      missingArgumentExit('space')
    if not userList and not groupList:
      missingArgumentExit('user|members|group|groups')
    for user in userList:
      memberNames.append(f'{parent}/members/{user}')
    for group in groupList:
      memberNames.append(f'{parent}/members/group-{group}')
  i, count, users = getEntityArgument(users)
  if useAdminAccess:
    _chkChatAdminAccess(count)
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(api, user, i, count, [Ent.CHAT_SPACE, parent] if parent is not None else None, useAdminAccess)
    if not chat:
      continue
    jcount = len(memberNames)
    entityPerformActionNumItems(kvList, jcount, Ent.CHAT_MEMBER, i, count)
    kvList.extend([Ent.CHAT_MEMBER, ''])
    Ind.Increment()
    if deleteMode:
      _deleteChatMembers(chat, kvList, jcount, memberNames, i, count, kwargsUAA)
    else:
      j = 0
      for name in memberNames:
        j += 1
        kvList[-1] = name
        try:
          member = callGAPI(chat.spaces().members(), 'patch',
                            bailOnInternalError=True,
                            throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.INTERNAL_ERROR,
                                          GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                            name=name, updateMask='role', body=body, **kwargsUAA)
          _getChatMemberEmail(cd, member)
          Ind.Increment()
          _showChatItem(member, Ent.CHAT_MEMBER, FJQC, j, jcount)
          Ind.Decrement()
        except GAPI.notFound as e:
          entityActionFailedWarning(kvList, str(e), j, jcount)
        except (GAPI.invalidArgument, GAPI.permissionDenied, GAPI.internalError) as e:
          exitIfChatNotConfigured(chat, kvList, str(e), i, count)
        except GAPI.failedPrecondition:
          userChatServiceNotEnabledWarning(user, i, count)
          continue
    Ind.Decrement()

CHAT_SYNC_PREVIEW_TITLES = ['space', 'member', 'role', 'action', 'message']

# gam <UserTypeEntity> sync chatmembers [asadmin] <ChatSpace>
#	[role member|manager] [type human|bot]
#	[addonly|removeonly]
#	[preview [actioncsv]]
#	(users <UserTypeEntity>)* (groups <GroupEntity>)*
def syncChatMembers(users):
  def _previewAction(members, entityType, jcount, action):
    Ind.Increment()
    j = 0
    for member in members:
      j += 1
      entityActionPerformed([Ent.CHAT_SPACE, parent, entityType, member, Ent.ROLE, role], j, jcount)
    Ind.Decrement()
    if csvPF:
      for member in members:
        csvPF.WriteRow({'space': parent, 'member': member, 'role': role, 'action': Act.PerformedName(action), 'message': Act.PREVIEW})

  def addMembers(memberNames, members, entityType, i, count):
    jcount = len(memberNames)
    entityPerformActionNumItems(kvList, jcount, entityType, i, count)
    if jcount == 0:
      return
    if preview:
      _previewAction(memberNames, entityType, jcount, Act.REMOVE)
      return
    kvList.extend([entityType, ''])
    Ind.Increment()
    j = 0
    for memberName in memberNames:
      j += 1
      body = members[memberName]
      kvList[-1] = memberName
      try:
        callGAPI(chat.spaces().members(), 'create',
                 bailOnInternalError=True,
                 throwReasons=[GAPI.ALREADY_EXISTS, GAPI.NOT_FOUND, GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.INTERNAL_ERROR],
                 parent=parent, body=body, **kwargsUAA)
        if role != 'ROLE_MEMBER' and entityType == Ent.CHAT_MANAGER_USER:
          callGAPI(chat.spaces().members(), 'patch',
                   bailOnInternalError=True,
                   throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.INTERNAL_ERROR],
                   name=memberName, updateMask='role', body={'role': role}, **kwargsUAA)
        entityActionPerformed(kvList, j, jcount)
      except (GAPI.alreadyExists, GAPI.notFound, GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied, GAPI.internalError) as e:
        entityActionFailedWarning(kvList, str(e), j, jcount)
    Ind.Decrement()
    del kvList[-2:]

  def deleteMembers(memberNames, entityType, i, count):
    jcount = len(memberNames)
    entityPerformActionNumItems(kvList, jcount, entityType, i, count)
    if jcount == 0:
      return
    if preview:
      _previewAction(memberNames, entityType, jcount, Act.ADD)
      return
    kvList.extend([entityType, ''])
    Ind.Increment()
    _deleteChatMembers(chat, kvList, jcount, memberNames, i, count, kwargsUAA)
    Ind.Decrement()
    del kvList[-2:]

  useAdminAccess, api, kwargsUAA = _getChatAdminAccess(API.CHAT_MEMBERSHIPS_ADMIN, API.CHAT_MEMBERSHIPS)
  cd = buildGAPIObject(API.DIRECTORY)
  parent = None
  role = CHAT_MEMBER_ROLE_MAP['member']
  mtype = CHAT_MEMBER_TYPE_MAP['human']
  syncOperation = 'addremove'
  kwargs = {}
  preview = False
  csvPF = None
  userList = []
  usersSpecified = False
  groupList = []
  groupsSpecified = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'space' or myarg.startswith('spaces/') or myarg.startswith('space/'):
      parent = getSpaceName(myarg)
    elif myarg == 'role':
      role = getChoice(CHAT_MEMBER_ROLE_MAP, mapChoice=True)
    elif myarg == 'type':
      mtype = getChoice(CHAT_MEMBER_TYPE_MAP, mapChoice=True)
    elif myarg in {'addonly', 'removeonly'}:
      syncOperation = myarg
    elif myarg == 'preview':
      preview = True
    elif myarg == 'actioncsv':
      csvPF = CSVPrintFile(CHAT_SYNC_PREVIEW_TITLES)
    elif myarg == 'users':
      for user in getEntityList(Cmd.OB_USER_ENTITY):
        getUserMemberID(cd, user, userList)
      usersSpecified = True
    elif myarg in {'member', 'members'}:
      _, members = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)
      for user in members:
        getUserMemberID(cd, user, userList)
      usersSpecified = True
    elif myarg == 'groups':
      for group in getEntityList(Cmd.OB_GROUP_ENTITY):
        getGroupMemberID(cd, group, groupList)
      groupsSpecified = True
    else:
      unknownArgumentExit()
  if not parent:
    missingArgumentExit('space')
  userEntityType = Ent.CHAT_MEMBER_USER if role == 'ROLE_MEMBER' else Ent.CHAT_MANAGER_USER
  userMembers = {}
  syncUsersSet = set()
  for user in userList:
    memberName = f'{parent}/members/{user}'
    userMembers[memberName] = {'member': {'name': f'users/{user}', 'type': mtype}}
    syncUsersSet.add(memberName)
  groupMembers = {}
  syncGroupsSet = set()
  for group in groupList:
    memberName = f'{parent}/members/group-{group}'
    groupMembers[memberName] = {'groupMember': {'name': f'groups/{group}'}}
    syncGroupsSet.add(memberName)
  qfilter = f'{Ent.Singular(Ent.CHAT_SPACE)}: {parent}'
  i, count, users = getEntityArgument(users)
  if useAdminAccess:
    kwargs['filter'] = 'member.type != "BOT"'
    _chkChatAdminAccess(count)
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(api, user, i, count, [Ent.CHAT_SPACE, parent], useAdminAccess)
    if not chat:
      continue
    currentUsersSet = set()
    currentGroupsSet = set()
    try:
      members = callGAPIpages(chat.spaces().members(), 'list', 'memberships',
                              pageMessage=_getChatPageMessage(Ent.CHAT_MEMBER, user, i, count, qfilter),
                              throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                              retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                              parent=parent, showGroups=groupsSpecified, pageSize=CHAT_PAGE_SIZE, **kwargs, **kwargsUAA)
      for member in members:
        if 'member' in member:
          if member['member']['type'] == mtype and member['role'] == role:
            currentUsersSet.add(member['name'])
        elif 'groupMember' in member:
          currentGroupsSet.add(member['name'])
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      exitIfChatNotConfigured(chat, kvList, str(e), i, count)
      continue
    except GAPI.failedPrecondition:
      userChatServiceNotEnabledWarning(user, i, count)
      continue
    if syncOperation != 'addonly':
      Act.Set([Act.REMOVE, Act.REMOVE_PREVIEW][preview])
      if usersSpecified:
        deleteMembers(currentUsersSet-syncUsersSet, userEntityType, i, count)
      if groupsSpecified:
        deleteMembers(currentGroupsSet-syncGroupsSet, Ent.CHAT_MEMBER_GROUP, i, count)
    if syncOperation != 'removeonly':
      Act.Set([Act.ADD, Act.ADD_PREVIEW][preview])
      if usersSpecified:
        addMembers(syncUsersSet-currentUsersSet, userMembers, userEntityType, i, count)
      if groupsSpecified:
        addMembers(syncGroupsSet-currentGroupsSet, groupMembers, Ent.CHAT_MEMBER_GROUP, i, count)
  if csvPF:
    csvPF.writeCSVfile('Chat Member Updates')

CHAT_MEMBERS_FIELDS_CHOICE_MAP = {
  "createtime": "createTime",
  "deletetime": "deleteTime",
  "groupmember": "groupMember",
  "member": "member",
  "name": "name",
  "role": "role",
  "state": "state",
  }

# gam [<UserTypeEntity>] info chatmember members <ChatMemberList>
#	[fields <ChatMemberFieldNameList>]
#	[formatjson]
# gam <UserItem> info chatmember asadmin members <ChatMemberList>
#	[fields <ChatMemberFieldNameList>]
#	[formatjson]
def infoChatMember(users):
  cd = buildGAPIObject(API.DIRECTORY)
  FJQC = FormatJSONQuoteChar()
  useAdminAccess, api, kwargsUAA = _getChatAdminAccess(API.CHAT_MEMBERSHIPS_ADMIN, API.CHAT_MEMBERSHIPS)
  fieldsList = []
  memberNames = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'member', 'members'}:
      memberNames.extend(getString(Cmd.OB_CHAT_MEMBER).replace(',', ' ').split())
    elif getFieldsList(myarg, CHAT_MEMBERS_FIELDS_CHOICE_MAP, fieldsList, initialField='name', onlyFieldsArg=True):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  if not memberNames:
    missingArgumentExit('members')
  fields = getFieldsFromFieldsList(fieldsList)
  i, count, users = getEntityArgument(users)
  if useAdminAccess:
    _chkChatAdminAccess(count)
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(api, user, i, count, None, useAdminAccess)
    if not chat:
      continue
    jcount = len(memberNames)
    if not FJQC.formatJSON:
      entityPerformActionNumItems(kvList, jcount, Ent.CHAT_MEMBER, i, count)
    kvList.extend([Ent.CHAT_MEMBER, ''])
    j = 0
    for name in memberNames:
      j += 1
      kvList[-1] = name
      try:
        member = callGAPI(chat.spaces().members(), 'get',
                          bailOnInternalError=True,
                          throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.INTERNAL_ERROR,
                                        GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                          name=name, fields=fields, **kwargsUAA)
        _getChatMemberEmail(cd, member)
        Ind.Increment()
        _showChatItem(member, Ent.CHAT_MEMBER, FJQC, j, jcount)
        Ind.Decrement()
      except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied, GAPI.internalError) as e:
        exitIfChatNotConfigured(chat, kvList, str(e), i, count)
      except GAPI.failedPrecondition:
        userChatServiceNotEnabledWarning(user, i, count)
        continue

def doInfoChatMember():
  infoChatMember([None])

# gam [<UserTypeEntity>] show chatmembers
#	<ChatSpace>* [types <ChatSpaceTypeList>]
#	[showinvited [<Boolean>]] [showgroups [<Boolean>]] [filter <String>]
#	[fields <ChatMemberFieldNameList>]
#	[formatjson]
# gam [<UserTypeEntity>] print chatmembers [todrive <ToDriveAttribute>*]
#	<ChatSpace>* [types <ChatSpaceTypeList>]
#	[showinvited [<Boolean>]] [showgroups [<Boolean>]] [filter <String>]
#	[fields <ChatMemberFieldNameList>]
#	(addcsvdata <FieldName> <String>)*
#	[formatjson [quotechar <Character>]]
# gam <UserItem> show chatmembers asadmin
#	<ChatSpace>* [query <String>] [querytime<String> <Time>]
#	[orderby <ChatSpaceAdminOrderByFieldName> [ascending|descending]]
#	[showinvited [<Boolean>]] [showgroups [<Boolean>]] [filter <String>]
#	[fields <ChatMemberFieldNameList>]
#	[formatjson]
# gam <UserItem> print chatmembers asadmin [todrive <ToDriveAttribute>*]
#	<ChatSpace>* [query <String>] [querytime<String> <Time>]
#	[orderby <ChatSpaceAdminOrderByFieldName> [ascending|descending]]
#	[showinvited [<Boolean>]] [showgroups [<Boolean>]] [filter <String>]
#	[fields <ChatMemberFieldNameList>]
#	(addcsvdata <FieldName> <String>)*
#	[formatjson [quotechar <Character>]]
def printShowChatMembers(users):
  cd = buildGAPIObject(API.DIRECTORY)
  csvPF = CSVPrintFile(['User', 'space.name', 'space.displayName', 'name'] if not isinstance(users, list) else ['space.name', 'space.displayName', 'name']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  OBY = OrderBy(CHAT_SPACES_ADMIN_ORDERBY_CHOICE_MAP)
  useAdminAccess, api, kwargsUAA = _getChatAdminAccess(API.CHAT_MEMBERSHIPS_ADMIN, API.CHAT_MEMBERSHIPS)
  if useAdminAccess:
    queries = ['customer = "customers/my_customer" AND spaceType = "SPACE"']
    queryTimes = {}
  fieldsList = []
  pfilter = ''
  kwargs = {}
  kwargsCS = {}
  parentList = []
  addCSVData = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'space' or myarg.startswith('spaces/') or myarg.startswith('space/'):
      parentList.append({'name': getSpaceName(myarg), 'displayName': ''})
    elif getFieldsList(myarg, CHAT_MEMBERS_FIELDS_CHOICE_MAP, fieldsList, initialField='name', onlyFieldsArg=True):
      pass
    elif myarg == 'showinvited':
      kwargs['showInvited'] = getBoolean()
    elif myarg == 'showgroups':
      kwargs['showGroups'] = getBoolean()
    elif myarg =='filter':
      pfilter = getString(Cmd.OB_STRING)
    elif not useAdminAccess and _getChatSpaceListParms(myarg, kwargsCS):
      pass
    elif useAdminAccess and _getChatSpaceSearchParms(myarg, queries, queryTimes, OBY):
      pass
    elif csvPF and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if useAdminAccess:
    if not parentList:
      kwargsCS['orderBy'] = OBY.orderBy
      substituteQueryTimes(queries, queryTimes)
      kwargsCS['query'] = queries[0]
      kwargsCS['useAdminAccess'] = True
  else:
    if not parentList and not kwargsCS:
      kwargsCS['filter'] = 'spaceType = "SPACE" OR spaceType = "GROUP_CHAT" OR spaceType = "DIRECT_MESSAGE"'
  if pfilter:
    if 'member.type' not in pfilter:
      kwargs['filter'] = 'member.type != "BOT" AND '+pfilter
    else:
      kwargs['filter'] = pfilter
  else:
    kwargs['filter'] = 'member.type != "BOT"'
  fields = getItemFieldsFromFieldsList('memberships', fieldsList)
  i, count, users = getEntityArgument(users)
  if useAdminAccess:
    _chkChatAdminAccess(count)
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(api, user, i, count, [Ent.CHAT_SPACE, None], useAdminAccess)
    if not chat:
      continue
    if useAdminAccess:
      _, chatsp, _ = buildChatServiceObject(API.CHAT_SPACES_ADMIN, user, i, count, None, useAdminAccess)
    else:
      _, chatsp, _ = buildChatServiceObject(API.CHAT_SPACES, user, i, count, None, useAdminAccess)
    if not chatsp:
      continue
    if kwargsCS:
      if useAdminAccess:
        try:
          spaces = callGAPIpages(chatsp.spaces(), 'search', 'spaces',
                                 pageMessage=_getChatPageMessage(Ent.CHAT_SPACE, user, i, count, queries[0], True),
                                 bailOnInternalError=True,
                                 throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.INTERNAL_ERROR,
                                               GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 fields="nextPageToken,spaces(name,displayName,spaceType,membershipCount)", pageSize=CHAT_PAGE_SIZE,
                                 **kwargsCS)
          for space in spaces:
            if space['spaceType'] == 'SPACE' and 'membershipCount' in space:
              parentList.append({'name': space['name'], 'displayName': space.get('displayName', 'None')})
        except (GAPI.notFound, GAPI.invalidArgument, GAPI.internalError,
                GAPI.permissionDenied, GAPI.failedPrecondition) as e:
          exitIfChatNotConfigured(chat, kvList, str(e), i, count)
          continue
      else:
        try:
          spaces = callGAPIpages(chatsp.spaces(), 'list', 'spaces',
                                 pageMessage=_getChatPageMessage(Ent.CHAT_SPACE, user, i, count, kwargsCS['filter']),
                                 bailOnInternalError=True,
                                 throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.INTERNAL_ERROR,
                                               GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 fields="nextPageToken,spaces(name,displayName,spaceType,membershipCount)", pageSize=CHAT_PAGE_SIZE,
                                 **kwargsCS)
          for space in spaces:
#            if 'membershipCount' in space:
#              parentList.append({'name': space['name'], 'displayName': space.get('displayName', 'None')})
            parentList.append({'name': space['name'], 'displayName': space.get('displayName', 'None')})
        except (GAPI.notFound, GAPI.invalidArgument, GAPI.internalError, GAPI.permissionDenied) as e:
          exitIfChatNotConfigured(chat, kvList, str(e), i, count)
          continue
        except GAPI.failedPrecondition:
          userChatServiceNotEnabledWarning(user, i, count)
          continue
    jcount = len(parentList)
    j = 0
    for parent in parentList:
      j += 1
      parentName = parent['name']
      kvList[-1] = parentName
      qfilter = f'{Ent.Singular(Ent.CHAT_SPACE)}: {parentName}'
      if 'filter' in kwargs:
        qfilter += f', {kwargs["filter"]}'
      try:
        if not parent['displayName']:
          space = callGAPI(chatsp.spaces(), 'get',
                           throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                           name=parentName, fields='displayName', **kwargsUAA)
          parent['displayName'] = space.get('displayName', 'None')
        members = callGAPIpages(chat.spaces().members(), 'list', 'memberships',
                                pageMessage=_getChatPageMessage(Ent.CHAT_MEMBER, user, j, jcount, qfilter),
                                throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                                retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                parent=parentName, fields=fields, pageSize=CHAT_PAGE_SIZE, **kwargs, **kwargsUAA)
        for member in members:
          _getChatMemberEmail(cd, member)
      except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
        exitIfChatNotConfigured(chat, kvList, str(e), j, jcount)
        continue
      if not csvPF:
        kcount = len(members)
        if not FJQC.formatJSON:
          entityPerformActionNumItems(kvList, kcount, Ent.CHAT_MEMBER, j, jcount)
        Ind.Increment()
        k = 0
        for member in members:
          k += 1
          member['space'] = {'name': parentName, 'displayName': parent['displayName']}
          _showChatItem(member, Ent.CHAT_MEMBER, FJQC, k, kcount)
        Ind.Decrement()
      else:
        for member in members:
          _printChatItem(user, member, parent, Ent.CHAT_MEMBER, csvPF, FJQC, addCSVData)
  if csvPF:
    csvPF.writeCSVfile('Chat Members')

def doPrintShowChatMembers():
  printShowChatMembers([None])

def _getChatSenderEmail(cd, sender):
  if sender['type'] == 'HUMAN':
    _, senderUid = sender['name'].split('/')
    sender['email'], _ = convertUIDtoEmailAddressWithType(f'uid:{senderUid}', cd, None, emailTypes=['user'])

def trimChatMessageIfRequired(body):
  msgLen = len(body['text'])
  if msgLen > 4096:
    stderrWarningMsg(Msg.TRIMMED_MESSAGE_FROM_LENGTH_TO_MAXIMUM.format(msgLen, 4096))
    body['text'] = body['text'][:4095]

CHAT_MESSAGE_REPLY_OPTION_MAP = {
  'fail': 'REPLY_MESSAGE_OR_FAIL',
  'fallbacktonew': 'REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD',
  }

# gam [<UserTypeEntityu>] create chatmessage <ChatSpace>
#	<ChatContent>
#	[messageId <ChatMessageID>]
#	[(thread <ChatThread>)|(threadkey <String>) [replyoption fail|fallbacktonew]]
#	[returnidonly]
def createChatMessage(users):
  messageId = messageReplyOption = parent = None
  body = {}
  returnIdOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'space' or myarg.startswith('spaces/') or myarg.startswith('space/'):
      parent = getSpaceName(myarg)
    elif myarg == 'thread':
      body.setdefault('thread', {})
      body['thread']['name'] = getString(Cmd.OB_CHAT_THREAD)
      Act.Set(Act.RESPOND)
    elif myarg == 'threadkey':
      body.setdefault('thread', {})
      body['thread']['threadKey'] = getString(Cmd.OB_STRING)
      Act.Set(Act.RESPOND)
    elif myarg == 'replyoption':
      messageReplyOption = getChoice(CHAT_MESSAGE_REPLY_OPTION_MAP, mapChoice=True)
    elif myarg in SORF_TEXT_ARGUMENTS:
      body['text'] = getStringOrFile(myarg, minLen=0, unescapeCRLF=True)[0]
    elif myarg == 'messageid':
      messageId = getString(Cmd.OB_CHAT_MESSAGE_ID, minLen=1, maxLen=63)
    elif myarg == 'returnidonly':
      returnIdOnly = True
    else:
      unknownArgumentExit()
  if not parent:
    missingArgumentExit('space')
  if 'text' not in body:
    missingArgumentExit('text or textfile')
  if 'thread' in body and messageReplyOption is None:
    messageReplyOption = CHAT_MESSAGE_REPLY_OPTION_MAP['fail']
  trimChatMessageIfRequired(body)
  action = Act.Get()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(API.CHAT_MESSAGES, user, i, count, [Ent.CHAT_SPACE, parent])
    if not chat:
      continue
    try:
      resp = callGAPI(chat.spaces().messages(), 'create',
                      throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                      parent=parent, requestId=str(uuid.uuid4()),
                      messageReplyOption=messageReplyOption, messageId=messageId, body=body)
      if not returnIdOnly:
        kvList.extend([Ent.CHAT_MESSAGE, resp['name']])
        if 'clientAssignedMessageId' in resp:
          kvList.extend([Ent.CHAT_MESSAGE_ID, resp['clientAssignedMessageId']])
        kvList.extend([Ent.CHAT_THREAD, resp['thread']['name']])
        if (action == Act.RESPOND) and not resp.get('threadReply', False):
          Act.Set(Act.CREATE)
        entityActionPerformed(kvList, i, count)
        Act.Set(action)
      else:
        writeStdout(f'{resp["name"]}\n')
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      exitIfChatNotConfigured(chat, kvList, str(e), i, count)
    except GAPI.failedPrecondition:
      userChatServiceNotEnabledWarning(user, i, count)

def doCreateChatMessage():
  createChatMessage([None])

# gam [<UserTypeMessage>] update chatmessage name <ChatMessage>
#	<ChatContent>
def updateChatMessage(users):
  name = None
  body = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'name':
      name = getString(Cmd.OB_CHAT_MESSAGE)
    elif myarg in SORF_TEXT_ARGUMENTS:
      body['text'] = getStringOrFile(myarg, minLen=0, unescapeCRLF=True)[0]
    else:
      unknownArgumentExit()
  if not name:
    missingArgumentExit('name')
  if 'text' not in body:
    missingArgumentExit('text or textfile')
  trimChatMessageIfRequired(body)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(API.CHAT_MESSAGES, user, i, count, [Ent.CHAT_MESSAGE, name])
    if not chat:
      continue
    try:
      resp = callGAPI(chat.spaces().messages(), 'patch',
                      throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                      name=name, updateMask='text', body=body)
      kvList.extend([Ent.CHAT_THREAD, resp['thread']['name']])
      entityActionPerformed(kvList, i, count)
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      exitIfChatNotConfigured(chat, kvList, str(e), i, count)
    except GAPI.failedPrecondition:
      userChatServiceNotEnabledWarning(user, i, count)

def doUpdateChatMessage():
  updateChatMessage([None])

# gam [<UserTypeEntity>] delete chatmessage name <ChatMessage>
def deleteChatMessage(users):
  name = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'name':
      name = getString(Cmd.OB_CHAT_MESSAGE)
    else:
      unknownArgumentExit()
  if not name:
    missingArgumentExit('name')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(API.CHAT_MESSAGES, user, i, count, [Ent.CHAT_MESSAGE, name])
    if not chat:
      continue
    try:
      callGAPI(chat.spaces().messages(), 'delete',
               throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
               name=name)
      entityActionPerformed(kvList, i, count)
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      exitIfChatNotConfigured(chat, kvList, str(e), i, count)
    except GAPI.failedPrecondition:
      userChatServiceNotEnabledWarning(user, i, count)

def doDeleteChatMessage():
  deleteChatMessage([None])

CHAT_MESSAGES_FIELDS_CHOICE_MAP = {
  "accessorywidgets": "accessoryWidgets",
  "actionresponse": "actionResponse",
  "annotations": "annotations",
  "argumenttext": "argumentText",
  "attachedgifs": "attachedGifs",
  "attachment": "attachment",
  "cards": "cards",
  "cardsv2": "cardsV2",
  "clientassignedmessageid": "clientAssignedMessageId",
  "createtime": "createTime",
  "deletetime": "deleteTime",
  "deletionmetadata": "deletionMetadata",
  "emojireactionsummaries": "emojiReactionSummaries",
  "fallbacktext": "fallbackText",
  "formattedtext": "formattedText",
  "lastupdatetime": "lastUpdateTime",
  "matchedurl": "matchedUrl",
  "name": "name",
  "privatemessageviewer": "privateMessageViewer",
  "quotedmessagemetadata": "quotedMessageMetadata",
  "sender": "sender",
  "slashcommand": "slashCommand",
  "space": "space",
  "text": "text",
  "thread": "thread",
  "threadreply": "threadReply",
  }

# gam [<UserTypeEntity>] info chatmessage name <ChatMessage>
#	[fields <ChatMessageFieldNameList>]
#	[formatjson]
def infoChatMessage(users):
  cd = buildGAPIObject(API.DIRECTORY)
  FJQC = FormatJSONQuoteChar()
  fieldsList = []
  name = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'name':
      name = getString(Cmd.OB_CHAT_MESSAGE)
    elif getFieldsList(myarg, CHAT_MESSAGES_FIELDS_CHOICE_MAP, fieldsList, initialField='name', onlyFieldsArg=True):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  if not name:
    missingArgumentExit('name')
  fields = getFieldsFromFieldsList(fieldsList)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(API.CHAT_MESSAGES, user, i, count, [Ent.CHAT_MESSAGE, name])
    if not chat:
      continue
    try:
      message = callGAPI(chat.spaces().messages(), 'get',
                         throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                         name=name, fields=fields)
      _getChatSenderEmail(cd, message['sender'])
      if not FJQC.formatJSON:
        entityPerformAction(kvList, i, count)
      Ind.Increment()
      _showChatItem(message, Ent.CHAT_MESSAGE, FJQC)
      Ind.Decrement()
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      exitIfChatNotConfigured(chat, kvList, str(e), i, count)
    except GAPI.failedPrecondition:
      userChatServiceNotEnabledWarning(user, i, count)

def doInfoChatMessage():
  infoChatMessage([None])

# gam <UserTypeEntity> show chatmessages
#	<ChatSpace>+
#	[showdeleted [<Boolean>]] [filter <String>]
#	[fields <ChatMessageFieldNameList>]
#	[formatjson]
# gam <UserTypeEntity> print chatmessages [todrive <ToDriveAttribute>*]
#	<ChatSpace>+
#	[showdeleted [<Boolean>]] [filter <String>]
#	[fields <ChatMessageFieldNameList>]
#	[formatjson [quotechar <Character>]]
def printShowChatMessages(users):
  cd = buildGAPIObject(API.DIRECTORY)
  csvPF = CSVPrintFile(['User', 'space.name', 'space.displayName', 'name'] if not isinstance(users, list) else ['space.name', 'space.displayName', 'name']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  fieldsList = []
  pfilter = None
  parentList = []
  showDeleted = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'space' or myarg.startswith('spaces/') or myarg.startswith('space/'):
      parentList.append({'name': getSpaceName(myarg), 'displayName': ''})
    elif getFieldsList(myarg, CHAT_MESSAGES_FIELDS_CHOICE_MAP, fieldsList, initialField='name', onlyFieldsArg=True):
      pass
    elif myarg == 'showdeleted':
      showDeleted = getBoolean()
    elif myarg =='filter':
      pfilter = getString(Cmd.OB_STRING)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if not parentList:
    missingArgumentExit('space')
  fields = getItemFieldsFromFieldsList('messages', fieldsList)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(API.CHAT_MESSAGES, user, i, count, [Ent.CHAT_SPACE, None])
    if not chat:
      continue
    _, chatspg, _ = buildChatServiceObject(API.CHAT_SPACES, user, i, count, None)
    if not chatspg:
      continue
    jcount = len(parentList)
    j = 0
    for parent in parentList:
      j += 1
      parentName = parent['name']
      kvList[-1] = parentName
      qfilter = f'{Ent.Singular(Ent.CHAT_SPACE)}: {parentName}'
      if pfilter:
        qfilter += f', {pfilter}'
      try:
        if not parent['displayName']:
          space = callGAPI(chatspg.spaces(), 'get',
                           throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                           name=parentName, fields='displayName')
          parent['displayName'] = space.get('displayName', 'None')
        messages = callGAPIpages(chat.spaces().messages(), 'list', 'messages',
                                 pageMessage=_getChatPageMessage(Ent.CHAT_MESSAGE, user, i, count, qfilter),
                                 throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 pageSize=CHAT_PAGE_SIZE, parent=parentName, filter=pfilter, showDeleted=showDeleted,
                                 fields=fields)
        for message in messages:
          if 'sender' in message:
            _getChatSenderEmail(cd, message['sender'])
      except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
        exitIfChatNotConfigured(chat, kvList, str(e), i, count)
        continue
      except GAPI.failedPrecondition:
        userChatServiceNotEnabledWarning(user, i, count)
        break
      if not csvPF:
        kcount = len(messages)
        if not FJQC.formatJSON:
          entityPerformActionNumItems(kvList, kcount, Ent.CHAT_MESSAGE, j, jcount)
        Ind.Increment()
        k = 0
        for message in messages:
          k += 1
          if 'space' in message:
            message['space']['displayName'] = parent['displayName']
          _showChatItem(message, Ent.CHAT_MESSAGE, FJQC, k, kcount)
        Ind.Decrement()
      else:
        for message in messages:
          _printChatItem(user, message, parent, Ent.CHAT_MESSAGE, csvPF, FJQC)
  if csvPF:
    csvPF.writeCSVfile('Chat Messages')

# gam <UserTypeEntity> info chatevent name <ChatEvent>
#	[formatjson]
def infoChatEvent(users):
  FJQC = FormatJSONQuoteChar()
  name = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'name':
      name = getString(Cmd.OB_CHAT_EVENT)
    else:
      FJQC.GetFormatJSON(myarg)
  if not name:
    missingArgumentExit('name')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(API.CHAT_EVENTS, user, i, count, [Ent.CHAT_EVENT, name])
    if not chat:
      continue
    try:
      event = callGAPI(chat.spaces().spaceEvents(), 'get',
                       throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                       name=name)
      if not FJQC.formatJSON:
        entityPerformAction(kvList, i, count)
      Ind.Increment()
      _showChatItem(event, Ent.CHAT_EVENT, FJQC)
      Ind.Decrement()
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      exitIfChatNotConfigured(chat, kvList, str(e), i, count)
    except GAPI.failedPrecondition:
      userChatServiceNotEnabledWarning(user, i, count)

def doInfoChatEvent():
  infoChatEvent([None])

# gam <UserTypeEntity> show chatevents
#       <ChatSpace>+
#	filter <String>
#	[formatjson]
# gam <UserTypeEntity> print chatevents [todrive <ToDriveAttribute>*]
#       <ChatSpace>+
#	filter <String>
#	[formatjson [quotechar <Character>]]
def printShowChatEvents(users):
  csvPF = CSVPrintFile(['User', 'space.name', 'space.displayName', 'name'] if not isinstance(users, list) else ['space.name', 'space.displayName', 'name']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  pfilter = None
  parentList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'space' or myarg.startswith('spaces/') or myarg.startswith('space/'):
      parentList.append({'name': getSpaceName(myarg), 'displayName': ''})
    elif myarg =='filter':
      pfilter = getString(Cmd.OB_STRING)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if not parentList:
    missingArgumentExit('space')
  if not pfilter:
    missingArgumentExit('filter')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, chat, kvList = buildChatServiceObject(API.CHAT_EVENTS, user, i, count, [Ent.CHAT_SPACE, None])
    if not chat:
      continue
    _, chatspg, _ = buildChatServiceObject(API.CHAT_SPACES, user, i, count, None)
    if not chatspg:
      continue
    jcount = len(parentList)
    j = 0
    for parent in parentList:
      j += 1
      parentName = parent['name']
      kvList[-1] = parentName
      qfilter = f'{Ent.Singular(Ent.CHAT_SPACE)}: {parentName}, {pfilter}'
      try:
        if not parent['displayName']:
          space = callGAPI(chatspg.spaces(), 'get',
                           throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                           name=parentName, fields='displayName')
          parent['displayName'] = space.get('displayName', 'None')
        events = callGAPIpages(chat.spaces().spaceEvents(), 'list', 'spaceEvents',
                               pageMessage=_getChatPageMessage(Ent.CHAT_EVENT, user, i, count, qfilter),
                               throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                               pageSize=CHAT_PAGE_SIZE, parent=parentName, filter=pfilter)
      except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
        exitIfChatNotConfigured(chat, kvList, str(e), i, count)
        continue
      except GAPI.failedPrecondition:
        userChatServiceNotEnabledWarning(user, i, count)
        break
      if not csvPF:
        kcount = len(events)
        if not FJQC.formatJSON:
          entityPerformActionNumItems(kvList, kcount, Ent.CHAT_EVENT, j, jcount)
        Ind.Increment()
        k = 0
        for event in events:
          k += 1
          event['space'] = {'name': parentName, 'displayName': parent['displayName']}
          _showChatItem(event, Ent.CHAT_EVENT, FJQC, k, kcount)
        Ind.Decrement()
      else:
        for event in events:
          _printChatItem(user, event, parent, Ent.CHAT_EVENT, csvPF, FJQC)
  if csvPF:
    csvPF.writeCSVfile('Chat Events')

def buildMeetServiceObject(api=API.MEET, user=None, i=0, count=0, entityTypeList=None):
  if GC.Values[GC.MEET_V2_BETA]:
    api = API.MEET_BETA
  user, meet = buildGAPIServiceObject(api, user, i, count)
  kvList = [Ent.USER, user]
  if entityTypeList is not None:
    kvList.extend(entityTypeList)
  return user, meet, kvList

def _showMeetItem(mitem, FJQC=None, i=0, count=0):
  if FJQC is not None and FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(mitem), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.MEET_SPACE, mitem['name']], i, count)
  Ind.Increment()
  showJSON(None, mitem)
  Ind.Decrement()

MEET_SPACE_OPTIONS_MAP = {
  'accesstype': 'accessType',
  'entrypointaccess': 'entryPointAccess',
  'moderation': 'moderation',
  'chatrestriction': 'chatRestriction',
  'reactionrestriction': 'reactionRestriction',
  'presentrestriction': 'presentRestriction',
  'defaultjoinasviewer': 'defaultJoinAsViewerType',
  'firstjoiner': 'firstJoinerType',
  'autorecording': 'recordingConfig',
  'autosmartnotes': 'smartNotesConfig',
  'autotranscription': 'transcriptionConfig',
  }

MEET_SPACE_ACCESSTYPE_CHOICES = {'open', 'trusted', 'restricted'}
MEET_SPACE_ENTRYPOINTACCESS_CHOICES_MAP = {
  'all': 'ALL',
  'creatorapponly': 'CREATOR_APP_ONLY'
  }

MEET_SPACE_RESTRICTIONS_CHOICES_MAP = {
  'hostsonly': 'HOSTS_ONLY',
  'norestriction': 'NO_RESTRICTION'
  }

#MEET_SPACE_FIRSTJOINERTYPE_CHOICES_MAP = {
#  'hostsonly': 'HOSTS_ONLY',
#  'anyone': 'ANYONE'
#  }

MEET_SPACE_ARTIFACT_SUB_OPTIONS = {
  'recordingConfig': 'autoRecordingGeneration',
  'smartNotesConfig': 'autoSmartNotesGeneration',
  'transcriptionConfig': 'autoTranscriptionGeneration'
  }

#	[accesstype open|trusted|restricted]
#	[entrypointaccess all|creatorapponly]
#	[moderation <Boolean>]
#	[chatrestriction hostsonly|norestriction]
#	[reactionrestriction hostsonly|norestriction]
#	[presentrestriction hostsonly|norestriction]
#	[defaultjoinasviewer <Boolean>]
#	[firstjoiner hostsonly|anyone]
#	[autorecording <Boolean>]
#	[autosmartnotes <Boolean>]
#	[autotranscription <Boolean>]
def _getMeetSpaceParameters(myarg, body):
  option = MEET_SPACE_OPTIONS_MAP.get(myarg, None)
  if option is None:
    return False
  if option == 'accessType':
    body['config'][option] = getChoice(MEET_SPACE_ACCESSTYPE_CHOICES).upper()
  elif option == 'entryPointAccess':
    body['config'][option] = getChoice(MEET_SPACE_ENTRYPOINTACCESS_CHOICES_MAP, mapChoice=True)
  elif option == 'moderation':
    body['config'][option] = 'ON' if getBoolean() else 'OFF'
  elif option in {'chatRestriction', 'reactionRestriction', 'presentRestriction'}:
    body['config'].setdefault('moderationRestrictions', {})
    body['config']['moderationRestrictions'][option] = getChoice(MEET_SPACE_RESTRICTIONS_CHOICES_MAP, mapChoice=True)
  elif option == 'defaultJoinAsViewerType':
    body['config'][option] = 'ON' if getBoolean() else 'OFF'
#  elif option == 'firstJoinerType':
#    body['config'][option] = getChoice(MEET_SPACE_FIRSTJOINERTYPE_CHOICES_MAP, mapChoice=True)
  elif option in {'recordingConfig', 'transcriptionConfig', 'smartNotesConfig'}:
    body['config'].setdefault('artifactConfig', {})
    body['config']['artifactConfig'].setdefault(option, {})
    body['config']['artifactConfig'][option][MEET_SPACE_ARTIFACT_SUB_OPTIONS[option]] = 'ON' if getBoolean() else 'OFF'
  return True

# gam <UserTypeEntity> create meetspace
#	<MeetSpaceOptions>*
#	[formatjson|returnidonly]
def createMeetSpace(users):
  FJQC = FormatJSONQuoteChar()
  body = {'config': {'accessType': 'TRUSTED',
                     'entryPointAccess': 'ALL',
#                     'moderation': 'OFF',
#                     'moderationRestrictions': {},
#                     'firstJoinerType': 'ANYONE',
                     }}
  returnIdOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if _getMeetSpaceParameters(myarg, body):
      pass
    elif myarg == 'returnidonly':
      returnIdOnly = True
    else:
      FJQC.GetFormatJSON(myarg)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, meet, kvList = buildMeetServiceObject(API.MEET, user, i, count, [Ent.MEET_SPACE, None])
    if not meet:
      continue
    try:
      space = callGAPI(meet.spaces(), 'create',
                       throwReasons=[GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                       body=body)
      if not returnIdOnly:
        kvList[-1] = space['name']
        if not FJQC.formatJSON:
          entityActionPerformed(kvList, i, count)
        Ind.Increment()
        _showMeetItem(space, FJQC)
        Ind.Decrement()
      else:
        writeStdout(f'{space["name"]}\n')
    except (GAPI.invalidArgument, GAPI.permissionDenied) as e:
      entityActionFailedExit([Ent.MEET_SPACE, None], str(e))

# gam <UserTypeEntity> update meetspace <MeetSpaceName>
#	<MeetSpaceOptions>*
#	[formatjson]
def updateMeetSpace(users):
  FJQC = FormatJSONQuoteChar()
  name = None
  body = {'config': {}}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if (myarg == 'space' or myarg.startswith('spaces/') or myarg.startswith('space/')):
      name = getSpaceName(myarg)
    elif _getMeetSpaceParameters(myarg, body):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  if not name:
    missingArgumentExit('space')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, meet, kvList = buildMeetServiceObject(API.MEET, user, i, count, [Ent.MEET_SPACE, name])
    if not meet:
      continue
    try:
      space = callGAPI(meet.spaces(), 'patch',
                       throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                       name=name, updateMask='', body=body)
      if not FJQC.formatJSON:
        entityActionPerformed(kvList, i, count)
      Ind.Increment()
      _showMeetItem(space, FJQC)
      Ind.Decrement()
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      entityActionFailedExit([Ent.MEET_SPACE, name], str(e))

# gam <UserTypeEntity> info meetspace <MeetSpaceName>
#	[formatjson]
def infoMeetSpace(users):
  FJQC = FormatJSONQuoteChar()
  name = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if (myarg == 'space' or myarg.startswith('spaces/') or myarg.startswith('space/')):
      name = getSpaceName(myarg)
    else:
      FJQC.GetFormatJSON(myarg)
  if not name:
    missingArgumentExit('space')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, meet, kvList = buildMeetServiceObject(API.MEET, user, i, count, [Ent.MEET_SPACE, name])
    if not meet:
      continue
    try:
      space = callGAPI(meet.spaces(), 'get',
                       throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                       name=name)
      if not FJQC.formatJSON:
        entityPerformAction(kvList, i, count)
      Ind.Increment()
      _showMeetItem(space, FJQC)
      Ind.Decrement()
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      entityActionFailedExit(kvList, str(e))

# gam <UserTypeEntity> end meetconference <MeetSpaceName>
def endMeetConference(users):
  name = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if (myarg == 'space' or myarg.startswith('spaces/') or myarg.startswith('space/')):
      name = getSpaceName(myarg)
    else:
      unknownArgumentExit()
  if not name:
    missingArgumentExit('space')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, meet, kvList = buildMeetServiceObject(API.MEET, user, i, count, [Ent.MEET_SPACE, name, Ent.MEET_CONFERENCE, None])
    if not meet:
      continue
    try:
      callGAPI(meet.spaces(), 'endActiveConference',
               throwReasons=[GAPI.FAILED_PRECONDITION, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
               name=name)
      entityActionPerformed(kvList, i,count)
    except (GAPI.failedPrecondition, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      entityActionFailedExit([Ent.MEET_CONFERENCE, name], str(e))

def _getMeetPageMessage(entityType, user, i, count, pfilter):
  printGettingAllEntityItemsForWhom(entityType, user, i, count, pfilter)
  return getPageMessageForWhom()

MEET_CONFERENCE_TIME_OBJECTS = {'startTime', 'endTime', 'expireTime', 'earliestStartTime', 'latestEndTime'}

def _showMeetConfItem(citem, entityType, FJQC, i=0, count=0):
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(citem, timeObjects=MEET_CONFERENCE_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  printEntity([entityType, citem['name']], i, count)
  Ind.Increment()
  showJSON(None, citem, timeObjects=MEET_CONFERENCE_TIME_OBJECTS)
  Ind.Decrement()

def _printMeetConfItem(user, citem, csvPF, FJQC):
  baserow = {'User': user}
  row = flattenJSON(citem, flattened=baserow.copy(), timeObjects=MEET_CONFERENCE_TIME_OBJECTS)
  if not FJQC.formatJSON:
    csvPF.WriteRowTitles(row)
  elif csvPF.CheckRowTitles(row):
    row = baserow.copy()
    row.update({'name': citem['name'],
                'JSON': json.dumps(cleanJSON(citem, timeObjects=MEET_CONFERENCE_TIME_OBJECTS), ensure_ascii=False, sort_keys=True)})
    csvPF.WriteRowNoFilter(row)

# gam <UserItem> show meetconferences
#	[space <MeetSpaceName>] [code <String>]
#	[andquery|orquery <String>] [querytime<String> <Time>]
#	[formatjson]
# gam <UserItem> print meetconferences [todrive <ToDriveAttribute>*]
#	[space <MeetSpaceName>] [code <String>]
#	[andquery|orquery <String>] [querytime<String> <Time>]
#	[formatjson [quotechar <Character>]]
def printShowMeetConferences(users):
  def _updateQuery(conjunction, clause):
    if queries[0]:
      queries[0] += f' {conjunction} '
    queries[0] += clause

  csvPF = CSVPrintFile(['User', 'space', 'name', 'startTime', 'endTime', 'expireTime']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  queries = ['']
  queryTimes = {}
  pfilter = ''
  kwargs = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif (myarg == 'space' or myarg.startswith('spaces/') or myarg.startswith('space/')):
      _updateQuery('AND', f'space.name = "{getSpaceName(myarg)}"')
    elif myarg == 'code':
      _updateQuery('AND', f'space.meeting_code = "{getString(Cmd.OB_STRING)}"')
    elif  myarg == 'andquery':
      _updateQuery('AND', getString(Cmd.OB_QUERY))
    elif  myarg == 'orquery':
      _updateQuery('OR', getString(Cmd.OB_QUERY))
    elif myarg.startswith('querytime'):
      queryTimes[myarg] = getTimeOrDeltaFromNow()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  substituteQueryTimes(queries, queryTimes)
  if queries[0]:
    pfilter = kwargs['filter'] = queries[0]
  else:
    pfilter = None
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, meet, kvList = buildMeetServiceObject(API.MEET, user, i, count, [Ent.MEET_CONFERENCE, None])
    if not meet:
      continue
    try:
      confRecs = callGAPIpages(meet.conferenceRecords(), 'list', 'conferenceRecords',
                               pageMessage=_getMeetPageMessage(Ent.MEET_CONFERENCE, user, i, count, pfilter),
                               throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                               pageSize=100, **kwargs)
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      entityActionFailedExit(kvList, str(e))
      continue
    jcount = len(confRecs)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
    if not csvPF:
      if not FJQC.formatJSON:
        entityPerformActionNumItems(kvList, jcount, Ent.MEET_CONFERENCE, i, count)
      Ind.Increment()
      j = 0
      for confRec in confRecs:
        j += 1
        _showMeetConfItem(confRec, Ent.MEET_CONFERENCE, FJQC, j, jcount)
      Ind.Decrement()
    else:
      for confRec in confRecs:
        _printMeetConfItem(user, confRec, csvPF, FJQC)
  if csvPF:
    csvPF.writeCSVfile(Ent.Plural(Ent.MEET_CONFERENCE))

# gam <UserItem> show meetparticipants <MeetConferenceName>
#	[query <String>] [querytime<String> <Time>]
#	[formatjson]
# gam <UserItem> print meetparticipants <MeetConferenceName> [todrive <ToDriveAttribute>*]
#	[query <String>] [querytime<String> <Time>]
#	[formatjson [quotechar <Character>]]
# gam <UserItem> show meetrecordings <MeetConferenceName>
#	[formatjson]
# gam <UserItem> print meetrecordings <MeetConferenceName> [todrive <ToDriveAttribute>*]
#	[formatjson [quotechar <Character>]]
# gam <UserItem> show meettranscripts <MeetConferenceName>
#	[formatjson]
# gam <UserItem> print meettranscripts <MeetConferenceName> [todrive <ToDriveAttribute>*]
#	[formatjson [quotechar <Character>]]
def _printShowMeetItems(users, entityType):
  def _updateQuery(conjunction, clause):
    if queries[0]:
      queries[0] += f' {conjunction} '
    queries[0] += clause

  csvPF = CSVPrintFile(['User', 'name']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  queries = ['']
  queryTimes = {}
  pfilter = ''
  kwargs = {}
  parent = getString(Cmd.OB_MEET_CONFERENCE_NAME)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif entityType == Ent.MEET_PARTICIPANT and myarg == 'andquery':
      _updateQuery('AND', getString(Cmd.OB_QUERY))
    elif entityType == Ent.MEET_PARTICIPANT and myarg == 'orquery':
      _updateQuery('OR', getString(Cmd.OB_QUERY))
    elif entityType == Ent.MEET_PARTICIPANT and myarg.startswith('querytime'):
      queryTimes[myarg] = getTimeOrDeltaFromNow()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  substituteQueryTimes(queries, queryTimes)
  if queries[0]:
    pfilter = kwargs['filter'] = queries[0]
  else:
    pfilter = None
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, meet, kvList = buildMeetServiceObject(API.MEET, user, i, count, [Ent.MEET_CONFERENCE, parent])
    if not meet:
      continue
    if entityType == Ent.MEET_PARTICIPANT:
      service = meet.conferenceRecords().participants()
      recType = 'participants'
      pageSize = 250
    elif entityType == Ent.MEET_RECORDING:
      service = meet.conferenceRecords().recordings()
      recType = 'recordings'
      pageSize = 100
    else:
      service = meet.conferenceRecords().transcripts()
      recType = 'transcripts'
      pageSize = 100
    try:
      confRecs = callGAPIpages(service, 'list', recType,
                               pageMessage=_getMeetPageMessage(entityType, user, i, count, pfilter),
                               throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                               parent=parent, pageSize=pageSize, **kwargs)
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      entityActionFailedExit(kvList, str(e))
      continue
    jcount = len(confRecs)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
    if not csvPF:
      if not FJQC.formatJSON:
        entityPerformActionNumItems(kvList, jcount, entityType, i, count)
      Ind.Increment()
      j = 0
      for confRec in confRecs:
        j += 1
        _showMeetConfItem(confRec, entityType, FJQC, j, jcount)
      Ind.Decrement()
    else:
      for confRec in confRecs:
        _printMeetConfItem(user, confRec, csvPF, FJQC)
  if csvPF:
    csvPF.writeCSVfile(Ent.Plural(entityType))

def printShowMeetParticipants(users):
  _printShowMeetItems(users, Ent.MEET_PARTICIPANT)

def printShowMeetRecordings(users):
  _printShowMeetItems(users, Ent.MEET_RECORDING)

def printShowMeetTranscripts(users):
  _printShowMeetItems(users, Ent.MEET_TRANSCRIPT)

def _getOrgunitsOrgUnitIdPath(cd, orgUnit):
  if orgUnit.startswith('orgunits/'):
    orgUnit = f'id:{orgUnit[9:]}'
  orgUnitPath, orgUnitId = getOrgUnitId(cd, orgUnit)
  return (orgUnitPath, f'orgunits/{orgUnitId[3:]}')

def _getChromePolicySchemaName():
  name = getString(Cmd.OB_SCHEMA_NAME)
  if not name.startswith('customers'):
    name = f'customers/{GC.Values[GC.CUSTOMER_ID]}/policySchemas/{name}'
  return name

def _getChromePolicySchema(cp, name, fields):
  if not name.startswith('customers'):
    name = f'customers/{GC.Values[GC.CUSTOMER_ID]}/policySchemas/{name}'
  try:
    return callGAPI(cp.customers().policySchemas(), 'get',
                    throwReasons=[GAPI.NOT_FOUND, GAPI.BAD_REQUEST, GAPI.FORBIDDEN],
                    name=name, fields=fields)
  except GAPI.notFound:
    entityDoesNotExistExit(Ent.CHROME_POLICY_SCHEMA, name)
  except (GAPI.badRequest, GAPI.forbidden):
    accessErrorExit(None)

def commonprefix(m):
  '''Given a list of strings m, return string which is prefix common to all'''
  s1 = min(m)
  loc = s1.find('ENUM_')
  if loc > 0:
    return s1[:loc+5]
  s2 = max(m)
  for i, c in enumerate(s1):
    if c != s2[i]:
      return s1[:i]
  return s1

def simplifyChromeSchema(schema):
  schema_name = schema['name'].split('/')[-1]
  schema_dict = {'name': schema_name,
                 'description': schema.get('policyDescription', ''),
                 'settings': {}
                }
  fieldDescriptions = schema['fieldDescriptions']
  for mtype in schema['definition']['messageType']:
    for setting in mtype['field']:
      setting_name = setting['name']
      setting_dict = {'name': setting_name,
                      'constraints': None,
                      'descriptions':  [],
                      'type': setting['type'],
                     }
      if setting_dict['type'] == 'TYPE_STRING' and setting.get('label') == 'LABEL_REPEATED':
        setting_dict['type'] = 'TYPE_LIST'
      if setting_dict['type'] == 'TYPE_ENUM':
        type_name = setting['typeName']
        for an_enum in schema['definition']['enumType']:
          if an_enum['name'] == type_name:
            setting_dict['enums'] = [enum['name'] for enum in an_enum['value']]
            setting_dict['enum_prefix'] = commonprefix(setting_dict['enums'])
            prefix_len = len(setting_dict['enum_prefix'])
            setting_dict['enums'] = [enum[prefix_len:] for enum in setting_dict['enums'] if not enum.endswith('UNSPECIFIED')]
            setting_dict['descriptions'] = ['']*len(setting_dict['enums'])
            for i, an in enumerate(setting_dict['enums']):
              for fdesc in fieldDescriptions:
                if fdesc.get('field') == setting_name:
                  for d in fdesc.get('knownValueDescriptions', []):
                    if d['value'][prefix_len:] == an:
                      setting_dict['descriptions'][i] = d.get('description', '')
                      break
                  break
            break
      elif setting_dict['type'] == 'TYPE_MESSAGE':
        continue
      else:
        setting_dict['enums'] = None
        for fdesc in schema['fieldDescriptions']:
          if fdesc['field'] == setting_name:
            if 'knownValueDescriptions' in fdesc:
              setting_dict['descriptions'] = fdesc['knownValueDescriptions']
            elif 'description' in fdesc:
              setting_dict['descriptions'] = [fdesc['description']]
      schema_dict['settings'][setting_name.lower()] = setting_dict
  return(schema_name, schema_dict)

def _getPolicyOrgUnitTarget(cd, cp, myarg, groupEmail):
  if groupEmail:
    Cmd.Backup()
    usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format(myarg, 'group'))
  targetName, targetResource = _getOrgunitsOrgUnitIdPath(cd, getString(Cmd.OB_ORGUNIT_PATH))
  return (targetName, targetName, targetResource, Ent.ORGANIZATIONAL_UNIT, cp.customers().policies().orgunits())

def _getPolicyGroupTarget(cd, cp, myarg, orgUnit):
  if orgUnit:
    Cmd.Backup()
    usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format(myarg, 'ou|org|orgunit'))
  targetName = getEmailAddress(returnUIDprefix='uid:')
  targetResource = f"groups/{convertEmailAddressToUID(targetName, cd, emailType='group')}"
  return (targetName, targetName, targetResource, Ent.GROUP, cp.customers().policies().groups())

def checkPolicyArgs(targetResource, printer_id, app_id):
  if not targetResource:
    missingArgumentExit('ou|org|orgunit|group')
  if printer_id and app_id:
    usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format('printerid', 'appid'))

def setPolicyKVList(baseList, printer_id, app_id):
  kvList = baseList[:]
  if app_id:
    kvList.extend([Ent.APP_ID, app_id])
  elif printer_id:
    kvList.extend([Ent.PRINTER_ID, printer_id])
  return kvList

def updatePolicyRequests(body, targetResource, printer_id, app_id):
  for request in body['requests']:
    request.setdefault('policyTargetKey', {})
    request['policyTargetKey']['targetResource'] = targetResource
    if app_id or printer_id:
      request['policyTargetKey'].setdefault('additionalTargetKeys', {})
      if app_id:
        request['policyTargetKey']['additionalTargetKeys']['app_id'] = app_id
      elif printer_id:
        request['policyTargetKey']['additionalTargetKeys']['printer_id'] = printer_id

# gam delete chromepolicy
#	(<SchemaName> [<JSONData>])+
#	((ou|org|orgunit <OrgUnitItem>)|(group <GroupItem>))
#	[(printerid <PrinterID>)|(appid <AppID>)]
def doDeleteChromePolicy():
  cp = buildGAPIObject(API.CHROMEPOLICY)
  cd = buildGAPIObject(API.DIRECTORY)
  customer = _getCustomersCustomerIdWithC()
  app_id = groupEmail = orgUnit = printer_id = targetResource = None
  body = {'requests': []}
  schemaNameList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'ou', 'org', 'orgunit'}:
      orgUnit, targetName, targetResource, entityType, service = _getPolicyOrgUnitTarget(cd, cp, myarg, groupEmail)
      function = 'batchInherit'
    elif myarg == 'group':
      groupEmail, targetName, targetResource, entityType, service = _getPolicyGroupTarget(cd, cp, myarg, orgUnit)
      function = 'batchDelete'
    elif myarg == 'printerid':
      printer_id = getString(Cmd.OB_PRINTER_ID)
    elif myarg == 'appid':
      app_id = getString(Cmd.OB_APP_ID)
    else:
      schema = _getChromePolicySchema(cp, Cmd.Previous(), 'name')
      schemaName = schema['name'].split('/')[-1]
      schemaNameList.append(schemaName)
      body['requests'].append({'policySchema': schemaName})
      if checkArgumentPresent('json'):
        jsonData = getJSON(['direct', 'name', 'orgUnitPath', 'parentOrgUnitPath', 'group'])
        if 'additionalTargetKeys' in jsonData:
          body['requests'][-1].setdefault('policyTargetKey', {'additionalTargetKeys': {}})
          for atk in jsonData['additionalTargetKeys']:
            body['requests'][-1]['policyTargetKey']['additionalTargetKeys'][atk['name']] = atk['value']
  checkPolicyArgs(targetResource, printer_id, app_id)
  count = len(body['requests'])
  if count != 1:
    entityPerformActionNumItems([entityType, targetName], count, Ent.CHROME_POLICY)
    if count == 0:
      return
  kvList = setPolicyKVList([entityType, targetName, Ent.CHROME_POLICY, ','.join(schemaNameList)], printer_id, app_id)
  updatePolicyRequests(body, targetResource, printer_id, app_id)
  try:
    callGAPI(service, function,
             throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED,
                           GAPI.INVALID_ARGUMENT, GAPI.SERVICE_NOT_AVAILABLE, GAPI.QUOTA_EXCEEDED],
             retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
             customer=customer, body=body)
    entityActionPerformed(kvList)
  except (GAPI.notFound, GAPI.permissionDenied, GAPI.invalidArgument, GAPI.serviceNotAvailable, GAPI.quotaExceeded) as e:
    entityActionFailedWarning(kvList, str(e))

CHROME_SCHEMA_SPECIAL_CASES = {
  'chrome.users.AutoUpdateCheckPeriodNewV2':
    {'autoupdatecheckperiodminutesnew':
       {'casedField': 'autoUpdateCheckPeriodMinutesNew',
        'type': 'duration', 'minVal': 1, 'maxVal': 720}},
  'chrome.users.Avatar':
    {'useravatarimage':
       {'casedField': 'userAvatarImage',
        'type': 'downloadUri'}},
  'chrome.users.BrowserSwitcherDelayDurationV2':
    {'browserswitcherdelayduration':
       {'casedField': 'browserSwitcherDelayDuration',
        'type': 'duration', 'minVal': 0, 'maxVal': 30}},
  'chrome.users.BrowsingDataLifetimeV2':
    {'browsinghistoryttl':
       {'casedField': 'browsingHistoryTtl',
        'type': 'duration', 'minVal': 1, 'maxVal': None},
     'downloadhistoryttl':
       {'casedField': 'downloadHistoryTtl',
        'type': 'duration', 'minVal': 1, 'maxVal': None},
     'cookiesandothersitedatattl':
       {'casedField': 'cookiesAndOtherSiteDataTtl',
        'type': 'duration', 'minVal': 1, 'maxVal': None},
     'cachedimagesandfilesttl':
       {'casedField': 'cachedImagesAndFilesTtl',
        'type': 'duration', 'minVal': 1, 'maxVal': None},
     'passwordsigninttl':
       {'casedField': 'passwordSigninTtl',
        'type': 'duration', 'minVal': 1, 'maxVal': None},
     'autofillttl':
       {'casedField': 'autofillTtl',
        'type': 'duration', 'minVal': 1, 'maxVal': None},
     'sitesettingsttl':
       {'casedField': 'siteSettingsTtl',
        'type': 'duration', 'minVal': 1, 'maxVal': None},
     'hostedappdatattl':
       {'casedField': 'hostedAppDataTtl',
        'type': 'duration', 'minVal': 1, 'maxVal': None}},
  'chrome.users.CloudReportingUploadFrequencyV2':
    {'cloudreportinguploadfrequency':
       {'casedField': 'cloudReportingUploadFrequency',
        'type': 'duration', 'minVal': 3, 'maxVal': 24}},
  'chrome.users.FetchKeepaliveDurationSecondsOnShutdownV2':
    {'fetchkeepalivedurationsecondsonshutdown':
       {'casedField': 'fetchKeepaliveDurationSecondsOnShutdown',
        'type': 'duration', 'minVal': 0, 'maxVal': 5}},
  'chrome.users.MaxInvalidationFetchDelayV2':
    {'maxinvalidationfetchdelay':
       {'casedField': 'maxInvalidationFetchDelay',
        'type': 'duration', 'minVal': 1, 'maxVal': 30, 'default': 10}},
  'chrome.users.PrintingMaxSheetsAllowed':
    {'printingmaxsheetsallowednullable':
       {'casedField': 'printingMaxSheetsAllowedNullable',
        'type': 'value', 'minVal': 1, 'maxVal': None}},
  'chrome.users.PrintJobHistoryExpirationPeriodNewV2':
    {'printjobhistoryexpirationperioddaysnew':
       {'casedField': 'printJobHistoryExpirationPeriodDaysNew',
        'type': 'duration', 'minVal': -1, 'maxVal': None}},
  'chrome.users.RelaunchNotificationWithDurationV2':
    {'relaunchnotificationperiodduration':
       {'casedField': 'relaunchNotificationPeriodDuration',
        'type': 'duration', 'minVal': -1, 'maxVal': None}},
  'chrome.users.SecurityTokenSessionSettingsV2':
    {'securitytokensessionnotificationseconds':
       {'casedField': 'securityTokenSessionNotificationSeconds',
        'type': 'duration', 'minVal': 0, 'maxVal': 9999}},
  'chrome.users.SessionLengthV2':
    {'sessiondurationlimit':
       {'casedField': 'sessionDurationLimit',
        'type': 'duration', 'minVal': 1, 'maxVal': 1440}},
  'chrome.users.UpdatesSuppressed':
    {'updatessuppresseddurationmin':
       {'casedField': 'updatesSuppressedDurationMin',
        'type': 'count', 'minVal': 1, 'maxVal': 1440},
     'updatessuppressedstarttime':
       {'casedField': 'updatesSuppressedStartTime',
        'type': 'timeOfDay'}},
  'chrome.users.Wallpaper':
    {'wallpaperimage':
       {'casedField': 'wallpaperImage',
        'type': 'downloadUri'}},
  'chrome.devices.EnableReportUploadFrequencyV2':
    {'reportdeviceuploadfrequency':
       {'casedField': 'reportDeviceUploadFrequency',
        'type': 'duration', 'minVal': 60, 'maxVal': 25379}},
  'chrome.devices.ScheduledRebootDurationV2':
    {'uptimelimitduration':
       {'casedField': 'uptimeLimitDuration',
        'type': 'duration', 'minVal': 1, 'maxVal': 365}},
  'chrome.devices.SignInWallpaperImage':
    {'devicewallpaperimage':
       {'casedField': 'deviceWallpaperImage',
        'type': 'downloadUri'}},
  'chrome.devices.kiosk.AcPowerSettingsV2':
    {'acidletimeout':
       {'casedField': 'acIdleTimeout',
        'type': 'duration', 'minVal': 1, 'maxVal': 35000},
     'acwarningtimeout':
       {'casedField': 'acWarningTimeout',
        'type': 'duration', 'minVal': 0, 'maxVal': 35000},
     'acdimtimeout':
       {'casedField': 'acDimTimeout',
        'type': 'duration', 'minVal': 0, 'maxVal': 35000},
     'acscreenofftimeout':
       {'casedField': 'acScreenOffTimeout',
        'type': 'duration', 'minVal': 0, 'maxVal': 35000}},
  'chrome.devices.kiosk.BatteryPowerSettingsV2':
    {'batteryidletimeout':
       {'casedField': 'batteryIdleTimeout',
        'type': 'duration', 'minVal': 1, 'maxVal': 35000},
     'batterywarningtimeout':
       {'casedField': 'batteryWarningTimeout',
        'type': 'duration', 'minVal': 0, 'maxVal': 35000},
     'batterydimtimeout':
       {'casedField': 'batteryDimTimeout',
        'type': 'duration', 'minVal': 0, 'maxVal': 35000},
     'batteryscreenofftimeout':
       {'casedField': 'batteryScreenOffTimeout',
        'type': 'duration', 'minVal': 0, 'maxVal': 35000}},
  'chrome.devices.managedguest.Avatar':
    {'useravatarimage':
       {'casedField': 'userAvatarImage',
        'type': 'downloadUri'}},
  'chrome.devices.managedguest.BrowsingDataLifetimeV2':
    {'browsinghistoryttl':
       {'casedField': 'browsingHistoryTtl',
        'type': 'duration', 'minVal': 1, 'maxVal': None},
     'downloadhistoryttl':
       {'casedField': 'downloadHistoryTtl',
        'type': 'duration', 'minVal': 1, 'maxVal': None},
     'cookiesandothersitedatattl':
       {'casedField': 'cookiesAndOtherSiteDataTtl',
        'type': 'duration', 'minVal': 1, 'maxVal': None},
     'cachedimagesandfilesttl':
       {'casedField': 'cachedImagesAndFilesTtl',
        'type': 'duration', 'minVal': 1, 'maxVal': None},
     'passwordsigninttl':
       {'casedField': 'passwordSigninTtl',
        'type': 'duration', 'minVal': 1, 'maxVal': None},
     'autofillttl':
       {'casedField': 'autofillTtl',
        'type': 'duration', 'minVal': 1, 'maxVal': None},
     'sitesettingsttl':
       {'casedField': 'siteSettingsTtl',
        'type': 'duration', 'minVal': 1, 'maxVal': None},
     'hostedappdatattl':
       {'casedField': 'hostedAppDataTtl',
        'type': 'duration', 'minVal': 1, 'maxVal': None}},
  'chrome.devices.managedguest.MaxInvalidationFetchDelayV2':
    {'maxinvalidationfetchdelay':
       {'casedField': 'maxInvalidationFetchDelay',
        'type': 'duration', 'minVal': 1, 'maxVal': 30, 'default': 10}},
  'chrome.devices.managedguest.PrintJobHistoryExpirationPeriodNewV2':
    {'printjobhistoryexpirationperioddaysnew':
       {'casedField': 'printJobHistoryExpirationPeriodDaysNew',
        'type': 'duration', 'minVal': -1, 'maxVal': None}},
  'chrome.devices.managedguest.SecurityTokenSessionSettingsV2':
    {'securitytokensessionnotificationseconds':
       {'casedField': 'securityTokenSessionNotificationSeconds',
        'type': 'duration', 'minVal': 0, 'maxVal': 9999}},
  'chrome.devices.managedguest.SessionLengthV2':
    {'sessiondurationlimit':
       {'casedField': 'sessionDurationLimit',
        'type': 'duration', 'minVal': 1, 'maxVal': 1440}},
  'chrome.devices.managedguest.Wallpaper':
    {'wallpaperimage':
       {'casedField': 'wallpaperImage',
        'type': 'downloadUri'}},
  }

CHROME_TARGET_VERSION_CHANNEL_MINUS_PATTERN = re.compile(r'^([a-z]+)-(\d+)$')
CHROME_TARGET_VERSION_PATTERN = re.compile(r'^(\d{1,4}\.){1,4}$')

# gam update chromepolicy [convertcrnl]
#	(<SchemaName> ((<Field> <Value>)+ | <JSONData>))+
#	((ou|orgunit <OrgUnitItem>)|(group <GroupItem>))
#	[(printerid <PrinterID>)|(appid <AppID>)]
def doUpdateChromePolicy():
  def getSpecialVtypeValue(vtype, value):
    if vtype in {'duration', 'value', 'downloadUri'}:
      return {vtype: value}
    if vtype == 'count':
      return value
    #if vtype == timeOfDay:
    hours, minutes = value.split(':')
    return {vtype: {'hours': hours, 'minutes': minutes}}

  cp = buildGAPIObject(API.CHROMEPOLICY)
  cd = buildGAPIObject(API.DIRECTORY)
  cv = None
  customer = _getCustomersCustomerIdWithC()
  app_id = channelMap = groupEmail = orgUnit = printer_id = targetResource = None
  body = {'requests': []}
  schemaNameList = []
  convertCRsNLs = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'ou', 'org', 'orgunit'}:
      orgUnit, targetName, targetResource, entityType, service = _getPolicyOrgUnitTarget(cd, cp, myarg, groupEmail)
    elif myarg == 'group':
      groupEmail, targetName, targetResource, entityType, service = _getPolicyGroupTarget(cd, cp, myarg, orgUnit)
    elif myarg == 'printerid':
      printer_id = getString(Cmd.OB_PRINTER_ID)
    elif myarg == 'appid':
      app_id = getString(Cmd.OB_APP_ID)
    elif myarg == 'convertcrnl':
      convertCRsNLs = True
    else:
      schemaName, schema = simplifyChromeSchema(_getChromePolicySchema(cp, Cmd.Previous(), '*'))
      body['requests'].append({'policyValue': {'policySchema': schemaName, 'value': {}},
                               'updateMask': ''})
      schemaNameList.append(schemaName)
      while Cmd.ArgumentsRemaining():
        field = getArgumentEmptyAllowed()
        # Allow an empty field/value pair which makes processing an input CSV file with schemas with different numbers of fields easy
        if not field:
          if Cmd.ArgumentsRemaining():
            Cmd.Advance()
          continue
        if field in {'ou', 'org', 'orgunit', 'group', 'printerid', 'appid'} or '.' in field:
          if field != 'appid' or not schemaName.startswith('chrome.devices.kiosk'):
            Cmd.Backup()
            break # field is actually a new policy name or orgunit
        # JSON
        if field == 'json':
          jsonData = getJSON(['direct', 'name', 'orgUnitPath', 'parentOrgUnitPath', 'group'])
          schemaNameAppId = schemaName
          if 'additionalTargetKeys' in jsonData:
            body['requests'][-1].setdefault('policyTargetKey', {'additionalTargetKeys': {}})
            for atk in jsonData['additionalTargetKeys']:
              body['requests'][-1]['policyTargetKey']['additionalTargetKeys'][atk['name']] = atk['value']
              if atk['name'] == 'app_id':
                schemaNameAppId += f"({atk['value']})"
          schemaNameList[-1] = schemaNameAppId
          for field in jsonData.get('fields', []):
            casedField = field['name']
            lowerField = casedField.lower()
            # Handle fields with durations, values, counts and timeOfDay as special cases
            tmschema = CHROME_SCHEMA_SPECIAL_CASES.get(schemaName, {}).get(lowerField)
            if tmschema:
              body['requests'][-1]['policyValue']['value'][casedField] = getSpecialVtypeValue(tmschema['type'], field['value'])
              body['requests'][-1]['updateMask'] += f'{casedField},'
              continue
            vtype = schema['settings'].get(lowerField, {}).get('type')
            value = field['value']
            if vtype in ['TYPE_INT64', 'TYPE_INT32', 'TYPE_UINT64']:
              value = int(value)
            elif vtype == 'TYPE_BOOL':
              pass
            elif vtype == 'TYPE_ENUM':
              prefix = schema['settings'][lowerField]['enum_prefix']
              if not value.startswith(prefix):
                value = f"{prefix}{value}"
            elif vtype == 'TYPE_LIST':
              value = value.split(',') if value else []
            if myarg == 'chrome.users.chromebrowserupdates' and casedField == 'targetVersionPrefixSetting':
              mg = CHROME_TARGET_VERSION_CHANNEL_MINUS_PATTERN.match(value)
              if mg:
                channel = mg.group(1).lower().replace('_', '')
                if channelMap is None:
                  cv, channelMap = getPlatformChannelMap(cv, Ent.CHROME_CHANNEL)
                if channel not in channelMap:
                  invalidChoiceExit(value, channelMap, True)
                cv, status, milestone = getRelativeMilestone(cv, channelMap[channel], int(mg.group(2)))
                if not status:
                  Cmd.Backup()
                  invalidArgumentExit(f'{milestone} for {casedField}: {value}')
                value =  f'{milestone}.'
              elif value and not CHROME_TARGET_VERSION_PATTERN.match(value):
                Cmd.Backup()
                invalidArgumentExit(f'{Msg.CHROME_TARGET_VERSION_FORMAT} for {casedField}: {value}')
            body['requests'][-1]['policyValue']['value'][casedField] = value
            body['requests'][-1]['updateMask'] += f'{casedField},'
          break
        # Handle fields with durations, values, counts and timeOfDay as special cases
        tmschema = CHROME_SCHEMA_SPECIAL_CASES.get(schemaName, {}).get(field)
        if tmschema:
          casedField = tmschema['casedField']
          vtype = tmschema['type']
          if vtype == 'downloadUri':
            value = getString(Cmd.OB_STRING)
          elif vtype != 'timeOfDay':
            if 'default' not in tmschema:
              value = getInteger(minVal=tmschema['minVal'], maxVal=tmschema['maxVal'])
            else:
              value = getIntegerEmptyAllowed(minVal=tmschema['minVal'], maxVal=tmschema['maxVal'], default=tmschema['default'])
          else:
            value = getHHMM()
          body['requests'][-1]['policyValue']['value'][casedField] = getSpecialVtypeValue(vtype, value)
          body['requests'][-1]['updateMask'] += f'{casedField},'
          continue
        if field not in schema['settings']:
          Cmd.Backup()
          missingChoiceExit(schema['settings'])
        casedField = schema['settings'][field]['name']
        vtype = schema['settings'][field]['type']
        value = getString(Cmd.OB_STRING, minLen=0 if vtype in {'TYPE_STRING', 'TYPE_LIST'}  else 1)
        if vtype in ['TYPE_INT64', 'TYPE_INT32', 'TYPE_UINT64']:
          if not value.isnumeric():
            Cmd.Backup()
            invalidArgumentExit(integerLimits(None, None))
          value = int(value)
        elif vtype == 'TYPE_BOOL':
          value = value.lower()
          if value in TRUE_VALUES:
            value = True
          elif value in FALSE_VALUES:
            value = False
          else:
            invalidChoiceExit(value, TRUE_FALSE, True)
        elif vtype == 'TYPE_ENUM':
          value = value.upper()
          prefix = schema['settings'][field]['enum_prefix']
          enum_values = schema['settings'][field]['enums']
          if value in enum_values:
            value = f'{prefix}{value}'
          elif value.replace(prefix, '') in enum_values:
            pass
          else:
            invalidChoiceExit(value, enum_values, True)
        elif vtype == 'TYPE_LIST':
          value = value.split(',') if value else []
        elif vtype == 'TYPE_STRING' and convertCRsNLs:
          value = unescapeCRsNLs(value)
        if myarg == 'chrome.users.chromebrowserupdates' and casedField == 'targetVersionPrefixSetting':
          mg = CHROME_TARGET_VERSION_CHANNEL_MINUS_PATTERN.match(value)
          if mg:
            channel = mg.group(1).lower().replace('_', '')
            if channelMap is None:
              cv, channelMap = getPlatformChannelMap(cv, Ent.CHROME_CHANNEL)
            if channel not in channelMap:
              invalidChoiceExit(value, channelMap, True)
            cv, status, milestone = getRelativeMilestone(cv, channelMap[channel], int(mg.group(2)))
            if not status:
              Cmd.Backup()
              invalidArgumentExit(f'{milestone} for {casedField}: {value}')
            value = f'{milestone}.'
          elif value and not CHROME_TARGET_VERSION_PATTERN.match(value):
            Cmd.Backup()
            invalidArgumentExit(Msg.CHROME_TARGET_VERSION_FORMAT)
        body['requests'][-1]['policyValue']['value'][casedField] = value
        body['requests'][-1]['updateMask'] += f'{casedField},'
  checkPolicyArgs(targetResource, printer_id, app_id)
  count = len(body['requests'])
  if count > 0 and not body['requests'][-1]['updateMask']:
    body['requests'].pop()
  kvList = setPolicyKVList([entityType, targetName, Ent.CHROME_POLICY, ','.join(schemaNameList)], printer_id, app_id)
  if count != 1:
    entityPerformActionNumItems(kvList, count, Ent.CHROME_POLICY)
    if count == 0:
      return
  updatePolicyRequests(body, targetResource, printer_id, app_id)
  try:
    callGAPI(service, 'batchModify',
             throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.INVALID_ARGUMENT,
                           GAPI.SERVICE_NOT_AVAILABLE, GAPI.QUOTA_EXCEEDED],
             retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
             customer=customer, body=body)
    entityActionPerformed(kvList)
  except (GAPI.notFound, GAPI.permissionDenied, GAPI.invalidArgument, GAPI.serviceNotAvailable, GAPI.quotaExceeded) as e:
    entityActionFailedWarning(kvList, str(e))

CHROME_POLICY_INDEXED_TITLES = ['fields']

CHROME_POLICY_SHOW_ALL = -1
CHROME_POLICY_SHOW_DIRECT = True
CHROME_POLICY_SHOW_INHERITED = False
CHROME_POLICY_SHOW_CHOICE_MAP = {
  'all': CHROME_POLICY_SHOW_ALL,
  'direct': CHROME_POLICY_SHOW_DIRECT,
  'inherited': CHROME_POLICY_SHOW_INHERITED
  }

# gam show chromepolicies
#	((ou|orgunit <OrgUnitItem>)|(group <GroupItem>))
#	[(printerid <PrinterID>)|(appid <AppID>)]
#	(filter <StringList>)* (namespace <NamespaceList>)*
#	[show all|direct|inherited]
#	[formatjson]
# gam print chromepolicies [todrive <ToDriveAttribute>*]
#	((ou|orgunit <OrgUnitItem>)|(group <GroupItem>))
#	[(printerid <PrinterID>)|(appid <AppID>)]
#	(filter <StringList>)* (namespace <NamespaceList>)*
#	[show all|direct|inherited]
#	[[formatjson [quotechar <Character>]]
def doPrintShowChromePolicies():
  def normalizedPolicy(policy):
    norm = {'name': policy['value']['policySchema']}
    if app_id:
      norm['appId'] = app_id
    elif printer_id:
      norm['printerId'] = printer_id
    if entityType == Ent.ORGANIZATIONAL_UNIT:
      orgUnitId = policy.get('targetKey', {}).get('targetResource')
      norm['orgUnitPath'] = convertOrgUnitIDtoPath(cd, orgUnitId) if orgUnitId else UNKNOWN
      parentOrgUnitId = policy.get('sourceKey', {}).get('targetResource')
      norm['parentOrgUnitPath'] = convertOrgUnitIDtoPath(cd, parentOrgUnitId) if parentOrgUnitId else UNKNOWN
      norm['direct'] = orgUnitId == parentOrgUnitId
    else:
      groupId = policy.get('targetKey', {}).get('targetResource')
      if groupId is not None:
        groupId = groupId.split('/')[1]
        norm['group'] = getGroupEmailFromID(groupId, cd)
        if norm['group'] is None:
          norm['group'] = groupId
      else:
        norm['group'] = UNKNOWN
    norm['additionalTargetKeys'] = []
    for setting, value in sorted(policy.get('targetKey', {}).get('additionalTargetKeys', {}).items()):
      norm['additionalTargetKeys'].append({'name': setting, 'value': value})
    norm['fields'] = []
    name = policy['value']['policySchema']
    values = policy.get('value', {}).get('value', {})
    if name in {'chrome.devices.managedguest.apps.ManagedConfiguration',
                'chrome.devices.kiosk.apps.ManagedConfiguration',
                'chrome.users.apps.ManagedConfiguration'} and 'managedConfiguration' in values:
      values['managedConfiguration'] = json.dumps(values['managedConfiguration'], ensure_ascii=False).replace('\\n', '').replace('\\"', '"')[1:-1]
    for setting, value in values.items():
      # Handle fields with durations, values, counts and timeOfDay as special cases
      schema = CHROME_SCHEMA_SPECIAL_CASES.get(name, {}).get(setting.lower())
      if schema and setting == schema['casedField']:
        vtype = schema['type']
        if vtype in {'duration', 'value', 'downloadUri'}:
          value = value.get(vtype, '')
        elif vtype == 'count':
          pass
        else: #timeOfDay
          hours = value.get(vtype, {}).get('hours', 0)
          minutes = value.get(vtype, {}).get('minutes', 0)
          value = f'{hours:02}:{minutes:02}'
      elif isinstance(value, str) and value.find('_ENUM_') != -1:
        value = value.split('_ENUM_')[-1]
      elif isinstance(value, list):
        if len(value) > 0:
          if not isinstance(value[0], dict):
            value = ','.join(value)
          else:
            value = json.dumps(value, ensure_ascii=False)
        else:
          value = ''
      norm['fields'].append({'name': setting, 'value': value})
    return norm

  def _showPolicy(policy, j, jcount):
    policy = normalizedPolicy(policy)
    if (entityType == Ent.GROUP) or showPolicies in (CHROME_POLICY_SHOW_ALL, policy['direct']):
      if FJQC.formatJSON:
        printLine(json.dumps(cleanJSON(policy), ensure_ascii=False, sort_keys=True))
        return
      printKeyValueListWithCount([policy['name']], j, jcount)
      Ind.Increment()
      showJSON(None, policy, sortDictKeys=False)
      Ind.Decrement()

  def _printPolicy(policy):
    policy = normalizedPolicy(policy)
    if (entityType == Ent.GROUP) or showPolicies in (CHROME_POLICY_SHOW_ALL, policy['direct']):
      row = flattenJSON(policy)
      if not FJQC.formatJSON:
        csvPF.WriteRowTitles(row)
      elif (not csvPF.rowFilter and not csvPF.rowDropFilter) or csvPF.CheckRowTitles(row):
        if entityType == Ent.ORGANIZATIONAL_UNIT:
          csvPF.WriteRowNoFilter({'name': policy['name'],
                                  'orgUnitPath': policy['orgUnitPath'],
                                  'parentOrgUnitPath': policy['parentOrgUnitPath'],
                                  'direct': policy['direct'],
                                  'JSON': json.dumps(cleanJSON(policy),
                                                     ensure_ascii=False, sort_keys=True)})
        else:
          csvPF.WriteRowNoFilter({'name': policy['name'],
                                  'group': policy['group'],
                                  'JSON': json.dumps(cleanJSON(policy),
                                                     ensure_ascii=False, sort_keys=True)})

  cp = buildGAPIObject(API.CHROMEPOLICY)
  cd = buildGAPIObject(API.DIRECTORY)
  customer = _getCustomersCustomerIdWithC()
  csvPF = CSVPrintFile(['name'], indexedTitles=CHROME_POLICY_INDEXED_TITLES) if Act.csvFormat() else None
  if csvPF:
    csvPF.SetNoEscapeChar(True)
  FJQC = FormatJSONQuoteChar(csvPF)
  app_id = groupEmail = orgUnit = printer_id = targetResource = None
  showPolicies = CHROME_POLICY_SHOW_ALL
  psFilters = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'ou', 'org', 'orgunit'}:
      orgUnit, targetName, targetResource, entityType, _ = _getPolicyOrgUnitTarget(cd, cp, myarg, groupEmail)
    elif myarg == 'group':
      groupEmail, targetName, targetResource, entityType, _ = _getPolicyGroupTarget(cd, cp, myarg, orgUnit)
    elif myarg == 'printerid':
      printer_id = getString(Cmd.OB_PRINTER_ID)
    elif myarg == 'appid':
      app_id = getString(Cmd.OB_APP_ID)
    elif myarg == 'filter':
      for psFilter in getString(Cmd.OB_STRING).replace(',', ' ').split():
        psFilters.append(psFilter)
    elif myarg == 'namespace':
      for psFilter in getString(Cmd.OB_STRING).replace(',', ' ').split():
        if psFilter.endswith('.*'):
          psFilters.append(psFilter)
        else:
          psFilters.append(f'{psFilter}.*')
    elif myarg == 'show':
      showPolicies = getChoice(CHROME_POLICY_SHOW_CHOICE_MAP, mapChoice=True)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  checkPolicyArgs(targetResource, printer_id, app_id)
  body = {'policyTargetKey': {'targetResource': targetResource}}
  if app_id:
    body['policyTargetKey']['additionalTargetKeys'] = {'app_id': app_id}
    if not psFilters:
      psFilters = ['chrome.users.apps.*',
                   'chrome.devices.kiosk.apps.*',
                   'chrome.devices.managedguest.apps.*',
                    ]
  elif printer_id:
    body['policyTargetKey']['additionalTargetKeys'] = {'printer_id': printer_id}
    if not psFilters:
      psFilters = ['chrome.printers.*']
  elif not psFilters:
    if entityType == Ent.ORGANIZATIONAL_UNIT:
      psFilters = ['chrome.users.*',
                   'chrome.users.apps.*',
                   'chrome.users.appsconfig.*',
                   'chrome.devices.*',
                   'chrome.devices.kiosk.*',
                   'chrome.devices.kiosk.apps.*',
                   'chrome.devices.managedguest.*',
                   'chrome.devices.managedguest.apps.*',
                   'chrome.networks.cellular.*',
                   'chrome.networks.certificates.*',
                   'chrome.networks.ethernet.*',
                   'chrome.networks.globalsettings.*',
                   'chrome.networks.vpn.*',
                   'chrome.networks.wifi.*',
                   'chrome.printers.*',
                   'chrome.printservers.*',
                   ]
    else:
      psFilters = ['chrome.users.*',
                   'chrome.users.apps.*',
                   'chrome.users.appsconfig.*',
                   'chrome.printers.*',
                   'chrome.printservers.*',
                   ]
  if csvPF:
    if entityType == Ent.ORGANIZATIONAL_UNIT:
      csvPF.AddTitles(['orgUnitPath', 'parentOrgUnitPath', 'direct'])
    else:
      csvPF.AddTitles(['group'])
    csvPF.SetSortAllTitles()
    if not FJQC.formatJSON:
      if app_id:
        csvPF.AddSortTitles(['appId'])
      elif printer_id:
        csvPF.AddSortTitles(['printerId'])
    else:
      csvPF.SetJSONTitles(csvPF.titlesList+['JSON'])
  policies = []
  for psFilter in psFilters:
    body['policySchemaFilter'] = psFilter
    body['pageToken'] = None
    printGettingAllEntityItemsForWhom(Ent.CHROME_POLICY, targetName, query=body['policySchemaFilter'])
    try:
      policies.extend(callGAPIpages(cp.customers().policies(), 'resolve', 'resolvedPolicies',
                                    pageMessage=getPageMessageForWhom(),
                                    throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.INVALID_ARGUMENT,
                                                  GAPI.SERVICE_NOT_AVAILABLE, GAPI.QUOTA_EXCEEDED],
                                    customer=customer, body=body, pageArgsInBody=True))
    except (GAPI.notFound, GAPI.permissionDenied, GAPI.quotaExceeded) as e:
      entityActionFailedWarning([entityType, targetName], str(e))
      continue
    except (GAPI.invalidArgument, GAPI.serviceNotAvailable) as e:
      entityActionFailedWarning([Ent.CHROME_POLICY, body['policySchemaFilter']], str(e))
      continue
    # sort policies first by app/printer id then by schema name
    policies = sorted(policies,
                      key=lambda k: (list(k.get('targetKey', {}).get('additionalTargetKeys', {}).values()),
                                     k.get('value', {}).get('policySchema', '')))
  if not csvPF:
    jcount = len(policies)
    if not FJQC.formatJSON:
      kvList = setPolicyKVList([entityType, targetName], printer_id, app_id)
      entityPerformActionModifierNumItems(kvList, Msg.MAXIMUM_OF, jcount, Ent.CHROME_POLICY)
    Ind.Increment()
    j = 0
    for policy in policies:
      j += 1
      _showPolicy(policy, j, jcount)
    Ind.Decrement()
  else:
    for policy in policies:
      _printPolicy(policy)
  if csvPF:
    csvPF.writeCSVfile(f'Chrome Policies - {targetName}')

CHROME_IMAGE_SCHEMAS_MAP = {
  'chrome.devices.managedguest.avatar': {'name': 'chrome.devices.managedguest.Avatar', 'field': 'userAvatarImage'},
  'chrome.devices.managedguest.wallpaper': {'name': 'chrome.devices.managedguest.Wallpaper', 'field': 'wallpaperImage'},
  'chrome.devices.signinwallpaperimage': {'name': 'chrome.devices.SignInWallpaperImage', 'field': 'deviceWallpaperImage'},
  'chrome.users.avatar': {'name': 'chrome.users.Avatar', 'field': 'userAvatarImage'},
  'chrome.users.wallpaper': {'name': 'chrome.users.Wallpaper', 'field': 'wallpaperImage'},
  }

# gam create chromepolicyimage <ChromePolicyImageSchemaName> <FileName>
def doCreateChromePolicyImage():
  cp = buildGAPIObject(API.CHROMEPOLICY)
  parent = _getCustomersCustomerIdWithC()
  schema = getChoice(CHROME_IMAGE_SCHEMAS_MAP, mapChoice=True)
  parameters = {DFA_URL: None}
  parameters[DFA_LOCALFILEPATH] = os.path.expanduser(getString(Cmd.OB_FILE_NAME))
  try:
    f = open(parameters[DFA_LOCALFILEPATH], 'rb')
    f.close()
  except IOError as e:
    Cmd.Backup()
    usageErrorExit(f'{parameters[DFA_LOCALFILEPATH]}: {str(e)}')
  parameters[DFA_LOCALFILENAME] = os.path.basename(parameters[DFA_LOCALFILEPATH])
  parameters[DFA_LOCALMIMETYPE] = mimetypes.guess_type(parameters[DFA_LOCALFILEPATH])[0]
  if parameters[DFA_LOCALMIMETYPE] is None:
    parameters[DFA_LOCALMIMETYPE] = 'image/jpeg'
  checkForExtraneousArguments()
  media_body = getMediaBody(parameters)
  try:
    result = callGAPI(cp.media(), 'upload',
                      throwReasons=[GAPI.INVALID_ARGUMENT, GAPI.FORBIDDEN],
                      customer=parent, media_body=media_body,
                      body={'policyField': f"{schema['name']}.{schema['field']}"})
    entityActionPerformed([Ent.CHROME_POLICY_IMAGE, f"{schema['name']} {schema['field']} {result['downloadUri']}"])
  except (GAPI.invalidArgument, GAPI.forbidden) as e:
    entityActionFailedWarning([Ent.CHROME_POLICY_IMAGE, f"{schema['name']}", Ent.FILE, f"{parameters[DFA_LOCALFILEPATH]}"], str(e))

def _showChromePolicySchema(schema, FJQC, i=0, count=0):
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(schema), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.CHROME_POLICY_SCHEMA, schema['name']], i, count)
  Ind.Increment()
  showJSON(None, schema)
  Ind.Decrement()

CHROME_POLICY_SCHEMA_FIELDS_CHOICE_MAP = {
  'accessrestrictions': 'accessRestrictions',
  'additionaltargetkeynames': 'additionalTargetKeyNames',
  'categorytitle': 'categoryTitle',
  'definition': 'definition',
  'fielddescriptions': 'fieldDescriptions',
  'name': 'name',
  'notices': 'notices',
  'policyapilifecycle': 'policyApiLifecycle',
  'policydescription': 'policyDescription',
  'schemaname': 'schemaName',
  'supporturi': 'supportUri',
  'validtargetresources': 'validTargetResources',
  }

# gam info chromeschema <SchemaName>
#	<ChromePolicySchemaFieldName>* [fields <ChromePolicySchemaFieldNameList>]
#	[formatjson]
def doInfoChromePolicySchemas():
  cp = buildGAPIObject(API.CHROMEPOLICY)
  FJQC = FormatJSONQuoteChar()
  fieldsList = []
  name = _getChromePolicySchemaName()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if getFieldsList(myarg, CHROME_POLICY_SCHEMA_FIELDS_CHOICE_MAP, fieldsList, initialField='name'):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  fields = getFieldsFromFieldsList(fieldsList)
  try:
    schema = callGAPI(cp.customers().policySchemas(), 'get',
                      throwReasons=[GAPI.NOT_FOUND, GAPI.BAD_REQUEST, GAPI.FORBIDDEN],
                      name=name, fields=fields)
    _showChromePolicySchema(schema, FJQC, 0, 0)
  except GAPI.notFound:
    entityUnknownWarning(Ent.CHROME_POLICY_SCHEMA, name)
  except (GAPI.badRequest, GAPI.forbidden):
    accessErrorExit(None)

# gam show chromeschemas
#	[filter <String>]
#	<ChromePolicySchemaFieldName>* [fields <ChromePolicySchemaFieldNameList>]
#	[formatjson]
# gam print chromschemas [todrive <ToDriveAttribute>*]
#	[filter <String>]
#	<ChromePolicySchemaFieldName>* [fields <ChromePolicySchemaFieldNameList>]
#	[[formatjson [quotechar <Character>]]
def doPrintShowChromeSchemas():
  def _printChromePolicySchema(schema):
    row = flattenJSON(schema)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif (not csvPF.rowFilter and not csvPF.rowDropFilter) or csvPF.CheckRowTitles(row):
      row = {'name': schema['name']}
      if 'policyDescription' in schema:
        row['policyDescription'] = schema['policyDescription']
      if 'policyApiLifecycle' in schema:
        row['policyApiLifecycleStage'] = schema['policyApiLifecycle'].get('policyApiLifecycleStage', '')
      row['JSON'] = json.dumps(cleanJSON(schema), ensure_ascii=False, sort_keys=True)
      csvPF.WriteRowNoFilter(row)

  if checkArgumentPresent('std'):
    doShowChromeSchemasStd()
    return
  cp = buildGAPIObject(API.CHROMEPOLICY)
  parent = _getCustomersCustomerIdWithC()
  csvPF = CSVPrintFile(['name', 'schemaName', 'policyDescription',
                        'policyApiLifecycle.policyApiLifecycleStage',
                        'policyApiLifecycle.description',
                        'policyApiLifecycle.endSupport.year',
                        'policyApiLifecycle.endSupport.month',
                        'policyApiLifecycle.endSupport.day',
                        'policyApiLifecycle.deprecatedInFavorOf',
                        'policyApiLifecycle.deprecatedInFavorOf.0'],
                        'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  fieldsList = []
  pfilter = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'filter':
      pfilter = getString(Cmd.OB_STRING)
    elif getFieldsList(myarg, CHROME_POLICY_SCHEMA_FIELDS_CHOICE_MAP, fieldsList, initialField='name'):
      pass
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if csvPF and FJQC.formatJSON:
    jsonTitles = ['name']
    if not fieldsList or 'policyDescription' in fieldsList:
      jsonTitles.append('policyDescription')
    if not fieldsList or 'policyApiLifecycle' in fieldsList:
      jsonTitles.append('policyApiLifecycleStage')
    jsonTitles.append('JSON')
    csvPF.SetJSONTitles(jsonTitles)
  fields = getItemFieldsFromFieldsList('policySchemas', fieldsList)
  printGettingAllAccountEntities(Ent.CHROME_POLICY_SCHEMA, pfilter)
  pageMessage = getPageMessage()
  try:
    schemas = callGAPIpages(cp.customers().policySchemas(), 'list', 'policySchemas',
                            pageMessage=pageMessage,
                            throwReasons=[GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.FORBIDDEN],
                            parent=parent, filter=pfilter, fields=fields)
    if not csvPF:
      jcount = len(schemas)
      if not FJQC.formatJSON:
        performActionNumItems(jcount, Ent.CHROME_POLICY_SCHEMA)
      Ind.Increment()
      j = 0
      for schema in schemas:
        j += 1
        _showChromePolicySchema(schema, FJQC, j, jcount)
      Ind.Decrement()
    else:
      for schema in schemas:
        _printChromePolicySchema(schema)
  except GAPI.invalidInput as e:
    entityActionFailedWarning([Ent.CHROME_POLICY, None], invalidQuery(pfilter) if pfilter else str(e))
  except (GAPI.badRequest, GAPI.forbidden):
    accessErrorExit(None)
  if csvPF:
    csvPF.writeCSVfile('Chrome Policy Schemas')

# gam show chromeschemas std [filter <String>]
def doShowChromeSchemasStd():
  cp = buildGAPIObject(API.CHROMEPOLICY)
  sfilter = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'filter':
      sfilter = getString(Cmd.OB_STRING)
    else:
      unknownArgumentExit()
  parent = _getCustomersCustomerIdWithC()
  result = callGAPIpages(cp.customers().policySchemas(), 'list', 'policySchemas',
                         retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                         parent=parent, filter=sfilter)
  schemas = {}
  for schema in result:
    schema_name, schema_dict = simplifyChromeSchema(schema)
    schemas[schema_name.lower()] = schema_dict
  for _, value in sorted(iter(schemas.items())):
    printKeyValueList([f'{value.get("name")}', f'{value.get("description")}'])
    Ind.Increment()
    for val in value['settings'].values():
      vtype = val.get('type')
      printKeyValueList([f'{val.get("name")}', f'{vtype}'])
      Ind.Increment()
      if vtype == 'TYPE_ENUM':
        enums = val.get('enums', [])
        descriptions = val.get('descriptions', [])
        for i in range(len(val.get('enums', []))):
          printKeyValueList([f'{enums[i]}', f'{descriptions[i]}'])
      elif vtype == 'TYPE_BOOL':
        pvs = val.get('descriptions')
        for pvi in pvs:
          if isinstance(pvi, dict):
            pvalue = pvi.get('value')
            pdescription = pvi.get('description')
            printKeyValueList([f'{pvalue}', f'{pdescription}'])
          elif isinstance(pvi, list):
            printKeyValueList([f'{pvi[0]}'])
      else:
        description = val.get('descriptions')
        if len(description) > 0:
          printKeyValueList([f'{description[0]}'])
      Ind.Decrement()
    Ind.Decrement()
    printBlankLine()

# gam create chromenetwork
#	<OrgUnitItem> <String> <JSONData>
def doCreateChromeNetwork():
  cp = buildGAPIObject(API.CHROMEPOLICY)
  cd = buildGAPIObject(API.DIRECTORY)
  customer = _getCustomersCustomerIdWithC()
  body = {}
  orgUnitPath, body['targetResource'] = _getOrgunitsOrgUnitIdPath(cd, getString(Cmd.OB_ORGUNIT_PATH))
  body['name'] = getString(Cmd.OB_STRING)
  body.update(getJSON(['direct', 'name', 'orgUnitPath', 'parentOrgUnitPath', 'group']))
  checkForExtraneousArguments()
  kvList = [Ent.ORGANIZATIONAL_UNIT, orgUnitPath, Ent.CHROME_NETWORK_NAME, body['name']]
  try:
    result = callGAPI(cp.customers().policies().networks(), 'defineNetwork',
                      bailOnInternalError=True,
                      throwReasons=[GAPI.ALREADY_EXISTS, GAPI.PERMISSION_DENIED, GAPI.INVALID_ARGUMENT,
                                    GAPI.INTERNAL_ERROR, GAPI.SERVICE_NOT_AVAILABLE],
                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                      customer=customer, body=body)
    entityActionPerformed(kvList+[Ent.CHROME_NETWORK_ID, result['networkId']])
  except (GAPI.alreadyExists, GAPI.permissionDenied, GAPI.invalidArgument,
          GAPI.internalError, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning(kvList, str(e))

# gam delete chromenetwork
#	 <OrgUnitItem> <NetworkID>
def doDeleteChromeNetwork():
  cp = buildGAPIObject(API.CHROMEPOLICY)
  cd = buildGAPIObject(API.DIRECTORY)
  customer = _getCustomersCustomerIdWithC()
  body = {}
  orgUnitPath, body['targetResource'] = _getOrgunitsOrgUnitIdPath(cd, getString(Cmd.OB_ORGUNIT_PATH))
  body['networkId'] = getString(Cmd.OB_NETWORK_ID)
  checkForExtraneousArguments()
  kvList = [Ent.ORGANIZATIONAL_UNIT, orgUnitPath, Ent.CHROME_NETWORK_ID, body['networkId']]
  try:
    callGAPI(cp.customers().policies().networks(), 'removeNetwork',
             throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED,
                           GAPI.INVALID_ARGUMENT, GAPI.SERVICE_NOT_AVAILABLE],
             retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
             customer=customer, body=body)
    entityActionPerformed(kvList)
  except (GAPI.notFound, GAPI.permissionDenied, GAPI.invalidArgument, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning(kvList, str(e))

# Device command utilities
def buildGAPICIDeviceServiceObject():
  if not GC.Values[GC.ENABLE_DASA]:
    _, ci = buildGAPIServiceObject(API.CLOUDIDENTITY_DEVICES, _getAdminEmail(), displayError=True)
  else:
    ci = buildGAPIObject(API.CLOUDIDENTITY_DEVICES)
  if not ci:
    sys.exit(GM.Globals[GM.SYSEXITRC])
  return ci

def getUpdateDeleteCIDeviceOptions(entityType, count, action, doit, actionChoices):
  kwargs = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if not action and myarg == 'action':
      action = getChoice(actionChoices)
    elif action == 'wipe' and myarg == 'removeresetlock':
      kwargs = {'body': {'removeResetLock': True}}
    elif myarg == 'doit':
      doit = True
    else:
      unknownArgumentExit()
  if not action:
    actionNotPerformedNumItemsWarning(count, entityType, Msg.NO_ACTION_SPECIFIED)
    sys.exit(GM.Globals[GM.SYSEXITRC])
  if not doit:
    actionNotPerformedNumItemsWarning(count, entityType, Msg.USE_DOIT_ARGUMENT_TO_PERFORM_ACTION)
    sys.exit(GM.Globals[GM.SYSEXITRC])
  return action, kwargs

def getCIDeviceEntity():
  ci = buildGAPICIDeviceServiceObject()
  customer = _getCustomersCustomerIdNoC()
  if checkArgumentPresent('devicesn'):
    query = f'serial:{getString(Cmd.OB_SERIAL_NUMBER)}'
  elif checkArgumentPresent('query'):
    query = getString(Cmd.OB_QUERY)
  else:
    name = getString(Cmd.OB_DEVICE_ENTITY)
    if name[:6].lower() == 'query:':
      query = name[6:]
    else:
      if name.lower() in {'id', 'name'}:
        name = getString(Cmd.OB_DEVICE_ID)
      if not name.startswith('devices/'):
        name = f'devices/{name}'
      return ([{'name': name}], ci, customer, True)
  printGettingAllAccountEntities(Ent.DEVICE, query)
  pageMessage = getPageMessage()
  try:
    devices = callGAPIpages(ci.devices(), 'list', 'devices',
                            throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                            pageMessage=pageMessage,
                            customer=customer, filter=query,
                            fields='nextPageToken,devices(name)', pageSize=100)
    return (devices, ci, customer, False)
  except (GAPI.invalid, GAPI.invalidArgument):
    Cmd.Backup()
    usageErrorExit(Msg.INVALID_QUERY)
  except GAPI.permissionDenied as e:
    entityActionFailedWarning([Ent.DEVICE, None], str(e))
    return ([], ci, customer, False)

DEVICE_USERNAME_PATTERN = re.compile(r'^(devices/.+)/(deviceUsers/.+)$')
DEVICE_USERNAME_FORMAT_REQUIRED = 'devices/<String>/deviceUsers/<String>'
def getCIDeviceUserEntity():
  ci = buildGAPICIDeviceServiceObject()
  customer = _getCustomersCustomerIdNoC()
  if checkArgumentPresent('query'):
    query = getString(Cmd.OB_QUERY)
  else:
    name = getString(Cmd.OB_DEVICE_USER_ENTITY)
    if name[:6].lower() == 'query:':
      query = name[6:]
    else:
      if name.lower() in {'id', 'name'}:
        name = getString(Cmd.OB_DEVICE_USER_ID)
      if DEVICE_USERNAME_PATTERN.match(name):
        return ([{'name': name}], ci, customer, True)
      Cmd.Backup()
      invalidArgumentExit(DEVICE_USERNAME_FORMAT_REQUIRED)
  printGettingAllAccountEntities(Ent.DEVICE_USER, query)
  pageMessage = getPageMessage()
  try:
    deviceUsers = callGAPIpages(ci.devices().deviceUsers(), 'list', 'deviceUsers',
                                throwReasons=[GAPI.INVALID, GAPI.PERMISSION_DENIED],
                                retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                pageMessage=pageMessage,
                                customer=customer, filter=query, parent='devices/-',
                                fields='nextPageToken,deviceUsers(name)', pageSize=20)
    return (deviceUsers, ci, customer, False)
  except GAPI.invalid:
    Cmd.Backup()
    usageErrorExit(Msg.INVALID_QUERY)
  except GAPI.permissionDenied as e:
    entityActionFailedWarning([Ent.DEVICE_USER, None], str(e))
    return ([], ci, customer, False)

def _makeDeviceId(name, device):
  deviceId = f'{name}'
  for field in ['deviceType', 'serialNumber', 'assetTag']:
    if field in device:
      deviceId += f', {field}: {device[field]}'
  return deviceId

DEVICE_TYPE_MAP = {
  'android': 'ANDROID',
  'chromeos': 'CHROME_OS',
  'googlesync': 'GOOGLE_SYNC',
  'ios': 'IOS',
  'linux': 'LINUX',
  'macos': 'MAC_OS',
  'windows': 'WINDOWS'
  }

DEVICE_TIME_OBJECTS = {'createTime', 'firstSyncTime', 'lastSyncTime', 'lastUpdateTime', 'securityPatchTime'}

# gam create device serialnumber <String> devicetype <DeviceType> [assettag <String>]
def doCreateCIDevice():
  ci = buildGAPICIDeviceServiceObject()
  customer = _getCustomersCustomerIdNoC()
  body = {'deviceType': '', 'serialNumber': ''}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'serialnumber':
      body['serialNumber'] = getString(Cmd.OB_STRING)
    elif myarg == 'devicetype':
      body['deviceType'] = getChoice(DEVICE_TYPE_MAP, mapChoice=True)
    elif myarg in {'assettag', 'assteid'}:
      body['assetTag'] = getString(Cmd.OB_STRING)
    else:
      unknownArgumentExit()
  if not body['serialNumber']:
    missingArgumentExit('serialnumber')
  if not body['deviceType']:
    missingArgumentExit('devicetype')
  try:
    result = callGAPI(ci.devices(), 'create',
                      throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.ALREADY_EXISTS],
                      customer=customer, body=body)
    if 'response' in result:
      entityActionPerformed([Ent.COMPANY_DEVICE, _makeDeviceId(f'{result["response"]["name"]}', body)])
    else:
      entityActionFailedWarning([Ent.COMPANY_DEVICE, _makeDeviceId('/devices/???', body)], result['error']['message'])
  except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied, GAPI.alreadyExists) as e:
    entityActionFailedWarning([Ent.COMPANY_DEVICE, _makeDeviceId('/devices/???', body)], str(e))

DEVICE_ACTION_CHOICES = {'cancelwipe', 'wipe'}

def _performCIDeviceAction(action):
  entityList, ci, customer, doit = getCIDeviceEntity()
  count = len(entityList)
  action, kwargs = getUpdateDeleteCIDeviceOptions(Ent.DEVICE, count, action, doit, DEVICE_ACTION_CHOICES)
  if action == 'delete':
    kwargs['customer'] = customer
  else:
    kwargs.setdefault('body', {})
    kwargs['body']['customer'] = customer
  i = 0
  for device in entityList:
    i += 1
    name = device['name']
    try:
      result = callGAPI(ci.devices(), action,
                        bailOnInternalError=True,
                        throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.INVALID_ARGUMENT,
                                      GAPI.FAILED_PRECONDITION, GAPI.PERMISSION_DENIED, GAPI.INTERNAL_ERROR],
                        name=name, **kwargs)
      if result['done']:
        if 'error' not in result:
          entityActionPerformed([Ent.DEVICE, name], i, count)
        else:
          entityActionFailedWarning([Ent.DEVICE, name], result['error']['message'], i, count)
      else:
        entityActionPerformedMessage([Ent.DEVICE, name], Msg.ACTION_IN_PROGRESS.format(action), i, count)
    except GAPI.notFound:
      entityUnknownWarning(Ent.DEVICE, f'{name}', i, count)
    except (GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition, GAPI.permissionDenied, GAPI.internalError) as e:
      entityActionFailedWarning([Ent.DEVICE, f'{name}'], str(e), i, count)

# gam delete device <DeviceEntity> [doit]
def doDeleteCIDevice():
  _performCIDeviceAction('delete')

# gam cancelwipe device <DeviceEntity> [doit]
def doCancelWipeCIDevice():
  _performCIDeviceAction('cancelWipe')

# gam wipe device <DeviceEntity> [removeresetlock] [doit]
def doWipeCIDevice():
  _performCIDeviceAction('wipe')

# gam update device <DeviceEntity> action <DeviceAction> [removeresetlock] [doit]
def doUpdateCIDevice():
  _performCIDeviceAction(None)

DEVICE_MISSING_ACTION_MAP = {
  'delete': 'delete',
  'wipe': 'wipe',
  'donothing': 'none',
  'none': 'none',
  }

# gam sync devices
#	<CSVFileSelector>
#	[(query <QueryDevice>)|(queries <QueryDeviceList>) (querytime<String> <Time>)*]
#	(devicetype_column <String>)|(static_devicetype <DeviceType>)
#	(serialnumber_column <String>)
#	[assettag_column <String>]
#	[unassigned_missing_action delete|wipe|none|donothing]
#	[assigned_missing_action delete|wipe|none|donothing]
#	[preview]
def doSyncCIDevices():
  ci = buildGAPICIDeviceServiceObject()
  customer = _getCustomersCustomerIdNoC()
  queryTimes = {}
  queries = [None]
  filename = None
  serialNumberColumn = 'serialNumber'
  deviceTypeColumn = 'deviceType'
  assetTagColumn = None
  staticDeviceType = None
  fieldsList = ['serialNumber', 'deviceType', 'lastSyncTime', 'name']
  unassignedMissingAction = 'delete'
  assignedMissingAction = 'none'
  preview = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in ['filter', 'filters', 'query', 'queries']:
      queries = getQueries(myarg)
    elif myarg.startswith('querytime'):
      queryTimes[myarg] = getTimeOrDeltaFromNow()[0:19]
    elif myarg in {'csv', 'csvfile'}:
      filename = getString(Cmd.OB_STRING)
    elif myarg == 'serialnumbercolumn':
      serialNumberColumn = getString(Cmd.OB_STRING)
    elif myarg == 'devicetypecolumn':
      deviceTypeColumn = getString(Cmd.OB_STRING)
    elif myarg in {'assettagcolumn', 'assetidcolumn'}:
      assetTagColumn = getString(Cmd.OB_STRING)
      fieldsList.append('assetTag')
    elif myarg == 'staticdevicetype':
      staticDeviceType = getChoice(DEVICE_TYPE_MAP, mapChoice=True)
    elif myarg == 'unassignedmissingaction':
      unassignedMissingAction = getChoice(DEVICE_MISSING_ACTION_MAP, mapChoice=True)
    elif myarg == 'assignedmissingaction':
      assignedMissingAction = getChoice(DEVICE_MISSING_ACTION_MAP, mapChoice=True)
    elif myarg == 'preview':
      preview = True
    else:
      unknownArgumentExit()
  if not filename:
    missingArgumentExit('csvfile')
  f, csvFile, fieldnames = openCSVFileReader(filename)
  if serialNumberColumn not in fieldnames:
    csvFieldErrorExit(serialNumberColumn, fieldnames)
  if not staticDeviceType and deviceTypeColumn not in fieldnames:
    csvFieldErrorExit(deviceTypeColumn, fieldnames)
  if assetTagColumn and assetTagColumn not in fieldnames:
    csvFieldErrorExit(assetTagColumn, fieldnames)
  localDevices = {}
  for row in csvFile:
    # upper() is very important to comparison since Google
    # always return uppercase serials
    localDevice = {'serialNumber': row[serialNumberColumn].strip().upper()}
    if staticDeviceType:
      localDevice['deviceType'] = staticDeviceType
    else:
      dt = row[deviceTypeColumn].strip()
      localDevice['deviceType'] = DEVICE_TYPE_MAP.get(dt.lower().replace('_', '').replace('-', ''), dt.upper())
    sndt = f"{localDevice['serialNumber']}-{localDevice['deviceType']}"
    if assetTagColumn:
      localDevice['assetTag'] = row[assetTagColumn].strip()
      sndt += f"-{localDevice['assetTag']}"
    localDevices[sndt] = localDevice
  closeFile(f)
  fields = f'nextPageToken,devices({",".join(fieldsList)})'
  remoteDevices = {}
  remoteDeviceMap = {}
  substituteQueryTimes(queries, queryTimes)
  for query in queries:
    printGettingAllAccountEntities(Ent.COMPANY_DEVICE, query)
    pageMessage = getPageMessage()
    try:
      result = callGAPIpages(ci.devices(), 'list', 'devices',
                             throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                             retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                             pageMessage=pageMessage,
                             customer=customer, filter=query, view='COMPANY_INVENTORY',
                             fields=fields, pageSize=100)
    except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      entityActionFailedWarning([Ent.COMPANY_DEVICE, None], str(e))
      return
    for remoteDevice in result:
      sn = remoteDevice['serialNumber']
      last_sync = remoteDevice.pop('lastSyncTime', NEVER_TIME_NOMS)
      name = remoteDevice.pop('name')
      sndt = f"{remoteDevice['serialNumber']}-{remoteDevice['deviceType']}"
      if assetTagColumn:
        if 'assetTag' not in remoteDevice:
          remoteDevice['assetTag'] = ''
        sndt += f"-{remoteDevice['assetTag']}"
      remoteDevices[sndt] = remoteDevice
      remoteDeviceMap[sndt] = {'name': name}
      if last_sync == NEVER_TIME_NOMS:
        remoteDeviceMap[sndt]['unassigned'] = True
  devicesToAdd = []
  for sndt, device in iter(localDevices.items()):
    if sndt not in remoteDevices:
      devicesToAdd.append(device)
  missingDevices = []
  for sndt, device in iter(remoteDevices.items()):
    if sndt not in localDevices:
      missingDevices.append(device)
  Act.Set([Act.CREATE, Act.CREATE_PREVIEW][preview])
  count = len(devicesToAdd)
  performActionNumItems(count, Ent.COMPANY_DEVICE)
  i = 0
  for device in devicesToAdd:
    i += 1
    try:
      if not preview:
        result = callGAPI(ci.devices(), 'create',
                          throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.ALREADY_EXISTS],
                          customer=customer, body=device)
        if 'error' in result:
          entityActionFailedWarning([Ent.COMPANY_DEVICE, _makeDeviceId('/devices/???', device)], result['error']['message'], i, count)
          continue
        deviceId = _makeDeviceId(f'{result["response"]["name"]}', device)
      else:
        deviceId = _makeDeviceId('/devices/???', device)
      entityActionPerformed([Ent.COMPANY_DEVICE, deviceId], i, count)
    except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied, GAPI.alreadyExists) as e:
      entityActionFailedWarning([Ent.COMPANY_DEVICE, _makeDeviceId('/devices/???', device)], str(e), i, count)
  Act.Set([Act.PROCESS, Act.PROCESS_PREVIEW][preview])
  count = len(missingDevices)
  performActionNumItems(count, Ent.COMPANY_DEVICE)
  i = 0
  for device in missingDevices:
    i += 1
    sn = device['serialNumber']
    sndt = f"{sn}-{device['deviceType']}"
    if assetTagColumn:
      sndt += f"-{device['assetTag']}"
    name = remoteDeviceMap[sndt]['name']
    deviceId = _makeDeviceId(f'{name}', device)
    unassigned = remoteDeviceMap[sndt].get('unassigned')
    action = unassignedMissingAction if unassigned else assignedMissingAction
    if action == 'none':
      Act.Set([Act.NOACTION, Act.NOACTION_PREVIEW][preview])
      entityActionPerformed([Ent.COMPANY_DEVICE, deviceId], i, count)
      continue
    if action == 'delete':
      Act.Set([Act.DELETE, Act.DELETE_PREVIEW][preview])
      kwargs = {'customer': customer}
    else:
      Act.Set([Act.WIPE, Act.WIPE_PREVIEW][preview])
      kwargs = {'body': {'customer': customer}}
    try:
      if not preview:
        result = callGAPI(ci.devices(), action,
                          bailOnInternalError=True,
                          throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.INVALID_ARGUMENT,
                                        GAPI.FAILED_PRECONDITION, GAPI.PERMISSION_DENIED, GAPI.INTERNAL_ERROR],
                          name=name, **kwargs)
      else:
        result = {'done': True}
      if result['done']:
        if 'error' not in result:
          entityActionPerformed([Ent.COMPANY_DEVICE, deviceId], i, count)
        else:
          entityActionFailedWarning([Ent.COMPANY_DEVICE, deviceId], result['error']['message'], i, count)
      else:
        entityActionPerformedMessage([Ent.COMPANY_DEVICE, deviceId], Msg.ACTION_IN_PROGRESS.format(action), i, count)
    except GAPI.notFound:
      entityUnknownWarning(Ent.COMPANY_DEVICE, deviceId, i, count)
    except (GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition, GAPI.permissionDenied, GAPI.internalError) as e:
      entityActionFailedWarning([Ent.COMPANY_DEVICE, deviceId], str(e), i, count)

DEVICE_FIELDS_CHOICE_MAP = {
  'androidspecificattributes': 'androidSpecificAttributes',
  'assettag': 'assetTag',
  'basebandversion': 'basebandVersion',
  'bootloaderversion': 'bootloaderVersion',
  'brand': 'brand',
  'buildnumber': 'buildNumber',
  'compromisedstate': 'compromisedState',
  'createtime': 'createTime',
  'deviceid': 'deviceId',
  'devicetype': 'deviceType',
  'enableddeveloperoptions': 'enabledDeveloperOptions',
  'enabledusbdebugging': 'enabledUsbDebugging',
  'encryptionstate': 'encryptionState',
  'endpointverificationspecificattributes': 'endpointVerificationSpecificAttributes',
  'hostname': 'hostname',
  'imei': 'imei',
  'kernelversion': 'kernelVersion',
  'lastsynctime': 'lastSyncTime',
  'managementstate': 'managementState',
  'manufacturer': 'manufacturer',
  'meid': 'meid',
  'model': 'model',
  'name': 'name',
  'networkoperator': 'networkOperator',
  'osversion': 'osVersion',
  'otheraccounts': 'otherAccounts',
  'ownertype': 'ownerType',
  'releaseversion': 'releaseVersion',
  'securitypatchtime': 'securityPatchTime',
  'serialnumber': 'serialNumber',
  'unifieddeviceid': 'unifiedDeviceId',
  'wifimacaddresses': 'wifiMacAddresses'
  }

DEVICEUSER_FIELDS_CHOICE_MAP = {
  'compromisedstate': 'compromisedState',
  'createtime': 'createTime',
  'firstsynctime': 'firstSyncTime',
  'languagecode': 'languageCode',
  'lastsynctime': 'lastSyncTime',
  'managementstate': 'managementState',
  'name': 'name',
  'passwordstate': 'passwordState',
  'useragent': 'userAgent',
  'useremail': 'userEmail',
  }

# gam info device <DeviceEntity>
#	<DeviceFieldName>* [fields <DevieFieldNameList>] [userfields <DeviceUserFieldNameList>]
#	[nodeviceusers]
#	[formatjson]
def doInfoCIDevice():
  entityList, ci, customer, _ = getCIDeviceEntity()
  FJQC = FormatJSONQuoteChar()
  fieldsList = []
  userFieldsList = []
  getDeviceUsers = True
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if getFieldsList(myarg, DEVICE_FIELDS_CHOICE_MAP, fieldsList, initialField='name'):
      pass
    elif getFieldsList(myarg, DEVICEUSER_FIELDS_CHOICE_MAP, userFieldsList, initialField='name', fieldsArg='userfields'):
      pass
    elif myarg == 'nodeviceusers':
      getDeviceUsers = False
    else:
      FJQC.GetFormatJSON(myarg)
  fields = getFieldsFromFieldsList(fieldsList)
  userFields = getFieldsFromFieldsList(userFieldsList)
  i = 0
  count = len(entityList)
  for device in entityList:
    i += 1
    name = device['name']
    try:
      device = callGAPI(ci.devices(), 'get',
                        throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                        name=name, customer=customer, fields=fields)
      if getDeviceUsers:
        device_users = callGAPIpages(ci.devices().deviceUsers(), 'list', 'deviceUsers',
                                     throwReasons=[GAPI.INVALID, GAPI.PERMISSION_DENIED],
                                     parent=name, customer=customer, fields=userFields)
        for device_user in device_users:
          device_user['client_states'] = callGAPIpages(ci.devices().deviceUsers().clientStates(), 'list', 'clientStates',
                                                       throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                                                       parent=device_user['name'], customer=customer)
      else:
        device_users = []
      if FJQC.formatJSON:
        if getDeviceUsers:
          device['users'] = device_users
        printLine(json.dumps(cleanJSON(device, timeObjects=DEVICE_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
      else:
        printEntity([Ent.DEVICE, device.pop('name')])
        Ind.Increment()
        showJSON(None, device, timeObjects=DEVICE_TIME_OBJECTS)
        count = len(device_users)
        i = 0
        for device_user in device_users:
          i += 1
          printEntity([Ent.DEVICE_USER, device_user.pop('name')], i, count)
          Ind.Increment()
          showJSON(None, device_user, timeObjects=DEVICE_TIME_OBJECTS)
          Ind.Decrement()
          Ind.Decrement()
    except GAPI.notFound:
      entityUnknownWarning(Ent.DEVICE, f'{name}')
    except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      entityActionFailedWarning([Ent.DEVICE, f'{name}'], str(e))

DEVICE_VIEW_CHOICE_MAP = {
  'all': (None, Ent.DEVICE),
  'company': ('COMPANY_INVENTORY', Ent.COMPANY_DEVICE),
  'personal': ('USER_ASSIGNED_DEVICES', Ent.PERSONAL_DEVICE),
  'nocompanydevices': ('USER_ASSIGNED_DEVICES', Ent.PERSONAL_DEVICE),
  'nopersonaldevices': ('COMPANY_INVENTORY', Ent.COMPANY_DEVICE)
  }
DEVICE_ORDERBY_CHOICE_MAP = {
  'createtime': 'create_time',
  'devicetype': 'device_type',
  'lastsynctime': 'last_sync_time',
  'model': 'model',
  'osversion': 'os_version',
  'serialnumber': 'serial_number'
  }

# gam print devices [todrive <ToDriveAttribute>*]
#	[(query <QueryDevice>)|(queries <QueryDeviceList>) (querytime<String> <Time>)*]
#	<DeviceFieldName>* [fields <DeviceFieldNameList>] [userfields <DeviceUserFieldNameList>]
#	[orderby <DeviceOrderByFieldName> [ascending|descending]]
#	[all|company|personal|nocompanydevices|nopersonaldevices]
#	[nodeviceusers|oneuserperrow]
#	[formatjson [quotechar <Character>]]
# 	[showitemcountonly]
def doPrintCIDevices():
  ci = buildGAPICIDeviceServiceObject()
  customer = _getCustomersCustomerIdNoC()
  parent = 'devices/-'
  csvPF = CSVPrintFile(['name'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  OBY = OrderBy(DEVICE_ORDERBY_CHOICE_MAP)
  fieldsList = []
  userFieldsList = []
  queryTimes = {}
  queries = [None]
  view, entityType = DEVICE_VIEW_CHOICE_MAP['all']
  getDeviceUsers = True
  oneUserPerRow = showItemCountOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in ['filter', 'filters', 'query', 'queries']:
      queries = getQueries(myarg)
    elif myarg.startswith('querytime'):
      queryTimes[myarg] = getTimeOrDeltaFromNow()[0:19]
    elif myarg == 'orderby':
      OBY.GetChoice()
    elif myarg in DEVICE_VIEW_CHOICE_MAP:
      view, entityType = DEVICE_VIEW_CHOICE_MAP[myarg]
    elif myarg == 'nodeviceusers':
      getDeviceUsers = False
    elif myarg in {'oneuserperrow', 'oneitemperrow'}:
      getDeviceUsers = oneUserPerRow = True
    elif getFieldsList(myarg, DEVICE_FIELDS_CHOICE_MAP, fieldsList, initialField='name'):
      pass
    elif getFieldsList(myarg, DEVICEUSER_FIELDS_CHOICE_MAP, userFieldsList, initialField='name', fieldsArg='userfields'):
      pass
    elif myarg == 'sortheaders':
      pass
    elif myarg == 'showitemcountonly':
      showItemCountOnly = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  fields = getItemFieldsFromFieldsList('devices', fieldsList)
  userFields = getItemFieldsFromFieldsList('deviceUsers', userFieldsList)
  substituteQueryTimes(queries, queryTimes)
  if FJQC.formatJSON and oneUserPerRow:
    csvPF.SetJSONTitles(['name', 'user.name', 'JSON'])
  itemCount = 0
  for query in queries:
    printGettingAllAccountEntities(entityType, query)
    pageMessage = getPageMessage()
    try:
      devices = callGAPIpages(ci.devices(), 'list', 'devices',
                              pageMessage=pageMessage,
                              throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                              retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                              customer=customer, filter=query,
                              orderBy=OBY.orderBy, view=view, fields=fields, pageSize=100)
      if showItemCountOnly:
        itemCount += len(devices)
        continue
    except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      entityActionFailedWarning([entityType, None], str(e))
      continue
    if getDeviceUsers:
      ci._http.credentials.refresh(transportCreateRequest())
      deviceDict = {}
      for device in devices:
        deviceDict[device['name']] = device
      printGettingAllAccountEntities(Ent.DEVICE_USER, query)
      pageMessage = getPageMessage()
      try:
        deviceUsers = callGAPIpages(ci.devices().deviceUsers(), 'list', 'deviceUsers',
                                    pageMessage=pageMessage,
                                    throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                                    retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                    customer=customer, filter=query, parent=parent,
                                    orderBy=OBY.orderBy, fields=userFields, pageSize=20)
        for deviceUser in deviceUsers:
          mg = DEVICE_USERNAME_PATTERN.match(deviceUser['name'])
          if mg:
            deviceName = mg.group(1)
            if deviceName in deviceDict:
              deviceDict[deviceName].setdefault('users', [])
              deviceDict[deviceName]['users'].append(deviceUser)
      except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied) as e:
        entityActionFailedWarning([entityType, None], str(e))
    for device in devices:
      if not oneUserPerRow or 'users' not in device:
        row = flattenJSON(device, timeObjects=DEVICE_TIME_OBJECTS)
        if not FJQC.formatJSON:
          csvPF.WriteRowTitles(row)
        elif csvPF.CheckRowTitles(row):
          csvPF.WriteRowNoFilter({'name': device['name'],
                                  'JSON': json.dumps(cleanJSON(device, timeObjects=DEVICE_TIME_OBJECTS),
                                                     ensure_ascii=False, sort_keys=True)})
      else:
        deviceUsers = device.pop('users')
        baserow = flattenJSON(device, timeObjects=DEVICE_TIME_OBJECTS)
        for deviceUser in deviceUsers:
          row = flattenJSON({'user': deviceUser}, flattened=baserow.copy(), timeObjects=DEVICE_TIME_OBJECTS)
          if not FJQC.formatJSON:
            csvPF.WriteRowTitles(row)
          elif csvPF.CheckRowTitles(row):
            device['user'] = deviceUser
            csvPF.WriteRowNoFilter({'name': device['name'], 'user.name': deviceUser['name'],
                                    'JSON': json.dumps(cleanJSON(device, timeObjects=DEVICE_TIME_OBJECTS),
                                                       ensure_ascii=False, sort_keys=True)})
  if showItemCountOnly:
    writeStdout(f'{itemCount}\n')
    return
  csvPF.writeCSVfile('Devices')

DEVICE_USER_ACTION_CHOICES = {'approve', 'block', 'cancelwipe', 'wipe'}

def _performCIDeviceUserAction(action):
  entityList, ci, customer, doit = getCIDeviceUserEntity()
  count = len(entityList)
  action, kwargs = getUpdateDeleteCIDeviceOptions(Ent.DEVICE_USER, count, action, doit, DEVICE_USER_ACTION_CHOICES)
  if action == 'delete':
    kwargs['customer'] = customer
  else:
    kwargs.setdefault('body', {})
    kwargs['body']['customer'] = customer
  i = 0
  for deviceUser in entityList:
    i += 1
    name = deviceUser['name']
    try:
      result = callGAPI(ci.devices().deviceUsers(), action,
                        bailOnInternalError=True,
                        throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.INVALID_ARGUMENT,
                                      GAPI.FAILED_PRECONDITION, GAPI.PERMISSION_DENIED, GAPI.INTERNAL_ERROR],
                        name=name, **kwargs)
      if result['done']:
        if 'error' not in result:
          entityActionPerformed([Ent.DEVICE_USER, name], i, count)
        else:
          entityActionFailedWarning([Ent.DEVICE_USER, name], result['error']['message'], i, count)
      else:
        entityActionPerformedMessage([Ent.DEVICE_USER, name], Msg.ACTION_IN_PROGRESS.format(action), i, count)
      Ind.Increment()
      showJSON(None, result, timeObjects=DEVICE_TIME_OBJECTS)
      Ind.Decrement()
    except GAPI.notFound:
      entityUnknownWarning(Ent.DEVICE_USER, f'{name}', i, count)
    except (GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition, GAPI.permissionDenied, GAPI.internalError) as e:
      entityActionFailedWarning([Ent.DEVICE_USER, f'{name}'], str(e), i, count)

# gam approve deviceuser <DeviceUserEntity> [doit]
def doApproveCIDeviceUser():
  _performCIDeviceUserAction('approve')

# gam block deviceuser <DeviceUserEntity> [doit]
def doBlockCIDeviceUser():
  _performCIDeviceUserAction('block')

# gam delete deviceuser <DeviceUserEntity> [doit]
def doDeleteCIDeviceUser():
  _performCIDeviceUserAction('delete')

# gam cancelwipe deviceuser <DeviceUserEntity> [doit]
def doCancelWipeCIDeviceUser():
  _performCIDeviceUserAction('cancelWipe')

# gam wipe deviceuser <DeviceUserEntity> [doit]
def doWipeCIDeviceUser():
  _performCIDeviceUserAction('wipe')

# gam update deviceuser <DeviceUserEntity> action <DeviceUserAction> [doit]
def doUpdateCIDeviceUser():
  _performCIDeviceUserAction(None)

# gam info deviceuser <DeviceUserEntity>
#	<DeviceUserFieldName>* [fields <DevieUserFieldNameList>]
#	[formatjson]
def doInfoCIDeviceUser():
  entityList, ci, customer, _ = getCIDeviceUserEntity()
  FJQC = FormatJSONQuoteChar()
  userFieldsList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if getFieldsList(myarg, DEVICE_FIELDS_CHOICE_MAP, userFieldsList, initialField='name'):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  userFields = getFieldsFromFieldsList(userFieldsList)
  i = 0
  count = len(entityList)
  for deviceUser in entityList:
    i += 1
    name = deviceUser['name']
    try:
      deviceUser = callGAPI(ci.devices().deviceUsers(), 'get',
                            throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                            name=name, customer=customer, fields=userFields)
      if FJQC.formatJSON:
        printLine(json.dumps(cleanJSON(deviceUser, timeObjects=DEVICE_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
      else:
        printEntity([Ent.DEVICE_USER, deviceUser.pop('name')], i, count)
        Ind.Increment()
        showJSON(None, deviceUser, timeObjects=DEVICE_TIME_OBJECTS)
        Ind.Decrement()
    except GAPI.notFound:
      entityUnknownWarning(Ent.DEVICE_USER, f'{name}', i, count)
    except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      entityActionFailedWarning([Ent.DEVICE_USER, f'{name}'], str(e), i, count)

# gam print deviceusers [todrive <ToDriveAttribute>*]
#	[select <DeviceID>]
#	[(query <QueryDevice>)|(queries <QueryDeviceList>) (querytime<String> <Time>)*]
#	<DeviceUserFieldName>* [fields <DevieUserFieldNameList>]
#	[orderby <DeviceOrderByFieldName> [ascending|descending]]
#	[formatjson [quotechar <Character>]]
# 	[showitemcountonly]
def doPrintCIDeviceUsers():
  ci = buildGAPICIDeviceServiceObject()
  customer = _getCustomersCustomerIdNoC()
  csvPF = CSVPrintFile(['name'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  OBY = OrderBy(DEVICE_ORDERBY_CHOICE_MAP)
  userFieldsList = []
  queryTimes = {}
  queries = [None]
  parent = 'devices/-'
  showItemCountOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'select':
      parent = getString(Cmd.OB_DEVICE_ID)
      if not parent.startswith('devices/'):
        parent = f'devices/{parent}'
    elif myarg in ['filter', 'filters', 'query', 'queries']:
      queries = getQueries(myarg)
    elif myarg.startswith('querytime'):
      queryTimes[myarg] = getTimeOrDeltaFromNow()[0:19]
    elif myarg == 'orderby':
      OBY.GetChoice()
    elif getFieldsList(myarg, DEVICEUSER_FIELDS_CHOICE_MAP, userFieldsList, initialField='name'):
      pass
    elif myarg == 'sortheaders':
      pass
    elif myarg == 'showitemcountonly':
      showItemCountOnly = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  userFields = getItemFieldsFromFieldsList('deviceUsers', userFieldsList)
  substituteQueryTimes(queries, queryTimes)
  itemCount = 0
  for query in queries:
    printGettingAllAccountEntities(Ent.DEVICE_USER, query)
    pageMessage = getPageMessage()
    try:
      deviceUsers = callGAPIpages(ci.devices().deviceUsers(), 'list', 'deviceUsers',
                                  pageMessage=pageMessage,
                                  throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                                  retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                  customer=customer, filter=query,
                                  orderBy=OBY.orderBy, parent=parent, fields=userFields, pageSize=20)
      if showItemCountOnly:
        itemCount += len(deviceUsers)
        continue
      for deviceUser in deviceUsers:
        row = flattenJSON(deviceUser, timeObjects=DEVICE_TIME_OBJECTS)
        if not FJQC.formatJSON:
          csvPF.WriteRowTitles(row)
        elif csvPF.CheckRowTitles(row):
          csvPF.WriteRowNoFilter({'name': deviceUser['name'],
                                'JSON': json.dumps(cleanJSON(deviceUser, timeObjects=DEVICE_TIME_OBJECTS),
                                                     ensure_ascii=False, sort_keys=True)})
    except (GAPI.invalid, GAPI.permissionDenied) as e:
      entityActionFailedWarning([Ent.DEVICE_USER, None], str(e))
      break
  if showItemCountOnly:
    writeStdout(f'{itemCount}\n')
    return
  csvPF.writeCSVfile('Device Users')

DEVICE_USER_COMPLIANCE_STATE_CHOICE_MAP = {
  'compliant': 'COMPLIANT',
  'noncompliant': 'NON_COMPLIANT'
  }
DEVICE_USER_HEALTH_SCORE_CHOICE_MAP = {
  'verypoor': 'VERY_POOR',
  'poor': 'POOR',
  'neutral': 'NEUTRAL',
  'good': 'GOOD',
  'verygood': 'VERY_GOOD'
  }
DEVICE_USER_MANAGED_STATE_CHOICE_MAP = {
  'clear': None,
  'managed': 'MANAGED',
  'unmanaged': 'UNMANAGED'
  }
DEVICE_USER_CUSTOM_VALUE_TYPE_CHOICE_MAP = {
  'bool': 'boolValue',
  'boolean': 'boolValue',
  'number': 'numberValue',
  'string': 'stringValue'
  }

# gam info deviceuserstate <DeviceUserEntity> [clientid <String>]
def doInfoCIDeviceUserState():
  setTrueCustomerId()
  entityList, ci, customer, _ = getCIDeviceUserEntity()
  customerID = customer[10:]
  client_id = f'{customerID}-gam'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'clientid':
      client_id = f'{customerID}-{getString(Cmd.OB_STRING)}'
    else:
      unknownArgumentExit()
  count = len(entityList)
  i = 0
  for deviceUser in entityList:
    i += 1
    deviceUser = deviceUser['name']
    name = f'{deviceUser}/clientStates/{client_id}'
    try:
      result = callGAPI(ci.devices().deviceUsers().clientStates(), 'get',
                        bailOnInternalError=True,
                        throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.INVALID_ARGUMENT,
                                      GAPI.FAILED_PRECONDITION, GAPI.PERMISSION_DENIED, GAPI.INTERNAL_ERROR],
                        name=name, customer=customer)
      printEntity([Ent.DEVICE_USER_CLIENT_STATE, name], i, count)
      Ind.Increment()
      result.pop('name', None)
      showJSON(None, result, timeObjects=DEVICE_TIME_OBJECTS)
      Ind.Decrement()
    except GAPI.notFound:
      entityUnknownWarning(Ent.DEVICE_USER, deviceUser, i, count)
    except (GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition, GAPI.permissionDenied, GAPI.internalError) as e:
      entityActionFailedWarning([Ent.DEVICE_USER, deviceUser], str(e), i, count)

# gam update deviceuserstate <DeviceUserEntity> [clientid <String>]
#	[customid <String>] [assettags clear|<AssetTagList>]
#	[compliantstate|compliancestate compliant|noncompliant] [managedstate clear|managed|unmanaged]
#	[healthscore verypoor|poor|neutral|good|verygood] [scorereason clear|<String>]
#	(customvalue (bool|boolean <Boolean>)|(number <Integer>)(string <String>))*
def doUpdateCIDeviceUserState():
  setTrueCustomerId()
  entityList, ci, customer, _ = getCIDeviceUserEntity()
  customerID = customer[10:]
  client_id = f'{customerID}-gam'
  body = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'clientid':
      client_id = f'{customerID}-{getString(Cmd.OB_STRING)}'
    elif myarg in ['assettag', 'assettags']:
      body['assetTags'] = convertEntityToList(getString(Cmd.OB_STRING, minLen=0), shlexSplit=True)
      if not body['assetTags'] or body['assetTags'] == ['clear']:
        # TODO: This doesn't work to clear existing values; figure out why.
        body['assetTags'] = [None]
    elif myarg in ['compliantstate', 'compliancestate']:
      body['complianceState'] = getChoice(DEVICE_USER_COMPLIANCE_STATE_CHOICE_MAP, mapChoice=True)
    elif myarg == 'healthscore':
      body['healthScore'] = getChoice(DEVICE_USER_HEALTH_SCORE_CHOICE_MAP, mapChoice=True)
    elif myarg in ['scorereason']:
      body['scoreReason'] = getString(Cmd.OB_STRING)
      if body['scoreReason'] == 'clear':
        body['scoreReason'] = None
    elif myarg == 'customid':
      body['customId'] = getString(Cmd.OB_STRING)
    elif myarg == 'customvalue':
      valueType = getChoice(DEVICE_USER_CUSTOM_VALUE_TYPE_CHOICE_MAP, mapChoice=True)
      key = getString(Cmd.OB_STRING)
      if valueType == 'boolValue':
        value = getBoolean()
      elif valueType == 'numberValue':
        value = getInteger()
      else: # stringValue
        value = getString(Cmd.OB_STRING)
      body.setdefault('keyValuePairs', {})
      body['keyValuePairs'][key] = {valueType: value}
    elif myarg == 'managedstate':
      body['managed'] = getChoice(DEVICE_USER_MANAGED_STATE_CHOICE_MAP, mapChoice=True)
    else:
      unknownArgumentExit()
  count = len(entityList)
  i = 0
  for deviceUser in entityList:
    i += 1
    deviceUser = deviceUser['name']
    name = f'{deviceUser}/clientStates/{client_id}'
    try:
      result = callGAPI(ci.devices().deviceUsers().clientStates(), 'patch',
                        bailOnInternalError=True,
                        throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.INVALID_ARGUMENT,
                                      GAPI.FAILED_PRECONDITION, GAPI.PERMISSION_DENIED, GAPI.INTERNAL_ERROR],
                        name=name, customer=customer, updateMask=','.join(body.keys()), body=body)
      if result['done']:
        if 'error' not in result:
          entityActionPerformed([Ent.DEVICE_USER_CLIENT_STATE, name], i, count)
          result = result['response']
          result.pop('name')
        else:
          entityActionFailedWarning([Ent.DEVICE_USER_CLIENT_STATE, name], result['error']['message'], i, count)
      else:
        entityActionPerformedMessage([Ent.DEVICE_USER_CLIENT_STATE, name], Msg.ACTION_IN_PROGRESS.format('update Client State'), i, count)
      Ind.Increment()
      showJSON(None, result, timeObjects=DEVICE_TIME_OBJECTS)
      Ind.Decrement()
    except GAPI.notFound:
      entityUnknownWarning(Ent.DEVICE_USER, deviceUser)
    except (GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition, GAPI.permissionDenied, GAPI.internalError) as e:
      entityActionFailedWarning([Ent.DEVICE_USER, deviceUser], str(e))

def isolatePrinterID(name):
  ''' converts long name into simple ID'''
  return name.split('/')[-1]

def _getPrinterID():
  cd = buildGAPIObject(API.PRINTERS)
  customer = _getCustomersCustomerIdWithC()
  printerId = getString(Cmd.OB_PRINTER_ID)
  pattern = re.compile(rf'^{customer}/chrome/printers/(.+)$')
  mg = pattern.match(printerId)
  if mg:
    return (printerId, mg.group(1), cd)
  return (f'{customer}/chrome/printers/{printerId}', printerId, cd)

def _getPrinterEntity():
  cd = buildGAPIObject(API.PRINTERS)
  customer = _getCustomersCustomerIdWithC()
  printerId = getString(Cmd.OB_PRINTER_ID)
  entitySelector = printerId.lower()
  if entitySelector == Cmd.ENTITY_SELECTOR_FILE:
    printerIds = getEntitiesFromFile(False)
  elif entitySelector == Cmd.ENTITY_SELECTOR_CSVFILE:
    printerIds = getEntitiesFromCSVFile(False)
  else:
    printerIds = printerId.replace(',', ' ').split()
  pattern = re.compile(rf'^{customer}/chrome/printers/(.+)$')
  for i, printerId in enumerate(printerIds):
    mg = pattern.match(printerId)
    if mg:
      printerIds[i] = mg.group(1)
    return (customer, printerIds, cd)

CREATE_PRINTER_JSON_SKIP_FIELDS = ['id', 'name', 'createTime', 'orgUnitPath', 'auxiliaryMessages']
UPDATE_PRINTER_JSON_SKIP_FIELDS = ['id', 'name', 'createTime', 'orgUnitId', 'orgUnitPath', 'auxiliaryMessages']

def _getPrinterAttributes(cd, jsonDeleteFields):
  '''get printer attributes for create/update commands'''
  body = {}
  showDetails = True
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'description':
      body['description'] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg == 'displayname':
      body['displayName'] = getString(Cmd.OB_STRING)
    elif myarg == 'makeandmodel':
      body['makeAndModel'] = getString(Cmd.OB_STRING)
    elif myarg in {'ou', 'org', 'orgunit', 'orgunitid'}:
      _, body['orgUnitId'] = getOrgUnitId(cd)
      body['orgUnitId'] = body['orgUnitId'][3:]
    elif myarg == 'uri':
      body['uri'] = getString(Cmd.OB_STRING)
    elif myarg in {'driverless', 'usedriverlessconfig'}:
      body['useDriverlessConfig'] = getBoolean()
    elif myarg == 'nodetails':
      showDetails = False
    elif myarg == 'json':
      body.update(getJSON(jsonDeleteFields))
    else:
      unknownArgumentExit()
  if body.get('makeAndModel'):
    body.pop('useDriverlessConfig', None)
  return (body, showDetails)

PRINTER_FIELDS_CHOICE_MAP = {
  'auxiliarymessages': 'auxiliaryMessages',
  'createtime': 'createTime',
  'description': 'description',
  'displayname': 'displayName',
  'id': 'id',
  'makeandmodel': 'makeAndModel',
  'name': 'name',
  'org': 'orgUnitId',
  'orgunit': 'orgUnitId',
  'orgunitid': 'orgUnitId',
  'ou': 'orgUnitId',
  'uri': 'uri',
  'usedriverlessconfig': 'useDriverlessConfig',
  }
PRINTER_TIME_OBJECTS = {'createTime'}

def _checkPrinterInheritance(cd, printer, orgUnitId, showInherited):
  if 'orgUnitId' in printer:
    if not showInherited:
      if orgUnitId is not None and printer['orgUnitId'] != orgUnitId:
        return False
    elif orgUnitId is not None and printer['orgUnitId'] != orgUnitId:
      printer['inherited'] = True
      printer['parentOrgUnitId'] = printer['orgUnitId']
      printer['parentOrgUnitPath'] = convertOrgUnitIDtoPath(cd, f'id:{printer["parentOrgUnitId"]}')
      printer['orgUnitId'] = orgUnitId
    else:
      printer['inherited'] = False
      printer['parentOrgUnitId'] = printer['parentOrgUnitPath'] = ''
    printer['orgUnitPath'] = convertOrgUnitIDtoPath(cd, f'id:{printer["orgUnitId"]}')
  return True

def _showPrinter(cd, printer, FJQC, orgUnitId=None, showInherited=False, i=0, count=0):
  if not _checkPrinterInheritance(cd, printer, orgUnitId, showInherited):
    return False
  if FJQC is not None and FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(printer, timeObjects=PRINTER_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.PRINTER, printer['id']], i, count)
  Ind.Increment()
  showJSON(None, printer, timeObjects=PRINTER_TIME_OBJECTS)
  Ind.Decrement()

# gam create printer <PrinterAttribute>+ [nodetails]
def doCreatePrinter():
  cd = buildGAPIObject(API.DIRECTORY)
  parent = _getCustomersCustomerIdWithC()
  body, showDetails = _getPrinterAttributes(cd, CREATE_PRINTER_JSON_SKIP_FIELDS)
  if not body.get('orgUnitId'):
    missingArgumentExit('orgunit')
  try:
    printer = callGAPI(cd.customers().chrome().printers(), 'create',
                       throwReasons=[GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                       parent=parent, body=body)
    entityActionPerformed([Ent.PRINTER, printer['id']])
    if showDetails:
      _showPrinter(cd, printer, None)
  except (GAPI.invalidArgument, GAPI.permissionDenied) as e:
    entityActionFailedWarning([Ent.PRINTER, None], str(e))

# gam update printer <PrinterID> <PrinterAttribute>+ [nodetails]
def doUpdatePrinter():
  name, printerId, cd = _getPrinterID()
  body, showDetails = _getPrinterAttributes(cd, UPDATE_PRINTER_JSON_SKIP_FIELDS)
  updateMask = ','.join(list(body.keys()))
  # note clearMask seems unnecessary. Updating field to '' clears it.
  try:
    printer = callGAPI(cd.customers().chrome().printers(), 'patch',
                       throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                       name=name, updateMask=updateMask, body=body)
    entityActionPerformed([Ent.PRINTER, printerId])
    if showDetails:
      _showPrinter(cd, printer, None)
  except (GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
    entityActionFailedWarning([Ent.PRINTER, printerId], str(e))

# gam delete printer
#	<PrinterIDList>|
#	<FileSelector>|
#	<CSVFileSelector>
def doDeletePrinter():
  parent, printerIds, cd = _getPrinterEntity()
  # max 50 per API call
  batch_size = 50
  for chunk in range(0, len(printerIds), batch_size):
    body = {'printerIds': printerIds[chunk:chunk + batch_size]}
    result = callGAPI(cd.customers().chrome().printers(), 'batchDeletePrinters',
                      parent=parent, body=body)
    for printerId in result.get('printerIds', []):
      entityActionPerformed([Ent.PRINTER, printerId])
    for failure in result.get('failedPrinters', []):
      if 'printerIds' in failure:
        entityActionFailedWarning([Ent.PRINTER, failure['printerIds']], failure.get('errorMessage', 'Unknown printer'))
      else:
        entityActionFailedWarning([Ent.PRINTER, failure['printer']['id']], failure['errorMessage'])

# gam info printer <PrinterID>
#	[fields <PrinterFieldNameList>] [formatjson]
def doInfoPrinter():
  name, printerId, cd = _getPrinterID()
  FJQC = FormatJSONQuoteChar()
  fieldsList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if getFieldsList(myarg, PRINTER_FIELDS_CHOICE_MAP, fieldsList, initialField='id'):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  fields = getFieldsFromFieldsList(fieldsList)
  try:
    printer = callGAPI(cd.customers().chrome().printers(), 'get',
                       throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.PERMISSION_DENIED],
                       name=name, fields=fields)
    _showPrinter(cd, printer, FJQC)
  except GAPI.notFound:
    entityUnknownWarning(Ent.PRINTER, f'{printerId}')
  except (GAPI.invalid, GAPI.permissionDenied) as e:
    entityActionFailedWarning([Ent.DEVICE, f'{printerId}'], str(e))

ORGUNIT_ENTITIES_MAP = {
  'org': Cmd.ENTITY_OU,
  'organdchildren': Cmd.ENTITY_OU_AND_CHILDREN,
  'orgs': Cmd.ENTITY_OUS,
  'orgsandchildren': Cmd.ENTITY_OUS_AND_CHILDREN,
  'ou': Cmd.ENTITY_OU,
  'ouandchildren': Cmd.ENTITY_OU_AND_CHILDREN,
  'ous': Cmd.ENTITY_OUS,
  'ousandchildren': Cmd.ENTITY_OUS_AND_CHILDREN,
  'orgunit': Cmd.ENTITY_OU,
  'orgunitandchildren': Cmd.ENTITY_OU_AND_CHILDREN,
  'orgunits': Cmd.ENTITY_OUS,
  'orgunitsandchildren': Cmd.ENTITY_OUS_AND_CHILDREN,
  }

# gam show printers
#	[(ou <OrgUnitItem>)|(ou_and_children <OrgUnitItem>)|
#	 (ous <OrgUnitList>)|(ous_and_children <OrgUnitList>)]
#	[filter <String>] [showinherited [<Boolean>]
#	[fields <PrinterFieldNameList>] [formatjson]
# gam print printers [todrive <ToDriveAttribute>*]
#	[(ou <OrgUnitItem>)|(ou_and_children <OrgUnitItem>)|
#	 (ous <OrgUnitList>)|(ous_and_children <OrgUnitList>)]
#	[filter <String>] [showinherited [<Boolean>]
#	[fields <PrinterFieldNameList>] [[formatjson [quotechar <Character>]]
def doPrintShowPrinters():
  def _printPrinter(printer):
    if not _checkPrinterInheritance(cd, printer, orgUnitId, showInherited):
      return False
    row = flattenJSON(printer, timeObjects=PRINTER_TIME_OBJECTS)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'id': printer['id'],
                              'JSON': json.dumps(cleanJSON(printer, timeObjects=PRINTER_TIME_OBJECTS),
                                                 ensure_ascii=False, sort_keys=True)})

  cd = buildGAPIObject(API.DIRECTORY)
  parent = _getCustomersCustomerIdWithC()
  csvPF = CSVPrintFile(['id']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  fieldsList = []
  ous = [None]
  directlyInOU = True
  pfilter = None
  showInherited = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in ORGUNIT_ENTITIES_MAP:
      myarg = ORGUNIT_ENTITIES_MAP[myarg]
      ous = convertEntityToList(getString(Cmd.OB_ENTITY, minLen=0), shlexSplit=True, nonListEntityType=myarg in [Cmd.ENTITY_OU, Cmd.ENTITY_OU_AND_CHILDREN])
      directlyInOU = myarg in {Cmd.ENTITY_OU, Cmd.ENTITY_OUS}
    elif getFieldsList(myarg, PRINTER_FIELDS_CHOICE_MAP, fieldsList, initialField='id'):
      pass
    elif myarg == 'filter':
      pfilter = getString(Cmd.OB_STRING)
    elif myarg == 'showinherited':
      showInherited = getBoolean()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if fieldsList and (not directlyInOU or showInherited):
    fieldsList.append('orgUnitId')
  fields = getItemFieldsFromFieldsList('printers', fieldsList)
  for ou in ous:
    if ou is not None:
      ou = makeOrgUnitPathAbsolute(ou)
      _, orgUnitId = getOrgUnitId(cd, ou)
      ouList = [(ou, orgUnitId[3:])]
    else:
      ouList = [('/', None)]
    if not directlyInOU:
      try:
        orgs = callGAPI(cd.orgunits(), 'list',
                        throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                        customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=makeOrgUnitPathRelative(ou),
                        type='all', fields='organizationUnits(orgUnitPath,orgUnitId)')
      except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError, GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
        checkEntityDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, ou)
        return
      ouList.extend([(subou['orgUnitPath'], subou['orgUnitId'][3:]) for subou in sorted(orgs.get('organizationUnits', []), key=lambda k: k['orgUnitPath'])])
    for subou in ouList:
      orgUnitPath = subou[0]
      orgUnitId = subou[1]
      if orgUnitId is not None:
        oneQualifier = Msg.DIRECTLY_IN_THE.format(Ent.Singular(Ent.ORGANIZATIONAL_UNIT))
        printGettingAllEntityItemsForWhom(Ent.PRINTER, orgUnitPath, qualifier=oneQualifier, entityType=Ent.ORGANIZATIONAL_UNIT)
        pageMessage = getPageMessageForWhom()
      else:
        printGettingAllAccountEntities(Ent.PRINTER, pfilter)
        pageMessage = getPageMessage()
      try:
        printers = callGAPIpages(cd.customers().chrome().printers(), 'list', 'printers',
                                 pageMessage=pageMessage,
                                 throwReasons=[GAPI.INVALID, GAPI.PERMISSION_DENIED],
                                 parent=parent, orgUnitId=orgUnitId, filter=pfilter, fields=fields)
      except (GAPI.invalid, GAPI.permissionDenied) as e:
        entityActionFailedWarning([Ent.PRINTER, None], str(e))
        return
      if not csvPF:
        jcount = len(printers)
        if not FJQC.formatJSON:
          performActionNumItems(jcount, Ent.PRINTER)
        Ind.Increment()
        j = 0
        for printer in printers:
          j += 1
          _showPrinter(cd, printer, FJQC, orgUnitId, showInherited, j, jcount)
        Ind.Decrement()
      else:
        for printer in printers:
          _printPrinter(printer)
  if csvPF:
    csvPF.writeCSVfile('Printers')

# gam print printermodels [todrive <ToDriveAttribute>*]
#	[filter <String>]
#	[[formatjson [quotechar <Character>]]
# gam show printermodels
#	[filter <String>]
#	[formatjson]
def doPrintShowPrinterModels():
  def _showPrinterModel(model, FJQC, i, count):
    if FJQC.formatJSON:
      printLine(json.dumps(cleanJSON(model), ensure_ascii=False, sort_keys=True))
      return
    printEntity([Ent.PRINTER_MODEL, model['manufacturer']], i, count)
    Ind.Increment()
    for field in ['displayName', 'makeAndModel']:
      printKeyValueList([field, model[field]])
    Ind.Decrement()

  def _printPrinterModel(model):
    row = flattenJSON(model)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'JSON': json.dumps(cleanJSON(model),
                                                 ensure_ascii=False, sort_keys=True)})

  cd = buildGAPIObject(API.DIRECTORY)
  parent = _getCustomersCustomerIdWithC()
  csvPF = CSVPrintFile() if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  pfilter = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'filter':
      pfilter = getString(Cmd.OB_STRING)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if not FJQC.formatJSON:
    csvPF.SetTitles(['manufacturer', 'displayName', 'makeAndModel'])
    csvPF.SetSortAllTitles()
  printGettingAllAccountEntities(Ent.PRINTER_MODEL, pfilter)
  pageMessage = getPageMessage()
  try:
    models = callGAPIpages(cd.customers().chrome().printers(), 'listPrinterModels', 'printerModels',
                           throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                           pageMessage=pageMessage,
                           parent=parent, pageSize=10000, filter=pfilter)
  except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied) as e:
    entityActionFailedWarning([Ent.PRINTER_MODEL, None], str(e))
    return
  if not csvPF:
    jcount = len(models)
    if not FJQC.formatJSON:
      performActionNumItems(jcount, Ent.PRINTER_MODEL)
    Ind.Increment()
    j = 0
    for model in models:
      j += 1
      _showPrinterModel(model, FJQC, j, jcount)
    Ind.Decrement()
  else:
    for model in models:
      _printPrinterModel(model)
  if csvPF:
    csvPF.writeCSVfile('Printer Models')

CHROME_APPS_TIME_OBJECTS = {'firstPublishTime', 'latestPublishTime'}
CHROME_APPS_TYPE_CHOICES  = ['android', 'chrome', 'web']

# gam info chromeapp android|chrome|web <AppID> [formatjson]
def doInfoChromeApp():
  cm = buildGAPIObject(API.CHROMEMANAGEMENT_APPDETAILS)
  app_type = getChoice(CHROME_APPS_TYPE_CHOICES)
  app_id = getString(Cmd.OB_APP_ID)
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    FJQC.GetFormatJSON(myarg)
  if app_type == 'chrome':
    service = cm.customers().apps().chrome()
  elif app_type == 'android':
    service = cm.customers().apps().android()
  else:
    service = cm.customers().apps().web()
  try:
    appDetails = callGAPI(service, 'get',
                          throwReasons=[GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                          name=f'customers/{GC.Values[GC.CUSTOMER_ID]}/apps/{app_type}/{app_id}')
    if FJQC.formatJSON:
      printLine(json.dumps(cleanJSON(appDetails), ensure_ascii=False, sort_keys=True))
      return
    printEntity([Ent.CHROME_APP, app_id])
    Ind.Increment()
    showJSON(None, appDetails, timeObjects=CHROME_APPS_TIME_OBJECTS)
    Ind.Decrement()
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden):
    checkEntityAFDNEorAccessErrorExit(None, Ent.CHROME_APP, f'{app_type}/{app_id}')

def _getPrintChromeGetting(subou, pfilter, entityType):
  orgUnitPath = subou[0]
  orgUnitId = subou[1]
  query = pfilter
  if orgUnitId is not None:
    if query:
      query += ' AND '
    else:
      query = ''
    query += f'orgUnitPath={orgUnitPath}'
  printGettingAllAccountEntities(entityType, query)
  return (orgUnitPath, orgUnitId)

CHROME_APPS_ORDERBY_CHOICE_MAP = {
  'appname': 'app_name',
  'apptype': 'appType',
  'installtype': 'install_type',
  'numberofpermissions': 'number_of_permissions',
  'totalinstallcount': 'total_install_count',
  }
CHROME_APPS_TITLES = [
  'displayName',
  'browserDeviceCount', 'osUserCount',
  'appType', 'description',
  'appInstallType', 'appSource',
  'disabled', 'homepageUri', 'permissions'
  ]

# gam print chromeapps [todrive <ToDriveAttribute>*]
#	[(ou <OrgUnitItem>)|(ou_and_children <OrgUnitItem>)|
#	 (ous <OrgUnitList>)|(ous_and_children <OrgUnitList>)]
#	[filter <String>]
#	[orderby appname|apptype|installtype|numberofpermissions|totalinstallcount]
#	[formatjson [quotechar <Character>]] [delimiter <Character>]
# gam show chromeapps
#	[(ou <OrgUnitItem>)|(ou_and_children <OrgUnitItem>)|
#	 (ous <OrgUnitList>)|(ous_and_children <OrgUnitList>)]
#	[filter <String>]
#	[orderby appname|apptype|installtype|numberofpermissions|totalinstallcount]
#	[formatjson]
def doPrintShowChromeApps():
  def _printApp(app):
    if showOrgUnit:
      app['orgUnitPath'] = orgUnitPath
    row = flattenJSON(app, simpleLists=['permissions'], delimiter=delimiter)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'appId': app['appId'],
                              'JSON': json.dumps(cleanJSON(app), ensure_ascii=False, sort_keys=True)})

  def _showApp(app, i=0, count=0):
    if showOrgUnit:
      app['orgUnitPath'] = orgUnitPath
    if FJQC.formatJSON:
      printLine(json.dumps(cleanJSON(app), ensure_ascii=False, sort_keys=True))
      return
    printEntity([Ent.CHROME_APP, app['appId']], i, count)
    Ind.Increment()
    showJSON(None, app)
    Ind.Decrement()

  cd = buildGAPIObject(API.DIRECTORY)
  cm = buildGAPIObject(API.CHROMEMANAGEMENT)
  customerId = _getCustomersCustomerIdWithC()
  csvPF = CSVPrintFile(['appId']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  ous = [None]
  directlyInOU = True
  showOrgUnit = False
  orderBy = pfilter = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in ORGUNIT_ENTITIES_MAP:
      myarg = ORGUNIT_ENTITIES_MAP[myarg]
      ous = convertEntityToList(getString(Cmd.OB_ENTITY, minLen=0), shlexSplit=True, nonListEntityType=myarg in [Cmd.ENTITY_OU, Cmd.ENTITY_OU_AND_CHILDREN])
      directlyInOU = myarg in {Cmd.ENTITY_OU, Cmd.ENTITY_OUS}
    elif myarg == 'filter':
      pfilter = getString(Cmd.OB_STRING)
    elif myarg == 'orderby':
      orderBy = getChoice(CHROME_APPS_ORDERBY_CHOICE_MAP, mapChoice=True)
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if ous[0] is not None:
    showOrgUnit = True
  if csvPF and not FJQC.formatJSON:
    csvPF.AddTitles(CHROME_APPS_TITLES)
    if showOrgUnit:
      csvPF.AddTitle('orgUnitPath')
  for ou in ous:
    if ou is not None:
      ou = makeOrgUnitPathAbsolute(ou)
      _, orgUnitId = getOrgUnitId(cd, ou)
      ouList = [(ou, orgUnitId[3:])]
    else:
      ouList = [('/', None)]
    if not directlyInOU:
      try:
        orgs = callGAPI(cd.orgunits(), 'list',
                        throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                        customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=makeOrgUnitPathRelative(ou),
                        type='all', fields='organizationUnits(orgUnitPath,orgUnitId)')
      except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError, GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
        checkEntityDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, ou)
        return
      ouList.extend([(subou['orgUnitPath'], subou['orgUnitId'][3:]) for subou in sorted(orgs.get('organizationUnits', []), key=lambda k: k['orgUnitPath'])])
    for subou in ouList:
      orgUnitPath, orgUnitId = _getPrintChromeGetting(subou, pfilter, Ent.CHROME_APP)
      pageMessage = getPageMessage()
      try:
        apps = callGAPIpages(cm.customers().reports(), 'countInstalledApps', 'installedApps',
                             throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                             retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                             pageMessage=pageMessage,
                             customer=customerId, orgUnitId=orgUnitId, filter=pfilter, orderBy=orderBy)
      except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.CHROME_APP, None], str(e))
        return
      jcount = len(apps)
      if not csvPF:
        if not FJQC.formatJSON:
          entityPerformActionNumItems([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], jcount, Ent.CHROME_APP)
        Ind.Increment()
        j = 0
        for app in apps:
          j += 1
          _showApp(app, j, jcount)
        Ind.Decrement()
      else:
        for app in apps:
          _printApp(app)
  if csvPF:
    csvPF.writeCSVfile('Chrome Installed Applications')

CHROME_APP_DEVICES_APPTYPE_CHOICE_MAP = {
  'extension': 'EXTENSION',
  'app': 'APP',
  'theme': 'THEME',
  'hostedapp': 'HOSTED_APP',
  'androidapp': 'ANDROID_APP',
  }
CHROME_APP_DEVICES_ORDERBY_CHOICE_MAP = {
  'deviceid': 'deviceId',
  'machine': 'machine',
  }
CHROME_APP_DEVICES_TITLES = ['appType', 'deviceId', 'machine']

# gam print chromeappdevices [todrive <ToDriveAttribute>*]
#	appid <AppID> apptype extension|app|theme|hostedapp|androidapp
#	[(ou <OrgUnitItem>)|(ou_and_children <OrgUnitItem>)|
#	 (ous <OrgUnitList>)|(ous_and_children <OrgUnitList>)]
#	[start <Date>] [end <Date>]
#	[orderby deviceid|machine]
#	[formatjson [quotechar <Character>]]
# gam show chromeappdevices
#	appid <AppID> apptype extension|app|theme|hostedapp|androidapp
#	[(ou <OrgUnitItem>)|(ou_and_children <OrgUnitItem>)|
#	 (ous <OrgUnitList>)|(ous_and_children <OrgUnitList>)]
#	[start <Date>] [end <Date>]
#	[orderby deviceid|machine]
#	[formatjson]
def doPrintShowChromeAppDevices():
  def _printDevice(device):
    device['appId'] = appId
    device['appType'] = appType
    if showOrgUnit:
      device['orgUnitPath'] = orgUnitPath
    row = flattenJSON(device)
    if not FJQC.formatJSON:
      csvPF.WriteRow(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'appId': device['appId'],
                              'JSON': json.dumps(cleanJSON(device), ensure_ascii=False, sort_keys=True)})

  def _showDevice(device, i=0, count=0):
    device['appId'] = appId
    device['appType'] = appType
    if showOrgUnit:
      device['orgUnitPath'] = orgUnitPath
    if FJQC.formatJSON:
      printLine(json.dumps(cleanJSON(device), ensure_ascii=False, sort_keys=True))
      return
    printEntity([Ent.CHROME_APP, device['appId']], i, count)
    Ind.Increment()
    showJSON(None, device)
    Ind.Decrement()

  cd = buildGAPIObject(API.DIRECTORY)
  cm = buildGAPIObject(API.CHROMEMANAGEMENT)
  customerId = _getCustomersCustomerIdWithC()
  csvPF = CSVPrintFile(['appId']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  ous = [None]
  directlyInOU = True
  showOrgUnit = False
  appId = appType = orderBy = None
  startDate = endDate = pfilter = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in ORGUNIT_ENTITIES_MAP:
      myarg = ORGUNIT_ENTITIES_MAP[myarg]
      ous = convertEntityToList(getString(Cmd.OB_ENTITY, minLen=0), shlexSplit=True, nonListEntityType=myarg in [Cmd.ENTITY_OU, Cmd.ENTITY_OU_AND_CHILDREN])
      directlyInOU = myarg in {Cmd.ENTITY_OU, Cmd.ENTITY_OUS}
    elif myarg == 'appid':
      appId = getString(Cmd.OB_APP_ID)
    elif myarg == 'apptype':
      appType = getChoice(CHROME_APP_DEVICES_APPTYPE_CHOICE_MAP, mapChoice=True)
    elif myarg in CROS_START_ARGUMENTS:
      startDate, _ = _getFilterDateTime()
      startDate = startDate.strftime(YYYYMMDD_FORMAT)
    elif myarg in CROS_END_ARGUMENTS:
      endDate, _ = _getFilterDateTime()
      endDate = endDate.strftime(YYYYMMDD_FORMAT)
    elif myarg == 'orderby':
      orderBy = getChoice(CHROME_APP_DEVICES_ORDERBY_CHOICE_MAP, mapChoice=True)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if appId is None:
    missingArgumentExit('appid')
  if appType is None:
    missingArgumentExit('apptype')
  if endDate:
    pfilter = f'last_active_date<={endDate}'
  if startDate:
    if pfilter:
      pfilter += ' AND '
    else:
      pfilter = ''
    pfilter += f'last_active_date>={startDate}'
  if ous[0] is not None:
    showOrgUnit = True
  if csvPF and not FJQC.formatJSON:
    csvPF.AddTitles(CHROME_APP_DEVICES_TITLES)
    if showOrgUnit:
      csvPF.AddTitle('orgUnitPath')
  for ou in ous:
    if ou is not None:
      ou = makeOrgUnitPathAbsolute(ou)
      _, orgUnitId = getOrgUnitId(cd, ou)
      ouList = [(ou, orgUnitId[3:])]
    else:
      ouList = [('/', None)]
    if not directlyInOU:
      try:
        orgs = callGAPI(cd.orgunits(), 'list',
                        throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                        customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=makeOrgUnitPathRelative(ou),
                        type='all', fields='organizationUnits(orgUnitPath,orgUnitId)')
      except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError, GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
        checkEntityDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, ou)
        return
      ouList.extend([(subou['orgUnitPath'], subou['orgUnitId'][3:]) for subou in sorted(orgs.get('organizationUnits', []), key=lambda k: k['orgUnitPath'])])
    for subou in ouList:
      orgUnitPath, orgUnitId = _getPrintChromeGetting(subou, pfilter, Ent.CHROME_APP_DEVICE)
      pageMessage = getPageMessage()
      try:
        devices = callGAPIpages(cm.customers().reports(), 'findInstalledAppDevices', 'devices',
                                throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                                retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                pageMessage=pageMessage,
                                appId=appId, appType=appType,
                                customer=customerId, orgUnitId=orgUnitId, filter=pfilter, orderBy=orderBy)
      except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.CHROME_APP_DEVICE, None], str(e))
        return
      jcount = len(devices)
      if not csvPF:
        if not FJQC.formatJSON:
          entityPerformActionNumItems([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], jcount, Ent.CHROME_APP_DEVICE)
        Ind.Increment()
        j = 0
        for device in devices:
          j += 1
          _showDevice(device, j, jcount)
        Ind.Decrement()
      else:
        for device in devices:
          _printDevice(device)
  if csvPF:
    csvPF.writeCSVfile('Chrome Installed Application Devices')


CHROME_AUE_TITLES = ['aueMonth', 'aueYear', 'expired']

# gam print chromeaues [todrive <ToDriveAttribute>*]
#	[(ou <OrgUnitItem>)|(ou_and_children <OrgUnitItem>)|
#	 (ous <OrgUnitList>)|(ous_and_children <OrgUnitList>)]
#	[minauedate <Date>] [maxauedate <Date>]
#	[formatjson [quotechar <Character>]]
# gam show chromeaues
#	[(ou <OrgUnitItem>)|(ou_and_children <OrgUnitItem>)|
#	 (ous <OrgUnitList>)|(ous_and_children <OrgUnitList>)]
#	[minauedate <Date>] [maxauedate <Date>]
#	[formatjson]
def doPrintShowChromeAues():
  def _printAue(aue):
    if showOrgUnit:
      aue['orgUnitPath'] = orgUnitPath
    row = flattenJSON(aue)
    if not FJQC.formatJSON:
      csvPF.WriteRow(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'model': aue['model'], 'count': aue['count'],
                              'JSON': json.dumps(cleanJSON(aue), ensure_ascii=False, sort_keys=True)})

  def _showAue(aue, i=0, count=0):
    if showOrgUnit:
      aue['orgUnitPath'] = orgUnitPath
    if FJQC.formatJSON:
      printLine(json.dumps(cleanJSON(aue), ensure_ascii=False, sort_keys=True))
      return
    printEntity([Ent.CHROME_MODEL, aue['model']], i, count)
    Ind.Increment()
    showJSON(None, aue)
    Ind.Decrement()

  cd = buildGAPIObject(API.DIRECTORY)
  cm = buildGAPIObject(API.CHROMEMANAGEMENT)
  customerId = _getCustomersCustomerIdWithC()
  csvPF = CSVPrintFile(['model', 'count']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  ous = [None]
  directlyInOU = True
  showOrgUnit = False
  minAueDate = maxAueDate = pfilter = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in ORGUNIT_ENTITIES_MAP:
      myarg = ORGUNIT_ENTITIES_MAP[myarg]
      ous = convertEntityToList(getString(Cmd.OB_ENTITY, minLen=0), shlexSplit=True, nonListEntityType=myarg in [Cmd.ENTITY_OU, Cmd.ENTITY_OU_AND_CHILDREN])
      directlyInOU = myarg in {Cmd.ENTITY_OU, Cmd.ENTITY_OUS}
    elif myarg == 'minauedate':
      minAueDate, _ = _getFilterDateTime()
      minAueDate = minAueDate.strftime(YYYYMMDD_FORMAT)
    elif myarg == 'maxauedate':
      maxAueDate, _ = _getFilterDateTime()
      maxAueDate = maxAueDate.strftime(YYYYMMDD_FORMAT)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if minAueDate:
    pfilter = f'minAueDate={minAueDate}'
  if maxAueDate:
    if pfilter:
      pfilter += ' AND '
    else:
      pfilter = ''
    pfilter += f'maxAueDate>={maxAueDate}'
  if ous[0] is not None:
    showOrgUnit = True
  if csvPF and not FJQC.formatJSON:
    csvPF.AddTitles(CHROME_AUE_TITLES)
    if showOrgUnit:
      csvPF.AddTitle('orgUnitPath')
  for ou in ous:
    if ou is not None:
      ou = makeOrgUnitPathAbsolute(ou)
      _, orgUnitId = getOrgUnitId(cd, ou)
      ouList = [(ou, orgUnitId[3:])]
    else:
      ouList = [('/', None)]
    if not directlyInOU:
      try:
        orgs = callGAPI(cd.orgunits(), 'list',
                        throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                        customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=makeOrgUnitPathRelative(ou),
                        type='all', fields='organizationUnits(orgUnitPath,orgUnitId)')
      except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError, GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
        checkEntityDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, ou)
        return
      ouList.extend([(subou['orgUnitPath'], subou['orgUnitId'][3:]) for subou in sorted(orgs.get('organizationUnits', []), key=lambda k: k['orgUnitPath'])])
    for subou in ouList:
      orgUnitPath, orgUnitId = _getPrintChromeGetting(subou, pfilter, Ent.CHROME_MODEL)
      try:
        aues = callGAPI(cm.customers().reports(), 'countChromeDevicesReachingAutoExpirationDate',
                        throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                        retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                        customer=customerId, orgUnitId=orgUnitId, minAueDate=minAueDate, maxAueDate=maxAueDate).get('deviceAueCountReports', [])
      except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.CHROME_MODEL, None], str(e))
        return
      jcount = len(aues)
      if not csvPF:
        Ind.Increment()
        j = 0
        for aue in sorted(aues, key=lambda k: k.get('model', UNKNOWN)):
          j += 1
          _showAue(aue, j, jcount)
        Ind.Decrement()
      else:
        for aue in sorted(aues, key=lambda k: k.get('model', UNKNOWN)):
          _printAue(aue)
  if csvPF:
    csvPF.writeCSVfile('Chrome AUEs')

CHROME_NEEDSATTN_TITLES = ['noRecentPolicySyncCount', 'noRecentUserActivityCount', 'pendingUpdate',
                          'osVersionNotCompliantCount', 'unsupportedPolicyCount']

# gam print chromeneedsattn [todrive <ToDriveAttribute>*]
#	[(ou <OrgUnitItem>)|(ou_and_children <OrgUnitItem>)|
#	 (ous <OrgUnitList>)|(ous_and_children <OrgUnitList>)]
#	[formatjson [quotechar <Character>]]
# gam show chromeneedsattn
#	[(ou <OrgUnitItem>)|(ou_and_children <OrgUnitItem>)|
#	 (ous <OrgUnitList>)|(ous_and_children <OrgUnitList>)]
#	[formatjson]
def doPrintShowChromeNeedsAttn():
  def _printNeedsAttn(needsattn):
    if showOrgUnit:
      needsattn['orgUnitPath'] = orgUnitPath
    row = flattenJSON(needsattn)
    if not FJQC.formatJSON:
      csvPF.WriteRow(row)
    elif csvPF.CheckRowTitles(row):
      row = {'orgUnitPath': orgUnitPath} if showOrgUnit else {}
      row['JSON'] = json.dumps(cleanJSON(needsattn), ensure_ascii=False, sort_keys=True)
      csvPF.WriteRowNoFilter(row)

  def _showNeedsAttn(needsattn, i=0, count=0):
    if showOrgUnit:
      needsattn['orgUnitPath'] = orgUnitPath
    if FJQC.formatJSON:
      printLine(json.dumps(cleanJSON(needsattn), ensure_ascii=False, sort_keys=True))
      return
    if showOrgUnit:
      printEntity([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], i, count)
    Ind.Increment()
    showJSON(None, needsattn)
    Ind.Decrement()

  cd = buildGAPIObject(API.DIRECTORY)
  cm = buildGAPIObject(API.CHROMEMANAGEMENT)
  customerId = _getCustomersCustomerIdWithC()
  csvPF = CSVPrintFile([]) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  ous = [None]
  directlyInOU = True
  showOrgUnit = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in ORGUNIT_ENTITIES_MAP:
      myarg = ORGUNIT_ENTITIES_MAP[myarg]
      ous = convertEntityToList(getString(Cmd.OB_ENTITY, minLen=0), shlexSplit=True, nonListEntityType=myarg in [Cmd.ENTITY_OU, Cmd.ENTITY_OU_AND_CHILDREN])
      directlyInOU = myarg in {Cmd.ENTITY_OU, Cmd.ENTITY_OUS}
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if ous[0] is not None:
    showOrgUnit = True
  if csvPF:
    if not FJQC.formatJSON:
      csvPF.AddTitles(CHROME_NEEDSATTN_TITLES)
      if showOrgUnit:
        csvPF.AddTitle('orgUnitPath')
    elif showOrgUnit:
      csvPF.SetJSONTitles(['orgUnitPath', 'JSON'])
  for ou in ous:
    if ou is not None:
      ou = makeOrgUnitPathAbsolute(ou)
      _, orgUnitId = getOrgUnitId(cd, ou)
      ouList = [(ou, orgUnitId[3:])]
    else:
      ouList = [('/', None)]
    if not directlyInOU:
      try:
        orgs = callGAPI(cd.orgunits(), 'list',
                        throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                        customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=makeOrgUnitPathRelative(ou),
                        type='all', fields='organizationUnits(orgUnitPath,orgUnitId)')
      except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError, GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
        checkEntityDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, ou)
        return
      ouList.extend([(subou['orgUnitPath'], subou['orgUnitId'][3:]) for subou in sorted(orgs.get('organizationUnits', []), key=lambda k: k['orgUnitPath'])])
    count = len(ouList)
    i = 0
    for subou in ouList:
      i += 1
      orgUnitPath, orgUnitId = _getPrintChromeGetting(subou, None, Ent.CHROME_DEVICE)
      try:
        result = callGAPI(cm.customers().reports(), 'countChromeDevicesThatNeedAttention',
                          throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                          retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                          customer=customerId, orgUnitId=orgUnitId, readMask=','.join(CHROME_NEEDSATTN_TITLES))
        for field in CHROME_NEEDSATTN_TITLES:
          result.setdefault(field, 0)
      except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.ORGANIZATIONAL_UNIT, orgUnitId], str(e))
        return
      if not csvPF:
        _showNeedsAttn(result, i, count)
      else:
        _printNeedsAttn(result)
  if csvPF:
    csvPF.writeCSVfile('Chrome Devices Needing Attention')

CHROME_VERSIONS_TITLES = ['channel', 'system', 'deviceOsVersion']

# gam print chromeversions [todrive <ToDriveAttribute>*]
#	[(ou <OrgUnitItem>)|(ou_and_children <OrgUnitItem>)|
#	 (ous <OrgUnitList>)|(ous_and_children <OrgUnitList>)]
#	[start <Date>] [end <Date>]
#	[recentfirst [<Boolean>]]
#	[formatjson [quotechar <Character>]]
# gam show chromeversions
#	[(ou <OrgUnitItem>)|(ou_and_children <OrgUnitItem>)|
#	 (ous <OrgUnitList>)|(ous_and_children <OrgUnitList>)]
#	[start <Date>] [end <Date>]
#	[recentfirst [<Boolean>]]
#	[formatjson]
def doPrintShowChromeVersions():
  def _getVersionKey(v):
    if 'version' not in v:
      return (0, 0, 0, 0)
    k = v['version'].split('.')
    for i, x in enumerate(k):
      k[i] = int(x)
    return tuple(k)

  def _printVersion(version):
    if showOrgUnit:
      version['orgUnitPath'] = orgUnitPath
    if 'version' not in version:
      version['version'] = UNKNOWN
    row = flattenJSON(version)
    if not FJQC.formatJSON:
      csvPF.WriteRow(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'version': version['version'], 'count': version['count'],
                              'JSON': json.dumps(cleanJSON(version), ensure_ascii=False, sort_keys=True)})

  def _showVersion(version, i=0, count=0):
    if showOrgUnit:
      version['orgUnitPath'] = orgUnitPath
    if 'version' not in version:
      version['version'] = UNKNOWN
    if FJQC.formatJSON:
      printLine(json.dumps(cleanJSON(version), ensure_ascii=False, sort_keys=True))
      return
    printEntity([Ent.CHROME_VERSION, version['version']], i, count)
    Ind.Increment()
    showJSON(None, version)
    Ind.Decrement()

  cd = buildGAPIObject(API.DIRECTORY)
  cm = buildGAPIObject(API.CHROMEMANAGEMENT)
  customerId = _getCustomersCustomerIdWithC()
  csvPF = CSVPrintFile(['version', 'count']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  ous = [None]
  directlyInOU = True
  reverse = showOrgUnit = False
  startDate = endDate = pfilter = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in ORGUNIT_ENTITIES_MAP:
      myarg = ORGUNIT_ENTITIES_MAP[myarg]
      ous = convertEntityToList(getString(Cmd.OB_ENTITY, minLen=0), shlexSplit=True, nonListEntityType=myarg in [Cmd.ENTITY_OU, Cmd.ENTITY_OU_AND_CHILDREN])
      directlyInOU = myarg in {Cmd.ENTITY_OU, Cmd.ENTITY_OUS}
    elif myarg in CROS_START_ARGUMENTS:
      startDate, _ = _getFilterDateTime()
      startDate = startDate.strftime(YYYYMMDD_FORMAT)
    elif myarg in CROS_END_ARGUMENTS:
      endDate, _ = _getFilterDateTime()
      endDate = endDate.strftime(YYYYMMDD_FORMAT)
    elif myarg == 'recentfirst':
      reverse = getBoolean()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if endDate:
    pfilter = f'last_active_date<={endDate}'
  if startDate:
    if pfilter:
      pfilter += ' AND '
    else:
      pfilter = ''
    pfilter += f'last_active_date>={startDate}'
  if ous[0] is not None:
    showOrgUnit = True
  if csvPF and not FJQC.formatJSON:
    csvPF.AddTitles(CHROME_VERSIONS_TITLES)
    if showOrgUnit:
      csvPF.AddTitle('orgUnitPath')
  for ou in ous:
    if ou is not None:
      ou = makeOrgUnitPathAbsolute(ou)
      _, orgUnitId = getOrgUnitId(cd, ou)
      ouList = [(ou, orgUnitId[3:])]
    else:
      ouList = [('/', None)]
    if not directlyInOU:
      try:
        orgs = callGAPI(cd.orgunits(), 'list',
                        throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
                        customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=makeOrgUnitPathRelative(ou),
                        type='all', fields='organizationUnits(orgUnitPath,orgUnitId)')
      except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError, GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
        checkEntityDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, ou)
        return
      ouList.extend([(subou['orgUnitPath'], subou['orgUnitId'][3:]) for subou in sorted(orgs.get('organizationUnits', []), key=lambda k: k['orgUnitPath'])])
    for subou in ouList:
      orgUnitPath, orgUnitId = _getPrintChromeGetting(subou, pfilter, Ent.CHROME_VERSION)
      pageMessage = getPageMessage()
      try:
        versions = callGAPIpages(cm.customers().reports(), 'countChromeVersions', 'browserVersions',
                                 throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 pageMessage=pageMessage,
                                 customer=customerId, orgUnitId=orgUnitId, filter=pfilter)
      except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.CHROME_VERSION, None], str(e))
        return
      jcount = len(versions)
      if not csvPF:
        if not FJQC.formatJSON:
          entityPerformActionNumItems([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], jcount, Ent.CHROME_VERSION)
        Ind.Increment()
        j = 0
        for version in sorted(versions, key=_getVersionKey, reverse=reverse):
          j += 1
          _showVersion(version, j, jcount)
        Ind.Decrement()
      else:
        for version in sorted(versions, key=_getVersionKey, reverse=reverse):
          _printVersion(version)
  if csvPF:
    csvPF.writeCSVfile('Chrome Versions')

def getPlatformChannelMap(cv, entityType):
  if cv is None:
    cv = buildGAPIObjectNoAuthentication(API.CHROMEVERSIONHISTORY)
  if entityType == Ent.CHROME_PLATFORM:
    svc = cv.platforms()
    parent = 'chrome'
    field = 'platformType'
  else: # elif entityType == Ent.CHROME_CHANNEL:
    svc = cv.platforms().channels()
    parent = 'chrome/platforms/all'
    field = 'channelType'
  try:
    pcitems = callGAPIpages(svc, 'list', CHROME_VERSIONHISTORY_ITEMS[entityType],
                            throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                            parent=parent, fields=f'nextPageToken,{CHROME_VERSIONHISTORY_ITEMS[entityType]}')
    pcMap = {'all': 'all'}
    for pcitem in pcitems:
      pcType = pcitem[field].lower()
      pcMap[pcType.replace('_', '')] = pcType
  except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied) as e:
    entityActionFailedWarning([entityType, None], str(e))
    pcMap = {}
  return (cv, pcMap)

def getRelativeMilestone(cv, channel, minus):
  ''' takes a channel and minus_versions like stable and -1. returns current given  milestone number '''
  if cv is None:
    cv = buildGAPIObjectNoAuthentication(API.CHROMEVERSIONHISTORY)
  try:
    releases = callGAPIpages(cv.platforms().channels().versions().releases(), 'list', 'releases',
                             throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                             parent=f'chrome/platforms/all/channels/{channel}/versions/all',
                             fields='nextPageToken,releases(version)')
  except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied) as e:
    entityActionFailedWarning([Ent.CHROME_RELEASE, None], str(e))
    return (cv, False, str(e))
  milestones = []
  # Note that milestones are usually sequential but some numbers
  # may be skipped. For example, there was no Chrome 82 stable.
  # Thus we need to do more than find the latest version and subtract.
  for release in releases:
    if 'version' in release:
      milestone = int(release['version'].split('.')[0])
      if milestone not in milestones:
        milestones.append(int(milestone))
  milestones.sort(reverse=True)
  try:
    return (cv, True, str(milestones[minus]))
  except IndexError:
    return (cv, False, f'{channel}-{0}:{channel}-{len(milestones)-1}')

CHROME_HISTORY_ENTITY_CHOICE_MAP = {
  'platforms': Ent.CHROME_PLATFORM,
  'channels': Ent.CHROME_CHANNEL,
  'versions': Ent.CHROME_VERSION,
  'releases': Ent.CHROME_RELEASE,
  }
CHROME_VERSIONHISTORY_ORDERBY_CHOICE_MAP = {
  Ent.CHROME_VERSION:  {
    'channel': 'channel',
    'name': 'name',
    'platform': 'platform',
    'version': 'version'
    },
  Ent.CHROME_RELEASE: {
    'channel': 'channel',
    'endtime': 'endtime',
    'fraction': 'fraction',
    'name': 'name',
    'platform': 'platform',
    'starttime': 'starttime',
    'version': 'version'
    }
  }
CHROME_VERSIONHISTORY_TITLES = {
  Ent.CHROME_PLATFORM: ['platform'],
  Ent.CHROME_CHANNEL:  ['channel', 'platform'],
  Ent.CHROME_VERSION: ['version', 'channel', 'platform',
                       'major_version', 'minor_version', 'build', 'patch'],
  Ent.CHROME_RELEASE: ['version', 'channel', 'platform',
                       'major_version', 'minor_version', 'build', 'patch',
                       'fraction', 'fractionGroup', 'serving.startTime',
                       'serving.endTime', 'pinnable']

  }
CHROME_VERSIONHISTORY_ITEMS = {
  Ent.CHROME_PLATFORM: 'platforms',
  Ent.CHROME_CHANNEL: 'channels',
  Ent.CHROME_VERSION: 'versions',
  Ent.CHROME_RELEASE: 'releases'
  }
CHROME_VERSIONHISTORY_TIMEOBJECTS = {
  Ent.CHROME_PLATFORM: None,
  Ent.CHROME_CHANNEL:  None,
  Ent.CHROME_VERSION: None,
  Ent.CHROME_RELEASE: ['startTime', 'endTime']
  }

# gam print chromehistory platforms [todrive <ToDriveAttribute>*]
#	[formatjson [quotechar <Character>]]
# gam show chromehistory platforms
#	[formatjson]

# gam print chromehistory channels [todrive <ToDriveAttribute>*]
#	[platform <ChromePlatformType>]
#	[formatjson [quotechar <Character>]]
# gam show chromehistory channels
#	[platform <ChromePlatformType>]
#	[formatjson]

# gam print chromehistory versions [todrive <ToDriveAttribute>*]
#	[platform <ChromePlatformType>] [channel <ChromeChannelType>]
#	(orderby <ChromeVersionsOrderByFieldName> [ascending|descending])*
#	[filter <String>]
#	[formatjson [quotechar <Character>]]
# gam show chromehistory versions
#	[platform <ChromePlatformType>] [channel <ChromeChannelType>]
#	(orderby <ChromeVersionsOrderByFieldName> [ascending|descending])*
#	[filter <String>]
#	[formatjson]

# gam print chromehistory releases [todrive <ToDriveAttribute>*]
#	[platform <ChromePlatformType>] [channel <ChromeChannelType>] [version <String>]
#	(orderby <ChromeReleasessOrderByFieldName> [ascending|descending])*
#	[filter <String>]
#	[formatjson [quotechar <Character>]]
# gam show chromehistory releases
#	[platform <ChromePlatformType>] [channel <ChromeChannelType>] [version <String>]
#	(orderby <ChromeReleasessOrderByFieldName> [ascending|descending])*
#	[filter <String>]
#	[formatjson]

def doPrintShowChromeHistory():
  def addDetailFields(citem):
    for key in list(citem):
      if key.endswith('Type'):
        citem[key[:-4]] = citem.pop(key)
    if 'channel' in citem:
      citem['channel'] = citem['channel'].lower()
    else:
      channel_match = re.search(r"\/channels\/([^/]*)", citem['name'])
      if channel_match:
        try:
          citem['channel'] = channel_match.group(1)
        except IndexError:
          pass
    if 'platform' in citem:
      citem['platform'] = citem['platform'].lower()
    else:
      platform_match = re.search(r"\/platforms\/([^/]*)", citem['name'])
      if platform_match:
        try:
          citem['platform'] = platform_match.group(1)
        except IndexError:
          pass
    if citem.get('version', '').count('.') == 3:
      citem['major_version'], citem['minor_version'], citem['build'], citem['patch'] = citem['version'].split('.')
    citem.pop('name')

  def _printItem(citem):
    addDetailFields(citem)
    row = flattenJSON(citem, timeObjects=CHROME_VERSIONHISTORY_TIMEOBJECTS[entityType])
    if not FJQC.formatJSON:
      csvPF.WriteRow(row)
    elif csvPF.CheckRowTitles(row):
      keyField = CHROME_VERSIONHISTORY_TITLES[entityType][0]
      csvPF.WriteRowNoFilter({keyField: citem[keyField],
                              'JSON': json.dumps(cleanJSON(citem), ensure_ascii=False, sort_keys=True)})

  def _showItem(citem, i=0, count=0):
    addDetailFields(citem)
    keyField = CHROME_VERSIONHISTORY_TITLES[entityType][0]
    if FJQC.formatJSON:
      printLine(json.dumps(cleanJSON(citem), ensure_ascii=False, sort_keys=True))
      return
    printEntity([entityType, citem[keyField]], i, count)
    Ind.Increment()
    citem = flattenJSON(citem, timeObjects=CHROME_VERSIONHISTORY_TIMEOBJECTS[entityType])
    for field in CHROME_VERSIONHISTORY_TITLES[entityType]:
      if field in citem:
        printKeyValueList([field, citem[field]])
    Ind.Decrement()

  cv = buildGAPIObjectNoAuthentication(API.CHROMEVERSIONHISTORY)
  entityType = getChoice(CHROME_HISTORY_ENTITY_CHOICE_MAP, mapChoice=True)
  csvPF = CSVPrintFile(CHROME_VERSIONHISTORY_TITLES[entityType][0:1]) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  platformMap = None
  channelMap = None
  cplatform = 'all'
  channel = 'all'
  version = 'all'
  kwargs = {}
  if entityType in {Ent.CHROME_VERSION, Ent.CHROME_RELEASE}:
    OBY = OrderBy(CHROME_VERSIONHISTORY_ORDERBY_CHOICE_MAP[entityType])
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif entityType != Ent.CHROME_PLATFORM and myarg == 'platform':
      if platformMap is None:
        _, platformMap = getPlatformChannelMap(cv, Ent.CHROME_PLATFORM)
      cplatform = getChoice(platformMap, mapChoice=True)
    elif entityType in {Ent.CHROME_VERSION, Ent.CHROME_RELEASE} and myarg == 'channel':
      if channelMap is None:
        _, channelMap = getPlatformChannelMap(cv, Ent.CHROME_CHANNEL)
      channel = getChoice(channelMap, mapChoice=True)
    elif entityType == Ent.CHROME_RELEASE and myarg == 'version':
      version = getString(Cmd.OB_CHROME_VERSION)
    elif entityType in {Ent.CHROME_VERSION, Ent.CHROME_RELEASE} and myarg == 'orderby':
      OBY.GetChoice()
    elif entityType in {Ent.CHROME_VERSION, Ent.CHROME_RELEASE} and myarg == 'filter':
      kwargs['filter'] = getString(Cmd.OB_STRING)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if csvPF and not FJQC.formatJSON:
    csvPF.AddTitles(CHROME_VERSIONHISTORY_TITLES[entityType][1:])
  if entityType == Ent.CHROME_PLATFORM:
    svc = cv.platforms()
    parent = 'chrome'
  elif entityType == Ent.CHROME_CHANNEL:
    svc = cv.platforms().channels()
    parent = f'chrome/platforms/{cplatform}'
  elif entityType == Ent.CHROME_VERSION:
    svc = cv.platforms().channels().versions()
    parent = f'chrome/platforms/{cplatform}/channels/{channel}'
    kwargs['orderBy'] = OBY.orderBy
  else: #elif entityType == Ent.CHROME_RELEASE
    svc = cv.platforms().channels().versions().releases()
    parent = f'chrome/platforms/{cplatform}/channels/{channel}/versions/{version}'
    kwargs['orderBy'] = OBY.orderBy
  printGettingAllAccountEntities(entityType)
  pageMessage = getPageMessage()
  try:
    citems = callGAPIpages(svc, 'list', CHROME_VERSIONHISTORY_ITEMS[entityType],
                           throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                           pageMessage=pageMessage,
                           parent=parent, fields=f'nextPageToken,{CHROME_VERSIONHISTORY_ITEMS[entityType]}',
                           **kwargs)
  except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied) as e:
    entityActionFailedWarning([entityType, None], str(e))
    return
  if not csvPF:
    jcount = len(citems)
    if not FJQC.formatJSON:
      performActionNumItems(jcount, entityType)
    j = 0
    for citem in citems:
      j += 1
      _showItem(citem, j, jcount)
  else:
    for citem in citems:
      _printItem(citem)
  if csvPF:
    csvPF.writeCSVfile(Ent.Plural(entityType))

# gam print chromesnvalidity [todrive <ToDriveAttribute>*]
#	cros_sn <SerialNumberEntity> [listlimit <Number>]
#	[delimiter <Character>]
def doPrintChromeSnValidity():
  cd = buildGAPIObject(API.DIRECTORY)
  csvPF = CSVPrintFile(['serialNumber', 'exactMatches', 'exactMatchDeviceIds', 'prefixMatches', 'prefixMatchDeviceIds'])
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  serialNumberList = []
  listLimit = 0
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'crossn':
      serialNumberList = getEntityList(Cmd.OB_STRING_LIST)
    elif myarg == 'listlimit':
      listLimit = getInteger(minVal=0)
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    else:
      unknownArgumentExit()
  if not serialNumberList:
    actionNotPerformedNumItemsWarning(0, Ent.CROS_SERIAL_NUMBER, Msg.NO_SERIAL_NUMBERS_SPECIFIED)
    return
  for serialNumber in serialNumberList:
    query = f'id:{serialNumber}'
    printGettingAllAccountEntities(Ent.CROS_DEVICE, query)
    try:
      devices = callGAPIpages(cd.chromeosdevices(), 'list', 'chromeosdevices',
                              pageMessage=getPageMessage(),
                              throwReasons=[GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                              retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                              customerId=GC.Values[GC.CUSTOMER_ID],
                              query=query, fields='nextPageToken,chromeosdevices(deviceId,serialNumber)',
                              orderBy='serialNumber', maxResults=GC.Values[GC.DEVICE_MAX_RESULTS])
    except (GAPI.invalidInput, GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
      accessErrorExit(cd)
    exactMatches = prefixMatches = 0
    exactMatchDeviceIds = []
    prefixMatchDeviceIds = []
    serialNumberLower = serialNumber.lower()
    for device in devices:
      if device['serialNumber'].lower() == serialNumberLower:
        exactMatches += 1
        if not listLimit or exactMatches <= listLimit:
          exactMatchDeviceIds.append(device['deviceId'])
      else:
        prefixMatches += 1
        if not listLimit or prefixMatches <= listLimit:
          prefixMatchDeviceIds.append(device['deviceId'])
    row = {'serialNumber': serialNumber,
           'exactMatches': exactMatches,
           'exactMatchDeviceIds': delimiter.join(exactMatchDeviceIds),
           'prefixMatches': prefixMatches,
           'prefixMatchDeviceIds': delimiter.join(prefixMatchDeviceIds)}
    csvPF.WriteRow(row)
  if csvPF:
    csvPF.writeCSVfile('Chrome Serial Number Validity')

# Mobile command utilities
MOBILE_ACTION_CHOICE_MAP = {
  'accountwipe': 'admin_account_wipe',
  'adminaccountwipe': 'admin_account_wipe',
  'wipeaccount': 'admin_account_wipe',
  'adminremotewipe': 'admin_remote_wipe',
  'wipe': 'admin_remote_wipe',
  'approve': 'approve',
  'block': 'block',
  'cancelremotewipethenactivate': 'cancel_remote_wipe_then_activate',
  'cancelremotewipethenblock': 'cancel_remote_wipe_then_block',
  }

def getMobileDeviceEntity():
  cd = buildGAPIObject(API.DIRECTORY)
  if checkArgumentPresent('query'):
    query = getString(Cmd.OB_QUERY)
  else:
    resourceId = getString(Cmd.OB_MOBILE_DEVICE_ENTITY)
    if resourceId[:6].lower() == 'query:':
      query = resourceId[6:]
    else:
      Cmd.Backup()
      query = None
  if not query:
    return ([{'resourceId': device, 'email': []} for device in getEntityList(Cmd.OB_MOBILE_ENTITY)], cd, True)
  try:
    printGettingAllAccountEntities(Ent.MOBILE_DEVICE, query)
    devices = callGAPIpages(cd.mobiledevices(), 'list', 'mobiledevices',
                            pageMessage=getPageMessage(),
                            throwReasons=[GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                            customerId=GC.Values[GC.CUSTOMER_ID], query=query,
                            fields='nextPageToken,mobiledevices(resourceId,email)')
  except GAPI.invalidInput:
    Cmd.Backup()
    usageErrorExit(Msg.INVALID_QUERY)
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
    accessErrorExit(cd)
  return ([{'resourceId': device['resourceId'], 'email': device.get('email', [])} for device in devices], cd, False)

def _getUpdateDeleteMobileOptions(myarg, options):
  if myarg in {'matchusers', 'ifusers'}:
    _, matchUsers = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)
    options['matchUsers'] = {normalizeEmailAddressOrUID(user) for user in matchUsers}
  elif myarg == 'doit':
    options['doit'] = True
  else:
    unknownArgumentExit()

def _getMobileDeviceUser(mobileDevice, options):
  if options['matchUsers']:
    if mobileDevice['email']:
      for deviceUser in mobileDevice['email']:
        if deviceUser.lower() in options['matchUsers']:
          return (deviceUser, True)
      return (mobileDevice['email'][0], False)
    return (UNKNOWN, False)
  if mobileDevice['email']:
    return (mobileDevice['email'][0], True)
  return (UNKNOWN, True)

# gam update mobile|mobiles <MobileDeviceEntity> action <MobileAction>
#	[doit] [matchusers <UserTypeEntity>]
def doUpdateMobileDevices():
  entityList, cd, doit = getMobileDeviceEntity()
  body = {}
  options = {'doit': doit, 'matchUsers': set()}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'action':
      body['action'] = getChoice(MOBILE_ACTION_CHOICE_MAP, mapChoice=True)
    else:
      _getUpdateDeleteMobileOptions(myarg, options)
  if not body:
    entityActionNotPerformedWarning([Ent.MOBILE_DEVICE, None], Msg.NO_ACTION_SPECIFIED)
    return
  i = 0
  count = len(entityList)
  for device in entityList:
    i += 1
    resourceId = device['resourceId']
    deviceUser, status = _getMobileDeviceUser(device, options)
    if not status:
      entityActionNotPerformedWarning([Ent.MOBILE_DEVICE, resourceId, Ent.USER, deviceUser], Msg.USER_NOT_IN_MATCHUSERS, i, count)
    elif not options['doit']:
      entityActionNotPerformedWarning([Ent.MOBILE_DEVICE, resourceId, Ent.USER, deviceUser], Msg.USE_DOIT_ARGUMENT_TO_PERFORM_ACTION, i, count)
    else:
      try:
        callGAPI(cd.mobiledevices(), 'action',
                 bailOnInternalError=True,
                 throwReasons=[GAPI.INTERNAL_ERROR, GAPI.RESOURCE_ID_NOT_FOUND,
                               GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                 customerId=GC.Values[GC.CUSTOMER_ID], resourceId=resourceId, body=body)
        printEntityKVList([Ent.MOBILE_DEVICE, resourceId, Ent.USER, deviceUser],
                          [Msg.ACTION_APPLIED, body['action']], i, count)
      except GAPI.internalError:
        entityActionFailedWarning([Ent.MOBILE_DEVICE, resourceId], Msg.DOES_NOT_EXIST, i, count)
      except (GAPI.resourceIdNotFound, GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden) as e:
        entityActionFailedWarning([Ent.MOBILE_DEVICE, resourceId], str(e), i, count)

# gam delete mobile|mobiles <MobileDeviceEntity>
#	[doit] [matchusers <UserTypeEntity>]
def doDeleteMobileDevices():
  entityList, cd, doit = getMobileDeviceEntity()
  options = {'doit': doit, 'matchUsers': set()}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    _getUpdateDeleteMobileOptions(myarg, options)
  i = 0
  count = len(entityList)
  for device in entityList:
    i += 1
    resourceId = device['resourceId']
    deviceUser, status = _getMobileDeviceUser(device, options)
    if not status:
      entityActionNotPerformedWarning([Ent.MOBILE_DEVICE, resourceId, Ent.USER, deviceUser], Msg.USER_NOT_IN_MATCHUSERS, i, count)
    elif not options['doit']:
      entityActionNotPerformedWarning([Ent.MOBILE_DEVICE, resourceId, Ent.USER, deviceUser], Msg.USE_DOIT_ARGUMENT_TO_PERFORM_ACTION, i, count)
    else:
      try:
        callGAPI(cd.mobiledevices(), 'delete',
                 bailOnInternalError=True,
                 throwReasons=[GAPI.INTERNAL_ERROR, GAPI.RESOURCE_ID_NOT_FOUND, GAPI.BAD_REQUEST,
                               GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                 customerId=GC.Values[GC.CUSTOMER_ID], resourceId=resourceId)
        entityActionPerformed([Ent.MOBILE_DEVICE, resourceId, Ent.USER, deviceUser], i, count)
      except GAPI.internalError:
        entityActionFailedWarning([Ent.MOBILE_DEVICE, resourceId], Msg.DOES_NOT_EXIST, i, count)
      except (GAPI.resourceIdNotFound, GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden) as e:
        entityActionFailedWarning([Ent.MOBILE_DEVICE, resourceId], str(e), i, count)

MOBILE_FIELDS_CHOICE_MAP = {
  'adbstatus': 'adbStatus',
  'applications': 'applications',
  'basebandversion': 'basebandVersion',
  'bootloaderversion': 'bootloaderVersion',
  'brand': 'brand',
  'buildnumber': 'buildNumber',
  'defaultlanguage': 'defaultLanguage',
  'developeroptionsstatus': 'developerOptionsStatus',
  'devicecompromisedstatus': 'deviceCompromisedStatus',
  'deviceid': 'deviceId',
  'devicepasswordstatus': 'devicePasswordStatus',
  'email': 'email',
  'encryptionstatus': 'encryptionStatus',
  'firstsync': 'firstSync',
  'hardware': 'hardware',
  'hardwareid': 'hardwareId',
  'imei': 'imei',
  'kernelversion': 'kernelVersion',
  'lastsync': 'lastSync',
  'managedaccountisonownerprofile': 'managedAccountIsOnOwnerProfile',
  'manufacturer': 'manufacturer',
  'meid': 'meid',
  'model': 'model',
  'name': 'name',
  'networkoperator': 'networkOperator',
  'os': 'os',
  'otheraccountsinfo': 'otherAccountsInfo',
  'privilege': 'privilege',
  'releaseversion': 'releaseVersion',
  'resourceid': 'resourceId',
  'securitypatchlevel': 'securityPatchLevel',
  'serialnumber': 'serialNumber',
  'status': 'status',
  'supportsworkprofile': 'supportsWorkProfile',
  'type': 'type',
  'unknownsourcesstatus': 'unknownSourcesStatus',
  'useragent': 'userAgent',
  'wifimacaddress': 'wifiMacAddress',
  }

MOBILE_TIME_OBJECTS = {'firstSync', 'lastSync'}

def _initMobileFieldsParameters():
  return {'fieldsList': [], 'projection': None}

def _getMobileFieldsArguments(myarg, parameters):
  if myarg == 'allfields':
    parameters['projection'] = 'FULL'
    parameters['fieldsList'] = []
  elif myarg in PROJECTION_CHOICE_MAP:
    parameters['projection'] = PROJECTION_CHOICE_MAP[myarg]
    parameters['fieldsList'] = []
  elif getFieldsList(myarg, MOBILE_FIELDS_CHOICE_MAP, parameters['fieldsList'], initialField='resourceId'):
    pass
  else:
    return False
  return True

# gam info mobile|mobiles <MobileDeviceEntity>
#	[basic|full|allfields] <MobileFieldName>* [fields <MobileFieldNameList>] [formatjson]
def doInfoMobileDevices():
  entityList, cd, _ = getMobileDeviceEntity()
  parameters = _initMobileFieldsParameters()
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if _getMobileFieldsArguments(myarg, parameters):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  fields = getFieldsFromFieldsList(parameters['fieldsList'])
  i = 0
  count = len(entityList)
  for device in entityList:
    i += 1
    resourceId = device['resourceId']
    try:
      mobile = callGAPI(cd.mobiledevices(), 'get',
                        bailOnInternalError=True,
                        throwReasons=[GAPI.INTERNAL_ERROR, GAPI.RESOURCE_ID_NOT_FOUND,
                                      GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                        customerId=GC.Values[GC.CUSTOMER_ID], resourceId=resourceId, projection=parameters['projection'], fields=fields)
      if FJQC.formatJSON:
        printLine(json.dumps(cleanJSON(mobile, timeObjects=MOBILE_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
      else:
        printEntity([Ent.MOBILE_DEVICE, resourceId], i, count)
        Ind.Increment()
        attrib = 'deviceId'
        if attrib in mobile:
          mobile[attrib] = mobile[attrib].encode('unicode-escape').decode(UTF8)
        attrib = 'securityPatchLevel'
        if attrib in mobile and int(mobile[attrib]):
          mobile[attrib] = formatLocalTimestamp(mobile[attrib])
        showJSON(None, mobile, timeObjects=MOBILE_TIME_OBJECTS)
        Ind.Decrement()
    except GAPI.internalError:
      entityActionFailedWarning([Ent.MOBILE_DEVICE, resourceId], Msg.DOES_NOT_EXIST, i, count)
    except (GAPI.resourceIdNotFound, GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden) as e:
      entityActionFailedWarning([Ent.MOBILE_DEVICE, resourceId], str(e), i, count)

MOBILE_ORDERBY_CHOICE_MAP = {
  'deviceid': 'deviceId',
  'email': 'email',
  'lastsync': 'lastSync',
  'model': 'model',
  'name': 'name',
  'os': 'os',
  'status': 'status',
  'type': 'type',
  }

# gam print mobile [todrive <ToDriveAttribute>*]
#	[(query <QueryMobile>)|(queries <QueryMobileList>) [querytime<String> <Time>]]
#	[orderby <MobileOrderByFieldName> [ascending|descending]]
#	[basic|full|allfields] <MobileFieldName>* [fields <MobileFieldNameList>]
#	[delimiter <Character>] [appslimit <Number>] [oneappperrow] [listlimit <Number>]
#	[formatjson [quotechar <Character>]]
# 	[showitemcountonly]
def doPrintMobileDevices():
  def _appDetails(app):
    appDetails = []
    for field in ['displayName', 'packageName', 'versionName']:
      appDetails.append(app.get(field, '<None>'))
    appDetails.append(str(app.get('versionCode', '<None>')))
    permissions = app.get('permission', [])
    if permissions:
      appDetails.append('/'.join(permissions))
    else:
      appDetails.append('<None>')
    return '-'.join(appDetails)

  def _printMobile(mobile):
    if FJQC.formatJSON:
      if (not csvPF.rowFilter and not csvPF.rowDropFilter) or csvPF.CheckRowTitles(flattenJSON(mobile, listLimit=listLimit, timeObjects=MOBILE_TIME_OBJECTS)):
        csvPF.WriteRowNoFilter({'resourceId': mobile['resourceId'],
                                'JSON': json.dumps(cleanJSON(mobile, listLimit=listLimit, timeObjects=MOBILE_TIME_OBJECTS),
                                                   ensure_ascii=False, sort_keys=True)})
      return
    row = {}
    for attrib in mobile:
      if attrib in DEFAULT_SKIP_OBJECTS:
        pass
      elif attrib in {'name', 'email', 'otherAccountsInfo'}:
        if listLimit > 0:
          row[attrib] = delimiter.join(mobile[attrib][0:listLimit])
        elif listLimit == 0:
          row[attrib] = delimiter.join(mobile[attrib])
      elif attrib == 'deviceId':
        row[attrib] = mobile[attrib].encode('unicode-escape').decode(UTF8)
      elif attrib in MOBILE_TIME_OBJECTS:
        row[attrib] = formatLocalTime(mobile[attrib])
      elif attrib == 'securityPatchLevel' and int(mobile[attrib]):
        row[attrib] = formatLocalTimestamp(mobile[attrib])
      elif attrib != 'applications':
        row[attrib] = mobile[attrib]
    attrib = 'applications'
    if not oneAppPerRow or attrib not in mobile or appsLimit < 0:
      if attrib in mobile and appsLimit >= 0:
        applications = []
        j = 0
        for app in mobile[attrib]:
          j += 1
          if appsLimit and (j > appsLimit):
            break
          applications.append(_appDetails(app))
        row[attrib] = delimiter.join(applications)
      csvPF.WriteRowTitles(row)
    else:
      j = 0
      for app in mobile[attrib]:
        j += 1
        if appsLimit and (j > appsLimit):
          break
        appRow = row.copy()
        appRow[attrib] = _appDetails(app)
        csvPF.WriteRowTitles(appRow)

  cd = buildGAPIObject(API.DIRECTORY)
  parameters = _initMobileFieldsParameters()
  csvPF = CSVPrintFile('resourceId')
  FJQC = FormatJSONQuoteChar(csvPF)
  orderBy = sortOrder = None
  oneAppPerRow = False
  queryTimes = {}
  queries = [None]
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  listLimit = 1
  appsLimit = -1
  showItemCountOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'query', 'queries'}:
      queries = getQueries(myarg)
    elif myarg.startswith('querytime'):
      queryTimes[myarg] = getTimeOrDeltaFromNow()[0:19]
    elif myarg == 'orderby':
      orderBy, sortOrder = getOrderBySortOrder(MOBILE_ORDERBY_CHOICE_MAP)
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    elif myarg == 'listlimit':
      listLimit = getInteger(minVal=-1)
    elif myarg == 'appslimit':
      appsLimit = getInteger(minVal=-1)
    elif myarg == 'oneappperrow':
      oneAppPerRow = True
    elif _getMobileFieldsArguments(myarg, parameters):
      pass
    elif myarg == 'showitemcountonly':
      showItemCountOnly = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if not FJQC.formatJSON:
    csvPF.SetSortTitles(['resourceId', 'deviceId', 'serialNumber', 'name', 'email', 'status'])
  if appsLimit >= 0:
    parameters['projection'] = 'FULL'
  fields = getItemFieldsFromFieldsList('mobiledevices', parameters['fieldsList'])
  substituteQueryTimes(queries, queryTimes)
  itemCount = 0
  for query in queries:
    printGettingAllAccountEntities(Ent.MOBILE_DEVICE, query)
    pageMessage = getPageMessage()
    totalItems = 0
    try:
      feed = yieldGAPIpages(cd.mobiledevices(), 'list', 'mobiledevices',
                            pageMessage=pageMessage,
                            throwReasons=[GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                            customerId=GC.Values[GC.CUSTOMER_ID], query=query, projection=parameters['projection'],
                            orderBy=orderBy, sortOrder=sortOrder, fields=fields, maxResults=GC.Values[GC.MOBILE_MAX_RESULTS])
      for mobiles in feed:
        totalItems += len(mobiles)
        if showItemCountOnly:
          itemCount += len(mobiles)
          continue
        for mobile in mobiles:
          _printMobile(mobile)
      printGotAccountEntities(totalItems)
    except GAPI.invalidInput:
      entityActionFailedWarning([Ent.MOBILE_DEVICE, None], invalidQuery(query))
      return
    except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
      accessErrorExit(cd)
  if showItemCountOnly:
    writeStdout(f'{itemCount}\n')
    return
  csvPF.writeCSVfile('Mobile')

GROUP_DISCOVER_CHOICES = {
  'allmemberscandiscover': 'ALL_MEMBERS_CAN_DISCOVER',
  'allindomaincandiscover': 'ALL_IN_DOMAIN_CAN_DISCOVER',
  'anyonecandiscover': 'ANYONE_CAN_DISCOVER',
  }
GROUP_ASSIST_CONTENT_CHOICES = {
  'allmembers': 'ALL_MEMBERS',
  'ownersandmanagers': 'OWNERS_AND_MANAGERS',
  'managersonly': 'MANAGERS_ONLY',
  'ownersonly': 'OWNERS_ONLY',
  'none': 'NONE',
  }
GROUP_MODERATE_CONTENT_CHOICES = {
  'allmembers': 'ALL_MEMBERS',
  'ownersandmanagers': 'OWNERS_AND_MANAGERS',
  'ownersonly': 'OWNERS_ONLY',
  'none': 'NONE',
  }
GROUP_MODERATE_MEMBERS_CHOICES = {
  'allmembers': 'ALL_MEMBERS',
  'ownersandmanagers': 'OWNERS_AND_MANAGERS',
  'ownersonly': 'OWNERS_ONLY',
  'none': 'NONE',
  }
GROUP_DEPRECATED_ATTRIBUTES = {
  'allowgooglecommunication': ['allowGoogleCommunication', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}],
  'favoriterepliesontop': ['favoriteRepliesOnTop', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}],
  'maxmessagebytes': ['maxMessageBytes', {GC.VAR_TYPE: GC.TYPE_INTEGER, GC.VAR_LIMITS: (ONE_KILO_BYTES, ONE_MEGA_BYTES)}],
  'messagedisplayfont': ['messageDisplayFont', {GC.VAR_TYPE: GC.TYPE_CHOICE,
                                                'choices': {'defaultfont': 'DEFAULT_FONT', 'fixedwidthfont': 'FIXED_WIDTH_FONT'}}],
  'whocanaddreferences': ['whoCanAddReferences', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_ASSIST_CONTENT_CHOICES}],
  'whocanmarkfavoritereplyonowntopic': ['whoCanMarkFavoriteReplyOnOwnTopic', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_ASSIST_CONTENT_CHOICES}],
  }
GROUP_DISCOVER_ATTRIBUTES = {
  'showingroupdirectory': ['showInGroupDirectory', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}],
  }
GROUP_ASSIST_CONTENT_ATTRIBUTES = {
  'whocanassigntopics': ['whoCanAssignTopics', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_ASSIST_CONTENT_CHOICES}],
  'whocanenterfreeformtags': ['whoCanEnterFreeFormTags', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_ASSIST_CONTENT_CHOICES}],
  'whocanhideabuse': ['whoCanHideAbuse', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_ASSIST_CONTENT_CHOICES}],
  'whocanmaketopicssticky': ['whoCanMakeTopicsSticky', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_ASSIST_CONTENT_CHOICES}],
  'whocanmarkduplicate': ['whoCanMarkDuplicate', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_ASSIST_CONTENT_CHOICES}],
  'whocanmarkfavoritereplyonanytopic': ['whoCanMarkFavoriteReplyOnAnyTopic', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_ASSIST_CONTENT_CHOICES}],
  'whocanmarknoresponseneeded': ['whoCanMarkNoResponseNeeded', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_ASSIST_CONTENT_CHOICES}],
  'whocanmodifytagsandcategories': ['whoCanModifyTagsAndCategories', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_ASSIST_CONTENT_CHOICES}],
  'whocantaketopics': ['whoCanTakeTopics', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_ASSIST_CONTENT_CHOICES}],
  'whocanunassigntopic': ['whoCanUnassignTopic', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_ASSIST_CONTENT_CHOICES}],
  'whocanunmarkfavoritereplyonanytopic': ['whoCanUnmarkFavoriteReplyOnAnyTopic', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_ASSIST_CONTENT_CHOICES}],
  }
GROUP_MODERATE_CONTENT_ATTRIBUTES = {
  'whocanapprovemessages': ['whoCanApproveMessages', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_MODERATE_CONTENT_CHOICES}],
  'whocandeleteanypost': ['whoCanDeleteAnyPost', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_MODERATE_CONTENT_CHOICES}],
  'whocandeletetopics': ['whoCanDeleteTopics', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_MODERATE_CONTENT_CHOICES}],
  'whocanlocktopics': ['whoCanLockTopics', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_MODERATE_CONTENT_CHOICES}],
  'whocanmovetopicsin': ['whoCanMoveTopicsIn', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_MODERATE_CONTENT_CHOICES}],
  'whocanmovetopicsout': ['whoCanMoveTopicsOut', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_MODERATE_CONTENT_CHOICES}],
  'whocanpostannouncements': ['whoCanPostAnnouncements', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_MODERATE_CONTENT_CHOICES}],
  }
GROUP_MODERATE_MEMBERS_ATTRIBUTES = {
  'whocanadd': ['whoCanAdd', {GC.VAR_TYPE: GC.TYPE_CHOICE,
                              'choices': {'allmanagerscanadd': 'ALL_MANAGERS_CAN_ADD', 'allownerscanadd': 'ALL_OWNERS_CAN_ADD',
                                          'allmemberscanadd': 'ALL_MEMBERS_CAN_ADD', 'nonecanadd': 'NONE_CAN_ADD'}}],
  'whocanapprovemembers': ['whoCanApproveMembers', {GC.VAR_TYPE: GC.TYPE_CHOICE,
                                                    'choices': {'allownerscanapprove': 'ALL_OWNERS_CAN_APPROVE', 'allmanagerscanapprove': 'ALL_MANAGERS_CAN_APPROVE',
                                                                'allmemberscanapprove': 'ALL_MEMBERS_CAN_APPROVE', 'nonecanapprove': 'NONE_CAN_APPROVE'}}],
  'whocanbanusers': ['whoCanBanUsers', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_MODERATE_MEMBERS_CHOICES}],
  'whocaninvite': ['whoCanInvite', {GC.VAR_TYPE: GC.TYPE_CHOICE,
                                    'choices': {'allmemberscaninvite': 'ALL_MEMBERS_CAN_INVITE', 'allmanagerscaninvite': 'ALL_MANAGERS_CAN_INVITE',
                                                'allownerscaninvite': 'ALL_OWNERS_CAN_INVITE', 'nonecaninvite': 'NONE_CAN_INVITE'}}],
  'whocanmodifymembers': ['whoCanModifyMembers', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_MODERATE_MEMBERS_CHOICES}],
  }
GROUP_BASIC_ATTRIBUTES = {
  'description': ['description', {GC.VAR_TYPE: GC.TYPE_STRING}],
  'name': ['name', {GC.VAR_TYPE: GC.TYPE_STRING}],
  'displayname': ['name', {GC.VAR_TYPE: GC.TYPE_STRING}],
  }
GROUP_SETTINGS_ATTRIBUTES = {
  'allowexternalmembers': ['allowExternalMembers', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}],
  'allowwebposting': ['allowWebPosting', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}],
  'archiveonly': ['archiveOnly', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}],
  'customfootertext': ['customFooterText', {GC.VAR_TYPE: GC.TYPE_STRING}],
  'customreplyto': ['customReplyTo', {GC.VAR_TYPE: GC.TYPE_EMAIL_OPTIONAL}],
  'customrolesenabledforsettingstobemerged': ['customRolesEnabledForSettingsToBeMerged', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}],
  'defaultmessagedenynotificationtext': ['defaultMessageDenyNotificationText', {GC.VAR_TYPE: GC.TYPE_STRING}],
  'defaultsender': ['defaultSender', {GC.VAR_TYPE: GC.TYPE_CHOICE,
                                      'choices': {'self': 'DEFAULT_SELF', 'defaultself': 'DEFAULT_SELF', 'group': 'GROUP'}}],
  'enablecollaborativeinbox': ['enableCollaborativeInbox', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}],
  'includecustomfooter': ['includeCustomFooter', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}],
  'includeinglobaladdresslist': ['includeInGlobalAddressList', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}],
  'isarchived': ['isArchived', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}],
  'memberscanpostasthegroup': ['membersCanPostAsTheGroup', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}],
  'messagemoderationlevel': ['messageModerationLevel', {GC.VAR_TYPE: GC.TYPE_CHOICE,
                                                        'choices': {'moderateallmessages': 'MODERATE_ALL_MESSAGES', 'moderatenonmembers': 'MODERATE_NON_MEMBERS',
                                                                    'moderatenewmembers': 'MODERATE_NEW_MEMBERS', 'moderatenone': 'MODERATE_NONE'}}],
  'primarylanguage': ['primaryLanguage', {GC.VAR_TYPE: GC.TYPE_LANGUAGE}],
  'replyto': ['replyTo', {GC.VAR_TYPE: GC.TYPE_CHOICE,
                          'choices': {'replytocustom': 'REPLY_TO_CUSTOM', 'replytosender': 'REPLY_TO_SENDER', 'replytolist': 'REPLY_TO_LIST',
                                      'replytoowner': 'REPLY_TO_OWNER', 'replytoignore': 'REPLY_TO_IGNORE', 'replytomanagers': 'REPLY_TO_MANAGERS'}}],
  'sendmessagedenynotification': ['sendMessageDenyNotification', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}],
  'spammoderationlevel': ['spamModerationLevel', {GC.VAR_TYPE: GC.TYPE_CHOICE,
                                                  'choices': {'allow': 'ALLOW', 'moderate': 'MODERATE', 'silentlymoderate': 'SILENTLY_MODERATE', 'reject': 'REJECT'}}],
  'whocancontactowner': ['whoCanContactOwner', {GC.VAR_TYPE: GC.TYPE_CHOICE,
                                                'choices': {'anyonecancontact': 'ANYONE_CAN_CONTACT', 'allindomaincancontact': 'ALL_IN_DOMAIN_CAN_CONTACT',
                                                            'allmemberscancontact': 'ALL_MEMBERS_CAN_CONTACT', 'allmanagerscancontact': 'ALL_MANAGERS_CAN_CONTACT',
                                                            'allownerscancontact': 'ALL_OWNERS_CAN_CONTACT'}}],
  'whocanjoin': ['whoCanJoin', {GC.VAR_TYPE: GC.TYPE_CHOICE,
                                'choices': {'anyonecanjoin': 'ANYONE_CAN_JOIN', 'allindomaincanjoin': 'ALL_IN_DOMAIN_CAN_JOIN',
                                            'invitedcanjoin': 'INVITED_CAN_JOIN', 'canrequesttojoin': 'CAN_REQUEST_TO_JOIN'}}],
  'whocanleavegroup': ['whoCanLeaveGroup', {GC.VAR_TYPE: GC.TYPE_CHOICE,
                                            'choices': {'allmanagerscanleave': 'ALL_MANAGERS_CAN_LEAVE', 'allownerscanleave': 'ALL_OWNERS_CAN_LEAVE',
                                                        'allmemberscanleave': 'ALL_MEMBERS_CAN_LEAVE', 'nonecanleave': 'NONE_CAN_LEAVE'}}],
  'whocanpostmessage': ['whoCanPostMessage', {GC.VAR_TYPE: GC.TYPE_CHOICE,
                                              'choices': {'nonecanpost': 'NONE_CAN_POST', 'allmanagerscanpost': 'ALL_MANAGERS_CAN_POST',
                                                          'allmemberscanpost': 'ALL_MEMBERS_CAN_POST', 'allownerscanpost': 'ALL_OWNERS_CAN_POST',
                                                          'allindomaincanpost': 'ALL_IN_DOMAIN_CAN_POST', 'anyonecanpost': 'ANYONE_CAN_POST'}}],
  'whocanviewgroup': ['whoCanViewGroup', {GC.VAR_TYPE: GC.TYPE_CHOICE,
                                          'choices': {'anyonecanview': 'ANYONE_CAN_VIEW', 'allindomaincanview': 'ALL_IN_DOMAIN_CAN_VIEW',
                                                      'allmemberscanview': 'ALL_MEMBERS_CAN_VIEW', 'allmanagerscanview': 'ALL_MANAGERS_CAN_VIEW',
                                                      'allownerscanview': 'ALL_OWNERS_CAN_VIEW'}}],
  'whocanviewmembership': ['whoCanViewMembership', {GC.VAR_TYPE: GC.TYPE_CHOICE,
                                                    'choices': {'allindomaincanview': 'ALL_IN_DOMAIN_CAN_VIEW', 'allmemberscanview': 'ALL_MEMBERS_CAN_VIEW',
                                                                'allmanagerscanview': 'ALL_MANAGERS_CAN_VIEW', 'allownerscanview': 'ALL_OWNERS_CAN_VIEW'}}],
  }
GROUP_ALIAS_ATTRIBUTES = {
  'collaborative': ['enableCollaborativeInbox', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}],
  'gal': ['includeInGlobalAddressList', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}],
  }
GROUP_MERGED_ATTRIBUTES = {
  'whocandiscovergroup': ['whoCanDiscoverGroup', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_DISCOVER_CHOICES}],
  'whocanassistcontent': ['whoCanAssistContent', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_ASSIST_CONTENT_CHOICES}],
  'whocanmoderatecontent': ['whoCanModerateContent', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_MODERATE_CONTENT_CHOICES}],
  'whocanmoderatemembers': ['whoCanModerateMembers', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': GROUP_MODERATE_MEMBERS_CHOICES}],
  }
GROUP_MERGED_ATTRIBUTES_PRINT_ORDER = ['whoCanDiscoverGroup', 'whoCanAssistContent', 'whoCanModerateContent', 'whoCanModerateMembers']
GROUP_MERGED_TO_COMPONENT_MAP = {
  'whoCanDiscoverGroup': GROUP_DISCOVER_ATTRIBUTES,
  'whoCanAssistContent': GROUP_ASSIST_CONTENT_ATTRIBUTES,
  'whoCanModerateContent': GROUP_MODERATE_CONTENT_ATTRIBUTES,
  'whoCanModerateMembers': GROUP_MODERATE_MEMBERS_ATTRIBUTES,
  }
GROUP_ATTRIBUTES_SET = set(list(GROUP_BASIC_ATTRIBUTES)+list(GROUP_SETTINGS_ATTRIBUTES)+list(GROUP_ALIAS_ATTRIBUTES)+
                           list(GROUP_ASSIST_CONTENT_ATTRIBUTES)+list(GROUP_MODERATE_CONTENT_ATTRIBUTES)+list(GROUP_MODERATE_MEMBERS_ATTRIBUTES)+
                           list(GROUP_MERGED_ATTRIBUTES)+list(GROUP_DEPRECATED_ATTRIBUTES))
GROUP_FIELDS_WITH_CRS_NLS = {'customFooterText', 'defaultMessageDenyNotificationText', 'description'}

def getGroupAttrProperties(myarg):
  attrProperties = GROUP_BASIC_ATTRIBUTES.get(myarg)
  if attrProperties is not None:
    return attrProperties
  attrProperties = GROUP_SETTINGS_ATTRIBUTES.get(myarg)
  if attrProperties is not None:
    return attrProperties
  attrProperties = GROUP_ALIAS_ATTRIBUTES.get(myarg)
  if attrProperties is not None:
    return attrProperties
  attrProperties = GROUP_DISCOVER_ATTRIBUTES.get(myarg)
  if attrProperties is not None:
    return attrProperties
  attrProperties = GROUP_ASSIST_CONTENT_ATTRIBUTES.get(myarg)
  if attrProperties is not None:
    return attrProperties
  attrProperties = GROUP_MODERATE_CONTENT_ATTRIBUTES.get(myarg)
  if attrProperties is not None:
    return attrProperties
  attrProperties = GROUP_MODERATE_MEMBERS_ATTRIBUTES.get(myarg)
  if attrProperties is not None:
    return attrProperties
  attrProperties = GROUP_MERGED_ATTRIBUTES.get(myarg)
  if attrProperties is not None:
    return attrProperties
  attrProperties = GROUP_DEPRECATED_ATTRIBUTES.get(myarg)
  if attrProperties is not None:
    return attrProperties
  return None

def getGroupAttrValue(argument, gs_body):
  if argument == 'copyfrom':
    gs_body[argument] = getEmailAddress()
    return
  attrProperties = getGroupAttrProperties(argument)
  if attrProperties is None:
    unknownArgumentExit()
  attrName = attrProperties[0]
  attribute = attrProperties[1]
  attrType = attribute[GC.VAR_TYPE]
  if attrType == GC.TYPE_BOOLEAN:
    gs_body[attrName] = str(getBoolean()).lower()
  elif attrType == GC.TYPE_STRING:
    if attrName in GROUP_FIELDS_WITH_CRS_NLS:
      gs_body[attrName] = getStringWithCRsNLs()
    else:
      gs_body[attrName] = getString(Cmd.OB_STRING, minLen=0)
  elif attrType == GC.TYPE_CHOICE:
    gs_body[attrName] = getChoice(attribute['choices'], mapChoice=True)
  elif attrType in [GC.TYPE_EMAIL, GC.TYPE_EMAIL_OPTIONAL]:
    gs_body[attrName] = getEmailAddress(noUid=True, optional=attrType == GC.TYPE_EMAIL_OPTIONAL)
    if attrType == GC.TYPE_EMAIL_OPTIONAL and gs_body[attrName] is None:
      gs_body[attrName] = ''
  elif attrType == GC.TYPE_LANGUAGE:
    gs_body[attrName] = getLanguageCode(LANGUAGE_CODES_MAP)
  else: # GC.TYPE_INTEGER
    minVal, maxVal = attribute[GC.VAR_LIMITS]
    if attrName == 'maxMessageBytes':
      gs_body[attrName] = getMaxMessageBytes(minVal, maxVal)
    else:
      gs_body[attrName] = getInteger(minVal, maxVal)

def GroupIsAbuseOrPostmaster(emailAddr):
  return emailAddr.startswith('abuse@') or emailAddr.startswith('postmaster@')

def mapGroupEmailForSettings(emailAddr):
  return emailAddr.replace('/', '%2F')

def getSettingsFromGroup(cd, group, gs, gs_body):
  if gs_body:
    copySettingsFromGroup = gs_body.pop('copyfrom', None)
    if copySettingsFromGroup:
      try:
        if copySettingsFromGroup.find('@') == -1: # group settings API won't take uid so we make sure cd API is used so that we can grab real email.
          copySettingsFromGroup = callGAPI(cd.groups(), 'get',
                                           throwReasons=GAPI.GROUP_GET_THROW_REASONS, retryReasons=GAPI.GROUP_GET_RETRY_REASONS,
                                           groupKey=copySettingsFromGroup, fields='email')['email']
        settings = callGAPI(gs.groups(), 'get',
                            throwReasons=GAPI.GROUP_SETTINGS_THROW_REASONS, retryReasons=GAPI.GROUP_SETTINGS_RETRY_REASONS,
                            groupUniqueId=mapGroupEmailForSettings(copySettingsFromGroup), fields='*')
        if settings is not None:
          for field in ['email', 'name', 'description']:
            settings.pop(field, None)
          settings.update(gs_body)
          return settings
        entityItemValueListActionNotPerformedWarning([Ent.GROUP, group], [Ent.COPYFROM_GROUP, copySettingsFromGroup], Msg.API_ERROR_SETTINGS)
        return None
      except GAPI.notFound:
        entityItemValueListActionNotPerformedWarning([Ent.GROUP, group], [Ent.COPYFROM_GROUP, copySettingsFromGroup], Msg.DOES_NOT_EXIST)
        return None
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
              GAPI.backendError, GAPI.invalid, GAPI.invalidInput, GAPI.badRequest, GAPI.permissionDenied,
              GAPI.systemError, GAPI.serviceLimit, GAPI.serviceNotAvailable, GAPI.authError) as e:
        entityItemValueListActionNotPerformedWarning([Ent.GROUP, group], [Ent.COPYFROM_GROUP, copySettingsFromGroup], str(e))
        return None
  return gs_body

def checkReplyToCustom(group, settings, i=0, count=0):
  if settings.get('replyTo') != 'REPLY_TO_CUSTOM' or settings.get('customReplyTo', ''):
    return True
  entityActionNotPerformedWarning([Ent.GROUP, group], Msg.REPLY_TO_CUSTOM_REQUIRES_EMAIL_ADDRESS, i, count)
  return False

GROUP_CIGROUP_ENTITYTYPE_MAP = {False: Ent.GROUP, True: Ent.CLOUD_IDENTITY_GROUP}
GROUP_CIGROUP_FIELDS_MAP = {'name': 'displayName', 'displayname': 'displayName', 'description': 'description'}
GROUP_JSON_SKIP_FIELDS = ['email', 'adminCreated', 'directMembersCount', 'members', 'aliases', 'nonEditableAliases']
GROUP_ACCESS_TYPE_CHOICE_MAP = {
  'public': {
    'whoCanJoin': 'ALL_IN_DOMAIN_CAN_JOIN',
    'whoCanPostMessage': 'ALL_IN_DOMAIN_CAN_POST',
    'whoCanViewGroup': 'ALL_IN_DOMAIN_CAN_VIEW',
    'whoCanViewMembership': 'ALL_IN_DOMAIN_CAN_VIEW',
  },
  'team': {
    'whoCanJoin': 'CAN_REQUEST_TO_JOIN',
    'whoCanPostMessage': 'ALL_IN_DOMAIN_CAN_POST',
    'whoCanViewGroup': 'ALL_IN_DOMAIN_CAN_VIEW',
    'whoCanViewMembership': 'ALL_IN_DOMAIN_CAN_VIEW',
    },
  'announcementonly': {
    'whoCanJoin': 'ALL_IN_DOMAIN_CAN_JOIN',
    'whoCanPostMessage': 'ALL_MANAGERS_CAN_POST',
    'whoCanViewGroup': 'ALL_IN_DOMAIN_CAN_VIEW',
    'whoCanViewMembership': 'ALL_MANAGERS_CAN_VIEW',
    },
  'restricted': {
    'whoCanJoin': 'CAN_REQUEST_TO_JOIN',
    'whoCanPostMessage': 'ALL_MEMBERS_CAN_POST',
    'whoCanViewGroup': 'ALL_MEMBERS_CAN_VIEW',
    'whoCanViewMembership': 'ALL_MEMBERS_CAN_VIEW',
    }
  }

# gam create group <EmailAddress> [copyfrom <GroupItem>] <GroupAttribute>*
#	[verifynotinvitable]
def doCreateGroup(ciGroupsAPI=False):
  cd = buildGAPIObject(API.DIRECTORY)
  verifyNotInvitable = getBeforeUpdate = False
  groupEmail = getEmailAddress(noUid=True)
  entityType = GROUP_CIGROUP_ENTITYTYPE_MAP[ciGroupsAPI]
  if not ciGroupsAPI:
    ci = None
    body = {'email': groupEmail}
  else:
    ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
    initialGroupConfig = 'EMPTY'
    setTrueCustomerId(cd)
    parent = f'customers/{GC.Values[GC.CUSTOMER_ID]}'
    body = {'groupKey': {'id': groupEmail},
            'parent': parent,
            'labels': {CIGROUP_DISCUSSION_FORUM_LABEL: ''},
            }
  gs_body = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'getbeforeupdate':
      getBeforeUpdate = True
    elif myarg == 'json':
      gs_body.update(getJSON(GROUP_JSON_SKIP_FIELDS))
    elif myarg == 'accesstype':
      gs_body.update(getChoice(GROUP_ACCESS_TYPE_CHOICE_MAP, mapChoice=True))
    elif ciGroupsAPI and myarg in ['alias', 'aliases']:
      body.setdefault('additionalGroupKeys', [])
      for alias in convertEntityToList(getString(Cmd.OB_CIGROUP_ALIAS_LIST), shlexSplit=True):
        body['additionalGroupKeys'].append({'id': alias})
    elif ciGroupsAPI and myarg == 'dynamic':
      body.setdefault('dynamicGroupMetadata', {'queries': []})
      body['dynamicGroupMetadata']['queries'].append({'resourceType': 'USER',
                                                      'query': getString(Cmd.OB_QUERY)})
    elif ciGroupsAPI and myarg == 'makeowner':
      initialGroupConfig = 'WITH_INITIAL_OWNER'
    elif ciGroupsAPI and myarg in {'security', 'makesecuritygroup'}:
      body['labels'][CIGROUP_SECURITY_LABEL] = ''
    elif myarg == 'verifynotinvitable':
      verifyNotInvitable = True
    else:
      getGroupAttrValue(myarg, gs_body)
  if verifyNotInvitable:
    isInvitableUser, _ = _getIsInvitableUser(None, groupEmail)
    if isInvitableUser:
      entityActionNotPerformedWarning([Ent.GROUP, groupEmail], Msg.EMAIL_ADDRESS_IS_UNMANAGED_ACCOUNT)
      return
  if ciGroupsAPI:
    for k, v in iter(GROUP_CIGROUP_FIELDS_MAP.items()):
      if k in gs_body:
        body[v] = gs_body.pop(k)
    body.setdefault('displayName', groupEmail)
  if gs_body:
    gs_body.setdefault('name', body.get('displayName', groupEmail))
    gs = buildGAPIObject(API.GROUPSSETTINGS)
    gs_body = getSettingsFromGroup(cd, groupEmail, gs, gs_body)
    if not gs_body or not checkReplyToCustom(groupEmail, gs_body):
      return
    if not getBeforeUpdate:
      settings = gs_body
  try:
    if not ciGroupsAPI:
      callGAPI(cd.groups(), 'insert',
               throwReasons=GAPI.GROUP_CREATE_THROW_REASONS,
               body=body, fields='')
    else:
      callGAPI(ci.groups(), 'create',
               throwReasons=GAPI.CIGROUP_CREATE_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
               initialGroupConfig=initialGroupConfig, body=body, fields='')
    if gs_body and not GroupIsAbuseOrPostmaster(groupEmail):
      if getBeforeUpdate:
        settings = callGAPI(gs.groups(), 'get',
                            throwReasons=GAPI.GROUP_SETTINGS_THROW_REASONS,
                            retryReasons=GAPI.GROUP_SETTINGS_RETRY_REASONS+[GAPI.NOT_FOUND],
                            groupUniqueId=mapGroupEmailForSettings(groupEmail), fields='*')
        settings.update(gs_body)
      callGAPI(gs.groups(), 'update',
               bailOnInvalidError='messageModerationLevel' in settings,
               throwReasons=GAPI.GROUP_SETTINGS_THROW_REASONS,
               retryReasons=GAPI.GROUP_SETTINGS_RETRY_REASONS+[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT],
               groupUniqueId=mapGroupEmailForSettings(groupEmail), body=settings, fields='')
    entityActionPerformed([entityType, groupEmail])
  except (GAPI.alreadyExists, GAPI.duplicate):
    duplicateAliasGroupUserWarning(cd, [entityType, groupEmail])
  except GAPI.notFound:
    entityActionFailedWarning([entityType, groupEmail], Msg.DOES_NOT_EXIST)
  except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.backendError,
          GAPI.invalid, GAPI.invalidArgument, GAPI.invalidAttributeValue, GAPI.invalidInput, GAPI.invalidArgument, GAPI.failedPrecondition,
          GAPI.badRequest, GAPI.permissionDenied, GAPI.systemError, GAPI.serviceLimit, GAPI.serviceNotAvailable, GAPI.authError) as e:
    entityActionFailedWarning([entityType, groupEmail], str(e))
  except GAPI.required:
    entityActionFailedWarning([entityType, groupEmail], Msg.INVALID_JSON_SETTING)

# [addonly|removeonly]
def getSyncOperation():
  return getChoice(['addonly', 'removeonly'], defaultChoice='addremove')

UPDATE_GROUP_SUBCMDS = ['add', 'create', 'delete', 'remove', 'clear', 'sync', 'update']
GROUP_PREVIEW_TITLES = ['group', 'email', 'role', 'action', 'message']

# gam update groups <GroupEntity> [email <EmailAddress>]
#	[copyfrom <GroupItem>] <GroupAttribute>*
#	[security|makesecuritygroup]
#	[admincreated <Boolean>]
#	[verifynotinvitable]
# gam update groups <GroupEntity> create [<GroupRole>]
#	[usersonly|groupsonly]
#	[notsuspended|suspended] [notarchived|archived]
#	[delivery <DeliverySetting>]
#	[preview] [actioncsv]
#	<UserTypeEntity>
# gam update groups <GroupEntity> delete|remove [<GroupRole>]
#	[usersonly|groupsonly]
#	[notsuspended|suspended] [notarchived|archived]
#	[preview] [actioncsv]
#	<UserTypeEntity>
# gam update groups <GroupEntity> sync [<GroupRole>|ignorerole]
#	[usersonly|groupsonly] [addonly|removeonly]
#	[notsuspended|suspended] [notarchived|archived]
#	[removedomainnostatusmembers]
#	[delivery <DeliverySetting>] [preview] [actioncsv]
#	(additionalmembers <EmailAddressEntity>)*
#	<UserTypeEntity>
# gam update groups <GroupEntity> update [<GroupRole>]
#	[usersonly|groupsonly]
#	[notsuspended|suspended] [notarchived|archived]
#	[delivery <DeliverySetting>] [preview] [actioncsv]
#	[createifnotfound]
#	<UserTypeEntity>
# gam update groups <GroupEntity> clear [member] [manager] [owner]
#	[usersonly|groupsonly]
#	[notsuspended|suspended] [notarchived|archived]
#	[emailclearpattern|emailretainpattern <REMatchPattern>]
#	[removedomainnostatusmembers]
#	[preview] [actioncsv]
def doUpdateGroups():

  def _getPreviewActionCSV():
    preview = checkArgumentPresent('preview')
    if checkArgumentPresent('actioncsv'):
      csvPF = CSVPrintFile(GROUP_PREVIEW_TITLES)
    else:
      csvPF = None
    return (preview, csvPF)

  def _validateSubkeyRoleGetMembers(group, role, origGroup, groupMemberLists, i, count):
    roleLower = role.lower()
    if roleLower in GROUP_ROLES_MAP:
      return (GROUP_ROLES_MAP[roleLower], groupMemberLists[origGroup][role])
    entityActionNotPerformedWarning([entityType, group, Ent.ROLE, role], Msg.INVALID_ROLE.format(','.join(sorted(GROUP_ROLES_MAP))), i, count)
    return (None, None)

  def _getRoleGroupMemberType(defaultRole=Ent.ROLE_MEMBER, allowIgnoreRole=False):
    if not allowIgnoreRole or not checkArgumentPresent(['ignorerole']):
      role = getChoice(GROUP_ROLES_MAP, defaultChoice=defaultRole, mapChoice=True)
    else:
      role = Ent.ROLE_ALL
    groupMemberType = getChoice({'usersonly': Ent.TYPE_USER, 'groupsonly': Ent.TYPE_GROUP}, defaultChoice='ALL', mapChoice=True)
    return (role, groupMemberType)

  def _getMemberEmailStatus(member):
    if member['type'] == Ent.TYPE_CUSTOMER:
      return (member['id'], member.get('status', 'UNKNOWN'))
    email = member['email'].lower()
    if not removeDomainNoStatusMembers or 'status' in member:
      return (email, member.get('status', 'UNKNOWN'))
    _, domain = splitEmailAddress(email)
    if domain != GC.Values[GC.DOMAIN]:
      return (email, 'UNKNOWN')
    return (email, 'NONE')

  def _executeBatch(dbatch, batchParms):
    dbatch.execute()
    if batchParms['wait'] > 0:
      time.sleep(batchParms['wait'])

# Convert foo@googlemail.com to foo@gmail.com; eliminate periods in name for foo.bar@gmail.com
  def _cleanConsumerAddress(emailAddress, mapCleanToOriginal):
    atLoc = emailAddress.find('@')
    if atLoc > 0:
      if emailAddress[atLoc+1:] in {'gmail.com', 'googlemail.com'}:
        cleanEmailAddress = emailAddress[:atLoc].replace('.', '')+'@gmail.com'
        if cleanEmailAddress != emailAddress:
          mapCleanToOriginal[cleanEmailAddress] = emailAddress
          return cleanEmailAddress
    return emailAddress

  def _previewAction(group, members, role, jcount, action):
    Ind.Increment()
    j = 0
    for member in members:
      j += 1
      entityActionPerformed([entityType, group, role, member], j, jcount)
    Ind.Decrement()
    if csvPF:
      for member in members:
        csvPF.WriteRow({'group': group, 'email': member, 'role': role, 'action': Act.PerformedName(action), 'message': Act.PREVIEW})

  def _showSuccess(group, member, role, delivery_settings, j, jcount, optMsg=None):
    kvList = []
    if role is not None and role != 'None':
      kvList.append(f'{Ent.Singular(Ent.ROLE)}: {role}')
    if delivery_settings != DELIVERY_SETTINGS_UNDEFINED:
      kvList.append(f'{Ent.Singular(Ent.DELIVERY)}: {delivery_settings}')
    if optMsg:
      kvList.append(optMsg)
    entityActionPerformedMessage([entityType, group, Ent.MEMBER, member], ', '.join(kvList), j, jcount)
    if csvPF:
      csvPF.WriteRow({'group': group, 'email': member, 'role': role, 'action': Act.Performed(), 'message': Act.SUCCESS})

  def _showFailure(group, member, role, errMsg, j, jcount):
    entityActionFailedWarning([entityType, group, Ent.MEMBER, member, Ent.ROLE, role], errMsg, j, jcount)
    if csvPF:
      csvPF.WriteRow({'group': group, 'email': member, 'role': role, 'action': Act.Failed(), 'message': errMsg})

  def _addMember(group, i, count, role, delivery_settings, member, j, jcount):
    body = {'role': role}
    if member.find('@') != -1:
      body['email'] = member
    else:
      body['id'] = member
    if delivery_settings != DELIVERY_SETTINGS_UNDEFINED:
      body['delivery_settings'] = delivery_settings
    try:
      callGAPI(cd.members(), 'insert',
               throwReasons=GAPI.MEMBERS_THROW_REASONS+[GAPI.DUPLICATE, GAPI.MEMBER_NOT_FOUND, GAPI.RESOURCE_NOT_FOUND,
                                                        GAPI.INVALID_MEMBER, GAPI.CYCLIC_MEMBERSHIPS_NOT_ALLOWED,
                                                        GAPI.CONDITION_NOT_MET, GAPI.CONFLICT],
               retryReasons=GAPI.MEMBERS_RETRY_REASONS,
               groupKey=group, body=body, fields='')
      _showSuccess(group, member, role, delivery_settings, j, jcount)
    except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden):
      entityUnknownWarning(entityType, group, i, count)
    except (GAPI.duplicate, GAPI.memberNotFound, GAPI.resourceNotFound,
            GAPI.invalidMember, GAPI.cyclicMembershipsNotAllowed, GAPI.conditionNotMet, GAPI.serviceNotAvailable) as e:
      _showFailure(group, member, role, str(e), j, jcount)
    except GAPI.conflict:
      _showSuccess(group, member, role, delivery_settings, j, jcount, Msg.ACTION_MAY_BE_DELAYED)

  def _handleDuplicateAdd(group, i, count, role, delivery_settings, member, j, jcount):
    try:
      result = callGAPI(cd.members(), 'get',
                        throwReasons=[GAPI.MEMBER_NOT_FOUND, GAPI.RESOURCE_NOT_FOUND],
                        groupKey=group, memberKey=member, fields='role')
      _showFailure(group, member, role, Msg.DUPLICATE_ALREADY_A_ROLE.format(Ent.Singular(result['role'])), j, jcount)
      return
    except (GAPI.memberNotFound, GAPI.resourceNotFound):
      pass
    printEntityKVList([entityType, group, Ent.MEMBER, member], [Msg.MEMBERSHIP_IS_PENDING_WILL_DELETE_ADD_TO_ACCEPT], j, jcount)
    try:
      callGAPI(cd.members(), 'delete',
               throwReasons=[GAPI.MEMBER_NOT_FOUND, GAPI.RESOURCE_NOT_FOUND],
               groupKey=group, memberKey=member)
    except (GAPI.memberNotFound, GAPI.resourceNotFound):
      _showFailure(group, member, role, Msg.DUPLICATE, j, jcount)
      return
    _addMember(group, i, count, role, delivery_settings, member, j, jcount)

  _ADD_MEMBER_REASON_TO_MESSAGE_MAP = {GAPI.DUPLICATE: Msg.DUPLICATE,
                                       GAPI.CONDITION_NOT_MET: Msg.DUPLICATE,
                                       GAPI.MEMBER_NOT_FOUND: Msg.DOES_NOT_EXIST,
                                       GAPI.RESOURCE_NOT_FOUND: Msg.DOES_NOT_EXIST,
                                       GAPI.INVALID_MEMBER: Msg.INVALID_MEMBER,
                                       GAPI.CYCLIC_MEMBERSHIPS_NOT_ALLOWED: Msg.WOULD_MAKE_MEMBERSHIP_CYCLE}

  def _callbackAddGroupMembers(request_id, _, exception):
    ri = request_id.splitlines()
    if exception is None:
      _showSuccess(ri[RI_ENTITY], ri[RI_ITEM], ri[RI_ROLE], ri[RI_OPTION], int(ri[RI_J]), int(ri[RI_JCOUNT]))
    else:
      http_status, reason, message = checkGAPIError(exception)
      if reason in GAPI.MEMBERS_THROW_REASONS and reason != GAPI.SERVICE_NOT_AVAILABLE:
        entityUnknownWarning(entityType, ri[RI_ENTITY], int(ri[RI_I]), int(ri[RI_COUNT]))
      elif reason == GAPI.CONFLICT:
        _showSuccess(ri[RI_ENTITY], ri[RI_ITEM], ri[RI_ROLE], ri[RI_OPTION], int(ri[RI_J]), int(ri[RI_JCOUNT]), Msg.ACTION_MAY_BE_DELAYED)
      elif reason in [GAPI.DUPLICATE, GAPI.CONDITION_NOT_MET]:
        _handleDuplicateAdd(ri[RI_ENTITY], int(ri[RI_I]), int(ri[RI_COUNT]), ri[RI_ROLE], ri[RI_OPTION], ri[RI_ITEM], int(ri[RI_J]), int(ri[RI_JCOUNT]))
      elif reason not in GAPI.DEFAULT_RETRY_REASONS+GAPI.MEMBERS_RETRY_REASONS:
        errMsg = getHTTPError(_ADD_MEMBER_REASON_TO_MESSAGE_MAP, http_status, reason, message)
        _showFailure(ri[RI_ENTITY], ri[RI_ITEM], ri[RI_ROLE], errMsg, int(ri[RI_J]), int(ri[RI_JCOUNT]))
      else:
        if addBatchParms['adjust']:
          addBatchParms['adjust'] = False
          addBatchParms['wait'] += 0.25
          writeStderr(f'{WARNING_PREFIX}{Msg.INTER_BATCH_WAIT_INCREASED.format(addBatchParms["wait"])}\n')
        time.sleep(0.1)
        _addMember(ri[RI_ENTITY], int(ri[RI_I]), int(ri[RI_COUNT]), ri[RI_ROLE], ri[RI_OPTION], ri[RI_ITEM], int(ri[RI_J]), int(ri[RI_JCOUNT]))

  def _batchAddGroupMembers(group, i, count, addMembers, role, delivery_settings):
    Act.Set([Act.ADD, Act.ADD_PREVIEW][preview])
    jcount = len(addMembers)
    entityPerformActionNumItems([entityType, group], jcount, role, i, count)
    if jcount == 0:
      return
    if preview:
      _previewAction(group, addMembers, role, jcount, Act.ADD)
      return
    if addBatchParms['size'] == 1 or jcount <= addBatchParms['size']:
      Ind.Increment()
      j = 0
      for member in addMembers:
        j += 1
        _addMember(group, i, count, role, delivery_settings, member, j, jcount)
      Ind.Decrement()
      return
    body = {'role': role}
    if delivery_settings != DELIVERY_SETTINGS_UNDEFINED:
      body['delivery_settings'] = delivery_settings
    svcargs = dict([('groupKey', group), ('body', body), ('fields', '')]+GM.Globals[GM.EXTRA_ARGS_LIST])
    method = getattr(cd.members(), 'insert')
    dbatch = cd.new_batch_http_request(callback=_callbackAddGroupMembers)
    bcount = 0
    Ind.Increment()
    j = 0
    for member in addMembers:
      j += 1
      svcparms = svcargs.copy()
      if member.find('@') != -1:
        svcparms['body']['email'] = member
        svcparms['body'].pop('id', None)
      else:
        svcparms['body']['id'] = member
        svcparms['body'].pop('email', None)
      dbatch.add(method(**svcparms), request_id=batchRequestID(group, i, count, j, jcount, member, role, delivery_settings))
      bcount += 1
      if bcount >= addBatchParms['size']:
        addBatchParms['adjust'] = True
        _executeBatch(dbatch, addBatchParms)
        dbatch = cd.new_batch_http_request(callback=_callbackAddGroupMembers)
        bcount = 0
    if bcount > 0:
      dbatch.execute()
    Ind.Decrement()

  def _removeMember(group, i, count, role, member, j, jcount):
    try:
      callGAPI(cd.members(), 'delete',
               throwReasons=GAPI.MEMBERS_THROW_REASONS+[GAPI.MEMBER_NOT_FOUND, GAPI.INVALID_MEMBER,
                                                        GAPI.RESOURCE_NOT_FOUND,
                                                        GAPI.CONDITION_NOT_MET, GAPI.CONFLICT],
               retryReasons=GAPI.MEMBERS_RETRY_REASONS,
               groupKey=group, memberKey=member)
      _showSuccess(group, member, role, DELIVERY_SETTINGS_UNDEFINED, j, jcount)
    except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden):
      entityUnknownWarning(entityType, group, i, count)
    except (GAPI.memberNotFound, GAPI.invalidMember, GAPI.resourceNotFound, GAPI.conditionNotMet, GAPI.serviceNotAvailable) as e:
      _showFailure(group, member, role, str(e), j, jcount)
    except GAPI.conflict:
      _showSuccess(group, member, role, DELIVERY_SETTINGS_UNDEFINED, j, jcount, Msg.ACTION_MAY_BE_DELAYED)

  _REMOVE_MEMBER_REASON_TO_MESSAGE_MAP = {GAPI.MEMBER_NOT_FOUND: f'{Msg.NOT_A} {Ent.Singular(Ent.MEMBER)}',
                                          GAPI.CONDITION_NOT_MET: f'{Msg.NOT_A} {Ent.Singular(Ent.MEMBER)}',
                                          GAPI.INVALID_MEMBER: Msg.DOES_NOT_EXIST}

  def _callbackRemoveGroupMembers(request_id, _, exception):
    ri = request_id.splitlines()
    if exception is None:
      _showSuccess(ri[RI_ENTITY], ri[RI_ITEM], ri[RI_ROLE], DELIVERY_SETTINGS_UNDEFINED, int(ri[RI_J]), int(ri[RI_JCOUNT]))
    else:
      http_status, reason, message = checkGAPIError(exception)
      if reason in GAPI.MEMBERS_THROW_REASONS and reason != GAPI.SERVICE_NOT_AVAILABLE:
        entityUnknownWarning(entityType, ri[RI_ENTITY], int(ri[RI_I]), int(ri[RI_COUNT]))
      elif reason == GAPI.CONFLICT:
        _showSuccess(ri[RI_ENTITY], ri[RI_ITEM], ri[RI_ROLE], DELIVERY_SETTINGS_UNDEFINED, int(ri[RI_J]), int(ri[RI_JCOUNT]), Msg.ACTION_MAY_BE_DELAYED)
      elif reason not in GAPI.DEFAULT_RETRY_REASONS+GAPI.MEMBERS_RETRY_REASONS+[GAPI.RESOURCE_NOT_FOUND]:
        errMsg = getHTTPError(_REMOVE_MEMBER_REASON_TO_MESSAGE_MAP, http_status, reason, message)
        _showFailure(ri[RI_ENTITY], ri[RI_ITEM], ri[RI_ROLE], errMsg, int(ri[RI_J]), int(ri[RI_JCOUNT]))
      else:
        if remBatchParms['adjust']:
          remBatchParms['adjust'] = False
          remBatchParms['wait'] += 0.25
          writeStderr(f'{WARNING_PREFIX}{Msg.INTER_BATCH_WAIT_INCREASED.format(remBatchParms["wait"])}\n')
        time.sleep(0.1)
        _removeMember(ri[RI_ENTITY], int(ri[RI_I]), int(ri[RI_COUNT]), ri[RI_ROLE], ri[RI_ITEM], int(ri[RI_J]), int(ri[RI_JCOUNT]))

  def _batchRemoveGroupMembers(group, i, count, removeMembers, role, qualifier=''):
    Act.Set([Act.REMOVE, Act.REMOVE_PREVIEW][preview])
    jcount = len(removeMembers)
    if not qualifier:
      entityPerformActionNumItems([entityType, group], jcount, role, i, count)
    else:
      entityPerformActionNumItemsModifier([entityType, group], jcount, role, qualifier, i, count)
    if jcount == 0:
      return
    if preview:
      _previewAction(group, removeMembers, role, jcount, Act.REMOVE)
      return
    if remBatchParms['size'] == 1 or jcount <= remBatchParms['size']:
      Ind.Increment()
      j = 0
      for member in removeMembers:
        j += 1
        _removeMember(group, i, count, role, member, j, jcount)
      Ind.Decrement()
      return
    svcargs = dict([('groupKey', group), ('memberKey', None)]+GM.Globals[GM.EXTRA_ARGS_LIST])
    method = getattr(cd.members(), 'delete')
    dbatch = cd.new_batch_http_request(callback=_callbackRemoveGroupMembers)
    bcount = 0
    Ind.Increment()
    j = 0
    for member in removeMembers:
      j += 1
      svcparms = svcargs.copy()
      svcparms['memberKey'] = member
      dbatch.add(method(**svcparms), request_id=batchRequestID(group, i, count, j, jcount, svcparms['memberKey'], role))
      bcount += 1
      if bcount >= remBatchParms['size']:
        remBatchParms['adjust'] = True
        _executeBatch(dbatch, remBatchParms)
        dbatch = cd.new_batch_http_request(callback=_callbackRemoveGroupMembers)
        bcount = 0
    if bcount > 0:
      dbatch.execute()
    Ind.Decrement()

  _UPDATE_MEMBER_REASON_TO_MESSAGE_MAP = {GAPI.MEMBER_NOT_FOUND: f'{Msg.NOT_A} {Ent.Singular(Ent.MEMBER)}',
                                          GAPI.INVALID_MEMBER: Msg.DOES_NOT_EXIST,
                                          GAPI.RESOURCE_NOT_FOUND: Msg.DOES_NOT_EXIST}

  def _getUpdateBody(role, delivery_settings):
    body = {}
    if role is not None:
      body['role'] = role
    else:
      if delivery_settings == DELIVERY_SETTINGS_UNDEFINED:
        # Backwards compatability; if neither role or delivery is specified, role = MEMBER
        role = Ent.ROLE_MEMBER
        body['role'] = role
    if delivery_settings != DELIVERY_SETTINGS_UNDEFINED:
      body['delivery_settings'] = delivery_settings
    return (body, role)

  def _updateMember(group, i, count, role, delivery_settings, member, j, jcount):
    body, role = _getUpdateBody(role, delivery_settings)
    try:
      callGAPI(cd.members(), 'patch',
               throwReasons=GAPI.MEMBERS_THROW_REASONS+[GAPI.MEMBER_NOT_FOUND, GAPI.INVALID_MEMBER, GAPI.RESOURCE_NOT_FOUND],
               retryReasons=GAPI.MEMBERS_RETRY_REASONS,
               groupKey=group, memberKey=member, body=body, fields='')
      _showSuccess(group, member, role, delivery_settings, j, jcount)
    except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden):
      entityUnknownWarning(entityType, group, i, count)
    except GAPI.memberNotFound as e:
      if createIfNotFound:
        Act.Set(Act.ADD)
        _addMember(group, i, count, role, delivery_settings, member, j, jcount)
        Act.Set(Act.UPDATE)
      else:
        _showFailure(group, member, role, str(e), j, jcount)
    except (GAPI.invalidMember, GAPI.resourceNotFound, GAPI.serviceNotAvailable) as e:
      _showFailure(group, member, role, str(e), j, jcount)

  def _callbackUpdateGroupMembers(request_id, _, exception):
    ri = request_id.splitlines()
    if exception is None:
      _showSuccess(ri[RI_ENTITY], ri[RI_ITEM], ri[RI_ROLE], ri[RI_OPTION], int(ri[RI_J]), int(ri[RI_JCOUNT]))
    else:
      http_status, reason, message = checkGAPIError(exception)
      if reason == GAPI.MEMBER_NOT_FOUND and createIfNotFound:
        Act.Set(Act.ADD)
        _addMember(ri[RI_ENTITY], int(ri[RI_I]), int(ri[RI_COUNT]), ri[RI_ROLE], ri[RI_OPTION], ri[RI_ITEM], int(ri[RI_J]), int(ri[RI_JCOUNT]))
        Act.Set(Act.UPDATE)
      elif reason in GAPI.MEMBERS_THROW_REASONS and reason != GAPI.SERVICE_NOT_AVAILABLE:
        entityUnknownWarning(entityType, ri[RI_ENTITY], int(ri[RI_I]), int(ri[RI_COUNT]))
      elif reason not in GAPI.DEFAULT_RETRY_REASONS+GAPI.MEMBERS_RETRY_REASONS:
        errMsg = getHTTPError(_UPDATE_MEMBER_REASON_TO_MESSAGE_MAP, http_status, reason, message)
        _showFailure(ri[RI_ENTITY], ri[RI_ITEM], ri[RI_ROLE], errMsg, int(ri[RI_J]), int(ri[RI_JCOUNT]))
      else:
        if addBatchParms['adjust']:
          addBatchParms['adjust'] = False
          addBatchParms['wait'] += 0.25
          writeStderr(f'{WARNING_PREFIX}{Msg.INTER_BATCH_WAIT_INCREASED.format(addBatchParms["wait"])}\n')
        time.sleep(0.1)
        _updateMember(ri[RI_ENTITY], int(ri[RI_I]), int(ri[RI_COUNT]), ri[RI_ROLE], ri[RI_OPTION], ri[RI_ITEM], int(ri[RI_J]), int(ri[RI_JCOUNT]))

  def _batchUpdateGroupMembers(group, i, count, updateMembers, role, delivery_settings):
    Act.Set([Act.UPDATE, Act.UPDATE_PREVIEW][preview])
    jcount = len(updateMembers)
    entityPerformActionNumItems([entityType, group], jcount, Ent.MEMBER, i, count)
    if jcount == 0:
      return
    if preview:
      _previewAction(group, updateMembers, role or Ent.ROLE_USER, jcount, Act.UPDATE)
      return
    if updBatchParms['size'] == 1 or jcount <= updBatchParms['size']:
      Ind.Increment()
      j = 0
      for member in updateMembers:
        j += 1
        _updateMember(group, i, count, role, delivery_settings, member, j, jcount)
      Ind.Decrement()
      return
    body, role = _getUpdateBody(role, delivery_settings)
    svcargs = dict([('groupKey', group), ('memberKey', None), ('body', body), ('fields', '')]+GM.Globals[GM.EXTRA_ARGS_LIST])
    method = getattr(cd.members(), 'patch')
    dbatch = cd.new_batch_http_request(callback=_callbackUpdateGroupMembers)
    bcount = 0
    Ind.Increment()
    j = 0
    for member in updateMembers:
      j += 1
      svcparms = svcargs.copy()
      svcparms['memberKey'] = member
      dbatch.add(method(**svcparms), request_id=batchRequestID(group, i, count, j, jcount, svcparms['memberKey'], role, delivery_settings))
      bcount += 1
      if bcount >= updBatchParms['size']:
        updBatchParms['adjust'] = True
        _executeBatch(dbatch, updBatchParms)
        dbatch = cd.new_batch_http_request(callback=_callbackUpdateGroupMembers)
        bcount = 0
    if bcount > 0:
      dbatch.execute()
    Ind.Decrement()

  cd = buildGAPIObject(API.DIRECTORY)
  ci = None
  entityType = Ent.GROUP
  csvPF = None
  getBeforeUpdate = preview = False
  entityList = getEntityList(Cmd.OB_GROUP_ENTITY)
  CL_subCommand = getChoice(UPDATE_GROUP_SUBCMDS, defaultChoice=None)
  addBatchParms = {'size': GC.Values[GC.BATCH_SIZE], 'wait': GC.Values[GC.INTER_BATCH_WAIT], 'adjust': True}
  remBatchParms = {'size': GC.Values[GC.BATCH_SIZE], 'wait': GC.Values[GC.INTER_BATCH_WAIT], 'adjust': True}
  updBatchParms = {'size': GC.Values[GC.BATCH_SIZE], 'wait': GC.Values[GC.INTER_BATCH_WAIT], 'adjust': True}
  if not CL_subCommand:
    body = {}
    gs_body = {}
    ci_body = {}
    verifyNotInvitable = False
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg == 'email':
        body['email'] = getEmailAddress(noUid=True)
      elif myarg == 'admincreated':
        body['adminCreated'] = getBoolean()
      elif myarg == 'getbeforeupdate':
        getBeforeUpdate = True
      elif myarg in {'security', 'makesecuritygroup'}:
        ci_body['labels'] = {CIGROUP_DISCUSSION_FORUM_LABEL: '',
                             CIGROUP_SECURITY_LABEL: ''}
      elif myarg == 'json':
        gs_body.update(getJSON(GROUP_JSON_SKIP_FIELDS))
      elif myarg == 'accesstype':
        gs_body.update(getChoice(GROUP_ACCESS_TYPE_CHOICE_MAP, mapChoice=True))
      elif myarg == 'verifynotinvitable':
        verifyNotInvitable = True
      else:
        getGroupAttrValue(myarg, gs_body)
    if 'email' in body and verifyNotInvitable:
      isInvitableUser, _ = _getIsInvitableUser(None, body['email'])
      if isInvitableUser:
        entityActionNotPerformedWarning([Ent.GROUP, body['email']], Msg.EMAIL_ADDRESS_IS_UNMANAGED_ACCOUNT)
        return
    if ci_body:
      ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
      if 'email' in body:
        ci_body['groupKey'] = {'id': body.pop('email')}
    if gs_body:
      gs = buildGAPIObject(API.GROUPSSETTINGS)
      gs_body = getSettingsFromGroup(cd, ','.join(entityList), gs, gs_body)
      if ci_body:
        for k, v in iter(GROUP_CIGROUP_FIELDS_MAP.items()):
          if k in gs_body:
            ci_body[v] = gs_body.pop(k)
      if gs_body:
        if not getBeforeUpdate:
          settings = gs_body
      elif not ci_body:
        return
    elif not body and not ci_body:
      return
    Act.Set(Act.UPDATE)
    i = 0
    count = len(entityList)
    for group in entityList:
      i += 1
      ci, _, group = convertGroupCloudIDToEmail(ci, group, i, count)
      if gs_body and not GroupIsAbuseOrPostmaster(group):
        try:
          if group.find('@') == -1: # group settings API won't take uid so we make sure cd API is used so that we can grab real email.
            group = callGAPI(cd.groups(), 'get',
                             throwReasons=GAPI.GROUP_GET_THROW_REASONS, retryReasons=GAPI.GROUP_GET_RETRY_REASONS,
                             groupKey=group, fields='email')['email']
          if getBeforeUpdate:
            settings = callGAPI(gs.groups(), 'get',
                                throwReasons=GAPI.GROUP_SETTINGS_THROW_REASONS, retryReasons=GAPI.GROUP_SETTINGS_RETRY_REASONS,
                                groupUniqueId=mapGroupEmailForSettings(group), fields='*')
            settings.update(gs_body)
          if not checkReplyToCustom(group, settings, i, count):
            continue
        except GAPI.notFound:
          entityActionFailedWarning([entityType, group], Msg.DOES_NOT_EXIST, i, count)
          continue
        except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
                GAPI.backendError, GAPI.invalid, GAPI.invalidInput, GAPI.badRequest, GAPI.permissionDenied,
                GAPI.systemError, GAPI.serviceLimit, GAPI.serviceNotAvailable, GAPI.authError) as e:
          entityActionFailedWarning([entityType, group], str(e), i, count)
          continue
      if body:
        try:
          group = callGAPI(cd.groups(), 'update',
                           throwReasons=GAPI.GROUP_UPDATE_THROW_REASONS, retryReasons=GAPI.GROUP_GET_RETRY_REASONS,
                           groupKey=group, body=body, fields='email')['email']
        except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.backendError, GAPI.badRequest, GAPI.invalid, GAPI.invalidInput,
                GAPI.systemError, GAPI.permissionDenied, GAPI.failedPrecondition, GAPI.forbidden) as e:
          entityActionFailedWarning([entityType, group], str(e), i, count)
          continue
      if gs_body and not GroupIsAbuseOrPostmaster(group):
        try:
          callGAPI(gs.groups(), 'update',
                   bailOnInvalidError='messageModerationLevel' in settings,
                   throwReasons=GAPI.GROUP_SETTINGS_THROW_REASONS, retryReasons=GAPI.GROUP_SETTINGS_RETRY_REASONS,
                   groupUniqueId=mapGroupEmailForSettings(group), body=settings, fields='')
        except GAPI.notFound:
          entityActionFailedWarning([entityType, group], Msg.DOES_NOT_EXIST, i, count)
          continue
        except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.backendError,
                GAPI.invalid, GAPI.invalidArgument, GAPI.invalidAttributeValue, GAPI.invalidInput, GAPI.badRequest, GAPI.permissionDenied,
                GAPI.systemError, GAPI.serviceLimit, GAPI.serviceNotAvailable, GAPI.authError) as e:
          entityActionFailedWarning([entityType, group], str(e), i, count)
          continue
        except GAPI.required:
          entityActionFailedWarning([entityType, group], Msg.INVALID_JSON_SETTING, i, count)
          continue
      if ci_body:
        _, name, _ = convertGroupEmailToCloudID(ci, group, i, count)
        if not name:
          continue
        try:
          callGAPI(ci.groups(), 'patch',
                   throwReasons=GAPI.CIGROUP_UPDATE_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                   name=name, body=ci_body, updateMask=','.join(list(ci_body.keys())))
        except (GAPI.notFound, GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
                GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.invalidInput, GAPI.invalidArgument,
                GAPI.systemError, GAPI.permissionDenied, GAPI.failedPrecondition, GAPI.serviceNotAvailable) as e:
          entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, group], str(e), i, count)
          continue
      entityActionPerformed([entityType, group], i, count)
  elif CL_subCommand in {'create', 'add'}:
    baseRole, groupMemberType = _getRoleGroupMemberType()
    isSuspended, isArchived = _getOptionalIsSuspendedIsArchived()
    delivery_settings = getDeliverySettings()
    preview, csvPF = _getPreviewActionCSV()
    _, addMembers = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS,
                                      isSuspended=isSuspended, isArchived=isArchived, groupMemberType=groupMemberType)
    groupMemberLists = addMembers if isinstance(addMembers, dict) else None
    subkeyRoleField = GM.Globals[GM.CSV_SUBKEY_FIELD]
    checkForExtraneousArguments()
    i = 0
    count = len(entityList)
    for group in entityList:
      i += 1
      roleList = [baseRole]
      if groupMemberLists:
        if not subkeyRoleField:
          addMembers = groupMemberLists[group]
        else:
          roleList = groupMemberLists[group]
      origGroup = group
      ci, _, group = checkGroupExists(cd, ci, False, group, i, count)
      if not group:
        continue
      for role in roleList:
        if groupMemberLists and subkeyRoleField:
          role, addMembers = _validateSubkeyRoleGetMembers(group, role, origGroup, groupMemberLists, i, count)
          if role is None:
            continue
        _batchAddGroupMembers(group, i, count,
                              [convertUIDtoEmailAddress(member, cd=cd, emailTypes='any',
                                                        checkForCustomerId=True) for member in addMembers],
                              role, delivery_settings)
  elif CL_subCommand in {'delete', 'remove'}:
    baseRole, groupMemberType = _getRoleGroupMemberType()
    isSuspended, isArchived = _getOptionalIsSuspendedIsArchived()
    preview, csvPF = _getPreviewActionCSV()
    _, removeMembers = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS,
                                         isSuspended=isSuspended, isArchived=isArchived, groupMemberType=groupMemberType)
    groupMemberLists = removeMembers if isinstance(removeMembers, dict) else None
    subkeyRoleField = GM.Globals[GM.CSV_SUBKEY_FIELD]
    checkForExtraneousArguments()
    i = 0
    count = len(entityList)
    for group in entityList:
      i += 1
      roleList = [baseRole]
      if groupMemberLists:
        if not subkeyRoleField:
          removeMembers = groupMemberLists[group]
        else:
          roleList = groupMemberLists[group]
      origGroup = group
      ci, _, group = checkGroupExists(cd, ci, False, group, i, count)
      if not group:
        continue
      for role in roleList:
        if groupMemberLists and subkeyRoleField:
          role, removeMembers = _validateSubkeyRoleGetMembers(group, role, origGroup, groupMemberLists, i, count)
          if role is None:
            continue
        _batchRemoveGroupMembers(group, i, count,
                                 [convertUIDtoEmailAddress(member, cd=cd, emailTypes='any',
                                                           checkForCustomerId=True) for member in removeMembers],
                                 role)
  elif CL_subCommand == 'sync':
    baseRole, groupMemberType = _getRoleGroupMemberType(allowIgnoreRole=True)
    ignoreRole = baseRole == Ent.ROLE_ALL
    syncOperation = getSyncOperation()
    isSuspended, isArchived = _getOptionalIsSuspendedIsArchived()
    removeDomainNoStatusMembers = checkArgumentPresent('removedomainnostatusmembers')
    delivery_settings = getDeliverySettings()
    preview, csvPF = _getPreviewActionCSV()
    additionalMembers = {}
    while checkArgumentPresent('additionalmembers'):
      additionalRole = getChoice(GROUP_ROLES_MAP, defaultChoice=baseRole, mapChoice=True)
      additionalMembers.setdefault(additionalRole, [])
      additionalMembers[additionalRole].extend(getEntityList(Cmd.OB_EMAIL_ADDRESS_ENTITY))
    _, syncMembers = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS,
                                       isSuspended=isSuspended, isArchived=isArchived, groupMemberType=groupMemberType)
    groupMemberLists = syncMembers if isinstance(syncMembers, dict) else None
    subkeyRoleField = GM.Globals[GM.CSV_SUBKEY_FIELD]
    syncMembersSets = {}
    syncMembersMaps = {}
    currentMembersSets = {}
    currentMembersMaps = {}
    domainNoStatusMembersSets = {}
    if groupMemberLists is None:
      syncMembersSets[baseRole] = set()
      syncMembersMaps[baseRole] = {}
      for member in syncMembers:
        syncMembersSets[baseRole].add(_cleanConsumerAddress(convertUIDtoEmailAddress(member, cd=cd, emailTypes='any',
                                                                                     checkForCustomerId=True), syncMembersMaps[baseRole]))
      for member in additionalMembers.get(baseRole, []):
        syncMembersSets[baseRole].add(_cleanConsumerAddress(convertUIDtoEmailAddress(member, cd=cd, emailTypes='any',
                                                                                     checkForCustomerId=True), syncMembersMaps[baseRole]))
    checkForExtraneousArguments()
    i = 0
    count = len(entityList)
    for group in entityList:
      i += 1
      origGroup = group
      ci, _, group = checkGroupExists(cd, ci, False, group, i, count)
      if not group:
        continue
      if groupMemberLists is None:
        roleList = [baseRole]
      else:
        if not subkeyRoleField:
          roleList = [baseRole]
        else:
          roleList = groupMemberLists[origGroup]
        for role in roleList:
          role = role.upper()
          syncMembersSets[role] = set()
          syncMembersMaps[role] = {}
      rolesSet = set()
      for role in roleList:
        origRole = role
        role = role.upper()
        if groupMemberLists is None:
          rolesSet.add(role)
        else:
          if not subkeyRoleField:
            rolesSet.add(role)
            syncMembers = groupMemberLists[origGroup]
          else:
            role, syncMembers = _validateSubkeyRoleGetMembers(group, origRole, origGroup, groupMemberLists, i, count)
            if role is None:
              continue
            rolesSet.add(role)
          for member in syncMembers:
            syncMembersSets[role].add(_cleanConsumerAddress(convertUIDtoEmailAddress(member, cd=cd, emailTypes='any',
                                                                                     checkForCustomerId=True), syncMembersMaps[role]))
          for member in additionalMembers.get(role, []):
            syncMembersSets[role].add(_cleanConsumerAddress(convertUIDtoEmailAddress(member, cd=cd, emailTypes='any',
                                                                                     checkForCustomerId=True), syncMembersMaps[role]))
      if not rolesSet:
        continue
      memberRoles = ','.join(sorted(rolesSet))
      printGettingAllEntityItemsForWhom(memberRoles, group, entityType=entityType)
      try:
        result = callGAPIpages(cd.members(), 'list', 'members',
                               pageMessage=getPageMessageForWhom(),
                               throwReasons=GAPI.MEMBERS_THROW_REASONS, retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                               groupKey=group,
                               roles=None if Ent.ROLE_MEMBER in rolesSet or ignoreRole else memberRoles,
                               fields='nextPageToken,members(email,id,type,status,role)',
                               maxResults=GC.Values[GC.MEMBER_MAX_RESULTS])
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden, GAPI.serviceNotAvailable):
        entityUnknownWarning(entityType, group, i, count)
        continue
      for role in rolesSet:
        currentMembersSets[role] = set()
        currentMembersMaps[role] = {}
        domainNoStatusMembersSets[role] = set()
      for member in result:
        role = member.get('role', Ent.ROLE_MEMBER) if not ignoreRole else Ent.ROLE_ALL
        email, memberStatus = _getMemberEmailStatus(member)
        if groupMemberType in ('ALL', member['type']) and role in rolesSet:
          if not removeDomainNoStatusMembers or memberStatus != 'NONE':
            if _checkMemberStatusIsSuspendedIsArchived(memberStatus, isSuspended, isArchived):
              currentMembersSets[role].add(_cleanConsumerAddress(email, currentMembersMaps[role]))
          else:
            domainNoStatusMembersSets[role].add(member['id'])
      del result
      if syncOperation != 'addonly':
        for role in rolesSet:
          if domainNoStatusMembersSets[role]:
            _batchRemoveGroupMembers(group, i, count,
                                     domainNoStatusMembersSets[role],
                                     role)
          _batchRemoveGroupMembers(group, i, count,
                                   [currentMembersMaps[role].get(emailAddress, emailAddress) for emailAddress in currentMembersSets[role]-syncMembersSets[role]],
                                   role)
      if syncOperation != 'removeonly':
        for role in [Ent.ROLE_OWNER, Ent.ROLE_MANAGER, Ent.ROLE_MEMBER, Ent.ROLE_ALL]:
          if role in rolesSet:
            _batchAddGroupMembers(group, i, count,
                                  [syncMembersMaps[role].get(emailAddress, emailAddress) for emailAddress in syncMembersSets[role]-currentMembersSets[role]],
                                  role if role != Ent.ROLE_ALL else Ent.ROLE_MEMBER, delivery_settings)
  elif CL_subCommand == 'update':
    baseRole, groupMemberType = _getRoleGroupMemberType(defaultRole=None)
    isSuspended, isArchived = _getOptionalIsSuspendedIsArchived()
    delivery_settings = getDeliverySettings()
    preview, csvPF = _getPreviewActionCSV()
    createIfNotFound = checkArgumentPresent('createifnotfound')
    _, updateMembers = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS,
                                         isSuspended=isSuspended, isArchived=isArchived, groupMemberType=groupMemberType)
    groupMemberLists = updateMembers if isinstance(updateMembers, dict) else None
    subkeyRoleField = GM.Globals[GM.CSV_SUBKEY_FIELD]
    checkForExtraneousArguments()
    i = 0
    count = len(entityList)
    for group in entityList:
      i += 1
      roleList = [baseRole]
      if groupMemberLists:
        if not subkeyRoleField:
          updateMembers = groupMemberLists[group]
        else:
          roleList = groupMemberLists[group]
      origGroup = group
      ci, _, group = checkGroupExists(cd, ci, False, group, i, count)
      if not group:
        continue
      for role in roleList:
        if groupMemberLists and subkeyRoleField:
          role, updateMembers = _validateSubkeyRoleGetMembers(group, role, origGroup, groupMemberLists, i, count)
          if role is None:
            continue
        _batchUpdateGroupMembers(group, i, count,
                                 [convertUIDtoEmailAddress(member, cd=cd, emailTypes='any', checkForCustomerId=True) for member in updateMembers],
                                 role, delivery_settings)
  else: #clear
    rolesSet = set()
    groupMemberType = 'ALL'
    isSuspended = isArchived = None
    removeDomainNoStatusMembers = False
    emailMatchPattern = None
    clearMatch = True
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg in GROUP_ROLES_MAP:
        rolesSet.add(GROUP_ROLES_MAP[myarg])
      elif myarg == 'usersonly':
        groupMemberType = Ent.TYPE_USER
      elif myarg == 'groupsonly':
        groupMemberType = Ent.TYPE_GROUP
      elif myarg in SUSPENDED_ARGUMENTS:
        isSuspended = _getIsSuspended(myarg)
      elif myarg in ARCHIVED_ARGUMENTS:
        isArchived = _getIsArchived(myarg)
      elif myarg == 'removedomainnostatusmembers':
        removeDomainNoStatusMembers = True
      elif myarg in {'emailclearpattern', 'emailretainpattern'}:
        emailMatchPattern = getREPattern(re.IGNORECASE)
        clearMatch = myarg == 'emailclearpattern'
      elif myarg == 'preview':
        preview = True
      elif myarg == 'actioncsv':
        csvPF = CSVPrintFile(GROUP_PREVIEW_TITLES)
      else:
        unknownArgumentExit()
    if isSuspended is not None and isArchived is not None:
      if isSuspended == isArchived:
        qualifier = '(Suspended) (Archived)' if isSuspended else '(Non-Suspended) (Non-Archived)'
      else:
        qualifier = '(Suspended)' if isSuspended else '(Archived)'
    elif isSuspended is not None:
      qualifier = '(Suspended)' if isSuspended else '(Non-Suspended)'
    elif isArchived is not None:
      qualifier = '(Archived)' if isArchived else '(Non-Archived)'
    else:
      qualifier = ''
    Act.Set(Act.REMOVE)
    if not rolesSet:
      rolesSet.add(Ent.ROLE_MEMBER)
    memberRoles = ','.join(sorted(rolesSet))
    i = 0
    count = len(entityList)
    for group in entityList:
      i += 1
      ci, _, group = checkGroupExists(cd, ci, False, group, i, count)
      if not group:
        continue
      printGettingAllEntityItemsForWhom(memberRoles, group, entityType=entityType)
      try:
        result = callGAPIpages(cd.members(), 'list', 'members',
                               pageMessage=getPageMessageForWhom(),
                               throwReasons=GAPI.MEMBERS_THROW_REASONS, retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                               groupKey=group, roles=None if Ent.ROLE_MEMBER in rolesSet else memberRoles,
                               fields='nextPageToken,members(email,id,type,status,role)',
                               maxResults=GC.Values[GC.MEMBER_MAX_RESULTS])
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden, GAPI.serviceNotAvailable):
        entityUnknownWarning(entityType, group, i, count)
        continue
      removeMembers = {}
      for role in rolesSet:
        removeMembers[role] = set()
      for member in result:
        role = member.get('role', Ent.ROLE_MEMBER)
        email, memberStatus = _getMemberEmailStatus(member)
        if groupMemberType in ('ALL', member['type']) and role in rolesSet:
          if not removeDomainNoStatusMembers:
            if _checkMemberStatusIsSuspendedIsArchived(memberStatus, isSuspended, isArchived):
              if emailMatchPattern is None:
                removeMembers[role].add(email if memberStatus != 'UNKNOWN' else member['id'])
              elif member['type'] == Ent.TYPE_CUSTOMER:
                pass
              elif emailMatchPattern.match(email):
                if clearMatch:
                  removeMembers[role].add(email if memberStatus != 'UNKNOWN' else member['id'])
              else:
                if not clearMatch:
                  removeMembers[role].add(email if memberStatus != 'UNKNOWN' else member['id'])
          elif memberStatus == 'NONE':
            removeMembers[role].add(member['id'])
      del result
      for role in rolesSet:
        _batchRemoveGroupMembers(group, i, count, removeMembers[role], role, qualifier)
  if csvPF:
    csvPF.writeCSVfile('Group Updates')

def verifyGroupPrimaryEmail(cd, group, i, count):
  try:
    result = callGAPI(cd.groups(), 'get',
                      throwReasons=GAPI.GROUP_GET_THROW_REASONS,
                      groupKey=group, fields='id,email')
    if (result['email'].lower() == group) or (result['id'] == group):
      return True
    entityActionNotPerformedWarning([Ent.GROUP, group], Msg.NOT_A_PRIMARY_EMAIL_ADDRESS, i, count)
    return False
  except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.backendError, GAPI.systemError):
    pass
  entityUnknownWarning(Ent.GROUP, group, i, count)
  return False

# gam delete groups <GroupEntity> [noactionifalias]
def doDeleteGroups(ciGroupsAPI=False):
  if not ciGroupsAPI:
    cd = buildGAPIObject(API.DIRECTORY)
    ci = None
  else:
    ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
  entityType = GROUP_CIGROUP_ENTITYTYPE_MAP[ciGroupsAPI]
  entityList = getEntityList(Cmd.OB_GROUP_ENTITY)
  if not ciGroupsAPI:
    noActionIfAlias = checkArgumentPresent('noactionifalias')
  checkForExtraneousArguments()
  i = 0
  count = len(entityList)
  for group in entityList:
    i += 1
    try:
      if not ciGroupsAPI:
        ci, _, groupKey = convertGroupCloudIDToEmail(ci, group, i, count)
        if noActionIfAlias and not verifyGroupPrimaryEmail(cd, groupKey, i, count):
          continue
        callGAPI(cd.groups(), 'delete',
                 throwReasons=[GAPI.GROUP_NOT_FOUND, GAPI.DOMAIN_NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID],
                 groupKey=groupKey)
      else:
        _, groupKey, group = convertGroupEmailToCloudID(ci, group, i, count)
        if not groupKey:
          continue
        callGAPI(ci.groups(), 'delete',
                 throwReasons=[GAPI.NOT_FOUND, GAPI.DOMAIN_NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID],
                 name=groupKey)
      entityActionPerformed([entityType, groupKey], i, count)
    except (GAPI.notFound, GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.invalid):
      entityUnknownWarning(entityType, groupKey, i, count)

def getGroupRoles(myarg, rolesSet):
  if myarg in {'role', 'roles'}:
    for role in getString(Cmd.OB_GROUP_ROLE_LIST).lower().replace(',', ' ').split():
      if role in GROUP_ROLES_MAP:
        rolesSet.add(GROUP_ROLES_MAP[role])
      else:
        invalidChoiceExit(role, GROUP_ROLES_MAP, True)
  elif myarg in GROUP_ROLES_MAP:
    rolesSet.add(GROUP_ROLES_MAP[myarg])
  else:
    return False
  return True

GROUP_MEMBER_TYPES_MAP = {
  'customer': Ent.TYPE_CUSTOMER,
  'group': Ent.TYPE_GROUP,
  'user': Ent.TYPE_USER,
  }
ALL_GROUP_MEMBER_TYPES = {Ent.TYPE_CUSTOMER, Ent.TYPE_GROUP, Ent.TYPE_USER}

def getGroupMemberTypes(myarg, typesSet):
  if myarg in {'type', 'types'}:
    for gtype in getString(Cmd.OB_GROUP_TYPE_LIST).lower().replace(',', ' ').split():
      if gtype in GROUP_MEMBER_TYPES_MAP:
        typesSet.add(GROUP_MEMBER_TYPES_MAP[gtype])
      else:
        invalidChoiceExit(gtype, GROUP_MEMBER_TYPES_MAP, True)
  else:
    return False
  return True

MEMBEROPTION_MEMBERNAMES = 0
MEMBEROPTION_NODUPLICATES = 1
MEMBEROPTION_RECURSIVE = 2
MEMBEROPTION_GETDELIVERYSETTINGS = 3
MEMBEROPTION_ISARCHIVED = 4
MEMBEROPTION_ISSUSPENDED = 5
MEMBEROPTION_INCLUDEDERIVEDMEMBERSHIP = 6
MEMBEROPTION_MATCHPATTERN = 7
MEMBEROPTION_DISPLAYMATCH = 8

def initMemberOptions():
  return [False, False, False, False, None, None, False, None, True]

def getMemberMatchOptions(myarg, memberOptions):
  if myarg in {'memberemaildisplaypattern', 'memberemailskippattern'}:
    memberOptions[MEMBEROPTION_MATCHPATTERN] = getREPattern(re.IGNORECASE)
    memberOptions[MEMBEROPTION_DISPLAYMATCH] = myarg == 'memberemaildisplaypattern'
  else:
    return False
  return True

def checkMemberMatch(member, memberOptions):
  if not memberOptions[MEMBEROPTION_MATCHPATTERN]:
    return True
  if member['type'] != Ent.TYPE_CUSTOMER:
    if memberOptions[MEMBEROPTION_MATCHPATTERN].match(member['email']):
      return memberOptions[MEMBEROPTION_DISPLAYMATCH]
    return not memberOptions[MEMBEROPTION_DISPLAYMATCH]
  if memberOptions[MEMBEROPTION_MATCHPATTERN].match(member['id']):
    return memberOptions[MEMBEROPTION_DISPLAYMATCH]
  return not memberOptions[MEMBEROPTION_DISPLAYMATCH]

def checkCIMemberMatch(member, memberOptions):
  if not memberOptions[MEMBEROPTION_MATCHPATTERN]:
    return True
  if memberOptions[MEMBEROPTION_MATCHPATTERN].match(member.get('preferredMemberKey', {}).get('id', '')):
    return memberOptions[MEMBEROPTION_DISPLAYMATCH]
  return not memberOptions[MEMBEROPTION_DISPLAYMATCH]

def initIPSGMGroupMemberDisplayOptions():
  return {Ent.ROLE_MEMBER: {'show': False},
          Ent.ROLE_MANAGER: {'show': False},
          Ent.ROLE_OWNER: {'show': False},
          'categories': [],
          'internal': False, 'external': False, 'showCategory': False,
          'internalDomains': set([GC.Values[GC.DOMAIN]])}

def getIPSGMGroupRolesMemberDisplayOptions(myarg, rolesSet, memberDisplayOptions):
  def setMemberDisplayOptionsRole():
    for role in rolesSet:
      memberDisplayOptions[role]['show'] = True

  if myarg in {'role', 'roles'}:
    for role in getString(Cmd.OB_GROUP_ROLE_LIST).lower().replace(',', ' ').split():
      if role in GROUP_ROLES_MAP:
        rolesSet.add(GROUP_ROLES_MAP[role])
      else:
        invalidChoiceExit(role, GROUP_ROLES_MAP, True)
    setMemberDisplayOptionsRole()
  elif myarg in GROUP_ROLES_MAP:
    rolesSet.add(GROUP_ROLES_MAP[myarg])
    setMemberDisplayOptionsRole()
  elif myarg == 'members':
    role = Ent.ROLE_MEMBER
    rolesSet.add(role)
    memberDisplayOptions[role]['show'] = True
  elif myarg == 'managers':
    role = Ent.ROLE_MANAGER
    rolesSet.add(role)
    memberDisplayOptions[role]['show'] = True
  elif myarg == 'owners':
    role = Ent.ROLE_OWNER
    rolesSet.add(role)
    memberDisplayOptions[role]['show'] = True
  elif myarg == 'internal':
    memberDisplayOptions['internal'] = memberDisplayOptions['showCategory'] = True
  elif myarg == 'external':
    memberDisplayOptions['external'] = memberDisplayOptions['showCategory'] = True
  elif myarg == 'internaldomains':
    memberDisplayOptions['internalDomains'] = set(getString(Cmd.OB_DOMAIN_NAME_LIST).replace(',', ' ').lower().split())
  else:
    return False
  return True

def initPGGroupMemberDisplayOptions():
  return {Ent.ROLE_MEMBER: {'show': False, 'countOnly': False},
          Ent.ROLE_MANAGER: {'show': False, 'countOnly': False},
          Ent.ROLE_OWNER: {'show': False, 'countOnly': False},
          'totalCount': False, 'categories': [],
          'internal': False, 'external': False, 'showCategory': False,
          'internalDomains': set([GC.Values[GC.DOMAIN]])}

def getPGGroupRolesMemberDisplayOptions(myarg, rolesSet, memberDisplayOptions):
  if myarg == 'memberscount':
    role = Ent.ROLE_MEMBER
    rolesSet.add(role)
    memberDisplayOptions[role]['show'] = True
    memberDisplayOptions[role]['countOnly'] = True
  elif myarg == 'managerscount':
    role = Ent.ROLE_MANAGER
    rolesSet.add(role)
    memberDisplayOptions[role]['show'] = True
    memberDisplayOptions[role]['countOnly'] = True
  elif myarg == 'ownerscount':
    role = Ent.ROLE_OWNER
    rolesSet.add(role)
    memberDisplayOptions[role]['show'] = True
    memberDisplayOptions[role]['countOnly'] = True
  elif myarg == 'totalcount':
    memberDisplayOptions['totalCount'] = True
  elif myarg == 'countsonly':
    for role in Ent.ROLE_LIST:
      memberDisplayOptions[role]['countOnly'] = True
  else:
    return getIPSGMGroupRolesMemberDisplayOptions(myarg, rolesSet, memberDisplayOptions)
  return True

GROUP_FIELDS_CHOICE_MAP = {
  'admincreated': 'adminCreated',
  'aliases': ['aliases', 'nonEditableAliases'],
  'description': 'description',
  'directmemberscount': 'directMembersCount',
  'email': 'email',
  'id': 'id',
  'name': 'name',
  }
GROUP_INFO_PRINT_ORDER = ['id', 'name', 'description', 'directMembersCount', 'adminCreated']
INFO_GROUP_OPTIONS = {'nousers', 'groups'}

CIGROUP_FIELDS_CHOICE_MAP = {
  'additionalgroupkeys': 'additionalGroupKeys',
  'createtime': 'createTime',
  'description': 'description',
  'displayname': 'displayName',
  'dynamicgroupmetadata': 'dynamicGroupMetadata',
  'email': 'groupKey',
  'groupkey': 'groupKey',
  'id': 'name',
  'labels': 'labels',
  'name': 'displayName',
  'parent': 'parent',
  'updatetime': 'updateTime',
  }
CIGROUP_FULL_FIELDS = {'additionalGroupKeys', 'createTime', 'dynamicGroupMetadata', 'parent', 'updateTime'}
CIGROUP_FIELDS_WITH_CRS_NLS = {'description'}
CIGROUP_INFO_ORDER = ['id', 'name', 'description', 'createTime', 'updateTime',
                      'groupKey', 'additionalGroupKeys', 'labels', 'parent', 'dynamicGroupMetadata',
                      'SecuritySettings']
CIGROUP_PRINT_ORDER = ['id', 'name', 'description', 'createTime', 'updateTime',
                       'groupKey', 'additionalGroupKeys', 'parent', 'dynamicGroupMetadata',
                       'memberRestrictionQuery', 'memberRestrictionEvaluation']
CIGROUP_TIME_OBJECTS = {'createTime', 'updateTime', 'statusTime'}

def mapCIGroupFieldNames(group):
  if 'groupKey' in group:
    group['email'] = group['groupKey'].pop('id')
    if not group['groupKey']:
      group.pop('groupKey')
  if 'name' in group:
    group['id'] = group.pop('name')
  if 'displayName' in group:
    group['name'] = group.pop('displayName')

def _showCIGroup(group, groupEmail, i=0, count=0):
  printEntity([Ent.CLOUD_IDENTITY_GROUP, groupEmail], i, count)
  mapCIGroupFieldNames(group)
  Ind.Increment()
  for key in CIGROUP_INFO_ORDER:
    if key not in group:
      continue
    value = group[key]
    if key == 'labels':
      for k, v in iter(value.items()):
        if v == '':
          value[k] = True
    if isinstance(value, (list, dict)):
      showJSON(key, value, timeObjects=CIGROUP_TIME_OBJECTS)
    elif key not in CIGROUP_FIELDS_WITH_CRS_NLS:
      if key not in CIGROUP_TIME_OBJECTS:
        printKeyValueList([key, value])
      else:
        printKeyValueList([key, formatLocalTime(value)])
    else:
      printKeyValueWithCRsNLs(key, value)
  Ind.Decrement()

def infoGroups(entityList):
  def initGroupFieldsLists():
    if not groupFieldsLists['cd']:
      groupFieldsLists['cd'] = ['email']
    if not groupFieldsLists['ci']:
      groupFieldsLists['ci'] = []
    if not groupFieldsLists['gs']:
      groupFieldsLists['gs'] = []

  cd = buildGAPIObject(API.DIRECTORY)
  ci = None
  getAliases = getUsers = True
  getCloudIdentity = getGroups = getSettings = False
  showDeprecatedAttributes = True
  FJQC = FormatJSONQuoteChar()
  groups = []
  members = []
  groupFieldsLists = {'cd': None, 'ci': None, 'gs': None}
  isSuspended = isArchived = None
  rolesSet = set()
  typesSet = set()
  memberOptions = initMemberOptions()
  memberDisplayOptions = initIPSGMGroupMemberDisplayOptions()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'quick':
      getAliases = getUsers = False
    elif myarg == 'nousers':
      getUsers = False
    elif myarg == 'nodeprecated':
      showDeprecatedAttributes = not getBoolean()
    elif myarg in SUSPENDED_ARGUMENTS:
      isSuspended = _getIsSuspended(myarg)
    elif myarg in ARCHIVED_ARGUMENTS:
      isArchived = _getIsArchived(myarg)
    elif myarg == 'noaliases':
      getAliases = False
    elif myarg == 'groups':
      getGroups = True
    elif getIPSGMGroupRolesMemberDisplayOptions(myarg, rolesSet, memberDisplayOptions):
      getUsers = True
    elif getGroupMemberTypes(myarg, typesSet):
      pass
    elif getMemberMatchOptions(myarg, memberOptions):
      pass
    elif myarg == 'basic':
      initGroupFieldsLists()
      for field in GROUP_FIELDS_CHOICE_MAP:
        addFieldToFieldsList(field, GROUP_FIELDS_CHOICE_MAP, groupFieldsLists['cd'])
    elif myarg in {'ciallfields', 'allcifields'}:
      if not groupFieldsLists['ci']:
        groupFieldsLists['ci'] = []
      for field in CIGROUP_FIELDS_CHOICE_MAP:
        addFieldToFieldsList(field, CIGROUP_FIELDS_CHOICE_MAP, groupFieldsLists['ci'])
    elif myarg in GROUP_FIELDS_CHOICE_MAP:
      initGroupFieldsLists()
      addFieldToFieldsList(myarg, GROUP_FIELDS_CHOICE_MAP, groupFieldsLists['cd'])
    elif myarg in GROUP_ATTRIBUTES_SET:
      initGroupFieldsLists()
      attrProperties = getGroupAttrProperties(myarg)
      groupFieldsLists['gs'].extend([attrProperties[0]])
    elif myarg == 'fields':
      initGroupFieldsLists()
      for field in _getFieldsList():
        if field in GROUP_FIELDS_CHOICE_MAP:
          addFieldToFieldsList(field, GROUP_FIELDS_CHOICE_MAP, groupFieldsLists['cd'])
        else:
          attrProperties = getGroupAttrProperties(field)
          if attrProperties is None:
            invalidChoiceExit(field, list(GROUP_FIELDS_CHOICE_MAP)+list(GROUP_ATTRIBUTES_SET), True)
          groupFieldsLists['gs'].extend([attrProperties[0]])
    elif myarg == 'cifields':
      initGroupFieldsLists()
      for field in _getFieldsList():
        if field in CIGROUP_FIELDS_CHOICE_MAP:
          addFieldToFieldsList(field, CIGROUP_FIELDS_CHOICE_MAP, groupFieldsLists['ci'])
        else:
          invalidChoiceExit(field, list(CIGROUP_FIELDS_CHOICE_MAP), True)
# Ignore info user arguments that may have come from whatis
    elif myarg in INFO_USER_OPTIONS:
      if myarg == 'schemas':
        getString(Cmd.OB_SCHEMA_NAME_LIST)
    else:
      FJQC.GetFormatJSON(myarg)
  if isSuspended is not None and isArchived is not None:
    if isSuspended == isArchived:
      entityType = Ent.MEMBER_SUSPENDED_ARCHIVED if isSuspended else Ent.MEMBER_NOT_SUSPENDED_NOT_ARCHIVED
    else:
      entityType = Ent.MEMBER_SUSPENDED if isSuspended else Ent.MEMBER_ARCHIVED
  elif isSuspended is not None:
    entityType = Ent.MEMBER_SUSPENDED if isSuspended else Ent.MEMBER_NOT_SUSPENDED
  elif isArchived is not None:
    entityType = Ent.MEMBER_ARCHIVED if isArchived else Ent.MEMBER_NOT_ARCHIVED
  else:
    entityType = Ent.MEMBER
  if not typesSet:
    typesSet = ALL_GROUP_MEMBER_TYPES
  cdfields = getFieldsFromFieldsList(groupFieldsLists['cd'])
  memberRoles = ','.join(sorted(rolesSet)) if rolesSet else None
  if groupFieldsLists['gs'] is None:
    getSettings = True
    gsfields = None
  elif groupFieldsLists['gs']:
    getSettings = True
    gsfields = getFieldsFromFieldsList(groupFieldsLists['gs'])
  else:
    gsfields = None
  if getSettings:
    gs = buildGAPIObject(API.GROUPSSETTINGS)
  if groupFieldsLists['ci']:
    getCloudIdentity = True
    cifields = getFieldsFromFieldsList(groupFieldsLists['ci'])
    ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
  else:
    cifields = None
  checkCategory = memberDisplayOptions['showCategory']
  i = 0
  count = len(entityList)
  for group in entityList:
    i += 1
    ci, _, group = convertGroupCloudIDToEmail(ci, group, i, count)
    try:
      basic_info = callGAPI(cd.groups(), 'get',
                            throwReasons=GAPI.GROUP_GET_THROW_REASONS, retryReasons=GAPI.GROUP_GET_RETRY_REASONS,
                            groupKey=group, fields=cdfields)
      group = basic_info['email']
      if getCloudIdentity:
        _, name, _ = convertGroupEmailToCloudID(ci, group, i, count)
        if not name:
          continue
        cigInfo = callGAPI(ci.groups(), 'get',
                           throwReasons=GAPI.CIGROUP_GET_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                           name=name, fields=cifields)
      else:
        cigInfo = {}
      settings = {}
      if getSettings and not GroupIsAbuseOrPostmaster(group):
 # Use email address retrieved from cd since GS API doesn't support uid
        settings = callGAPI(gs.groups(), 'get',
                            throwReasons=GAPI.GROUP_SETTINGS_THROW_REASONS, retryReasons=GAPI.GROUP_SETTINGS_RETRY_REASONS,
                            groupUniqueId=mapGroupEmailForSettings(group), fields=gsfields)
      if getGroups:
        groups = callGAPIpages(cd.groups(), 'list', 'groups',
                               throwReasons=GAPI.GROUP_LIST_USERKEY_THROW_REASONS,
                               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                               userKey=group, orderBy='email', fields='nextPageToken,groups(name,email)')
      if getUsers:
        validRoles, listRoles, listFields = _getRoleVerification(memberRoles, 'nextPageToken,members(email,id,role,status,type)')
        result = callGAPIpages(cd.members(), 'list', 'members',
                               throwReasons=GAPI.MEMBERS_THROW_REASONS, retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                               groupKey=group, roles=listRoles, fields=listFields, maxResults=GC.Values[GC.MEMBER_MAX_RESULTS])
        members = []
        for member in result:
          if ((member['type'] in typesSet) and
              _checkMemberRoleIsSuspendedIsArchived(member, validRoles, isSuspended, isArchived) and
              checkMemberMatch(member, memberOptions) and
              (not checkCategory or _checkMemberCategory(member, memberDisplayOptions))):
            members.append(member)
      if FJQC.formatJSON:
        basic_info.update(settings)
        if cigInfo:
          basic_info['cloudIdentity'] = cigInfo
        if getGroups:
          basic_info['groups'] = groups
        if getUsers:
          basic_info['members'] = members
        printLine(json.dumps(cleanJSON(basic_info), ensure_ascii=False, sort_keys=True))
        continue
      printEntity([Ent.GROUP, group], i, count)
      Ind.Increment()
      if cigInfo:
        _showCIGroup(cigInfo, None)
      printEntity([Ent.GROUP_SETTINGS, None])
      Ind.Increment()
      for key in GROUP_INFO_PRINT_ORDER:
        if key not in basic_info:
          continue
        value = basic_info[key]
        if isinstance(value, list):
          printKeyValueList([key, None])
          Ind.Increment()
          for val in value:
            printKeyValueList([val])
          Ind.Decrement()
        elif key not in GROUP_FIELDS_WITH_CRS_NLS:
          printKeyValueList([key, value])
        else:
          printKeyValueWithCRsNLs(key, value)
      if settings:
        for key, attr in sorted(iter(GROUP_SETTINGS_ATTRIBUTES.items())):
          key = attr[0]
          if key in settings:
            if key not in GROUP_FIELDS_WITH_CRS_NLS:
              printKeyValueList([key, settings[key]])
            else:
              printKeyValueWithCRsNLs(key, settings[key])
        for key in GROUP_MERGED_ATTRIBUTES_PRINT_ORDER:
          if key in settings:
            printKeyValueList([key, settings[key]])
            Ind.Increment()
            showTitle = False
          else:
            showTitle = True
          if showDeprecatedAttributes:
            for subkey, subattr in sorted(iter(GROUP_MERGED_TO_COMPONENT_MAP[key].items())):
              subkey = subattr[0]
              if subkey in settings:
                if showTitle:
                  printKeyValueList([key, ''])
                  Ind.Increment()
                  showTitle = False
                printKeyValueList([subkey, settings[subkey]])
          if not showTitle:
            Ind.Decrement()
        if showDeprecatedAttributes:
          showTitle = True
          for subkey, attr in sorted(iter(GROUP_DEPRECATED_ATTRIBUTES.items())):
            subkey = attr[0]
            if subkey in settings:
              if showTitle:
                printKeyValueList(['Deprecated', ''])
                Ind.Increment()
                showTitle = False
              if subkey != 'maxMessageBytes':
                printKeyValueList([subkey, settings[subkey]])
              else:
                printKeyValueList([subkey, formatMaxMessageBytes(settings[subkey], ONE_KILO_BYTES, ONE_MEGA_BYTES)])
          if not showTitle:
            Ind.Decrement()
      Ind.Decrement()
      if getAliases:
        for up in ['aliases', 'nonEditableAliases']:
          aliases = basic_info.get(up, [])
          if aliases:
            printEntitiesCount([Ent.NONEDITABLE_ALIAS, Ent.EMAIL_ALIAS][up == 'aliases'], aliases)
            Ind.Increment()
            for alias in aliases:
              printKeyValueList(['alias', alias])
            Ind.Decrement()
      if getGroups:
        printEntitiesCount(Ent.GROUP, groups)
        Ind.Increment()
        for groupm in groups:
          printKeyValueList([groupm['name'], groupm['email']])
        Ind.Decrement()
      if getUsers:
        printEntitiesCount(entityType, members)
        Ind.Increment()
        for member in members:
          memberDetails = [member.get('role', Ent.ROLE_MEMBER).lower(), f'{member.get("email", member["id"])} ({member["type"].lower()})']
          if checkCategory:
            memberDetails[1] += f' ({member["category"]})'
          printKeyValueList(memberDetails)
        Ind.Decrement()
        printKeyValueList([Msg.TOTAL_ITEMS_IN_ENTITY.format(Ent.Plural(entityType), Ent.Singular(Ent.GROUP)), len(members)])
      Ind.Decrement()
    except GAPI.notFound:
      entityActionFailedWarning([Ent.GROUP, group], Msg.DOES_NOT_EXIST, i, count)
    except (GAPI.groupNotFound, GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.backendError,
            GAPI.invalid, GAPI.invalidMember, GAPI.invalidParameter, GAPI.invalidInput, GAPI.forbidden, GAPI.badRequest,
            GAPI.permissionDenied, GAPI.systemError, GAPI.serviceLimit, GAPI.serviceNotAvailable, GAPI.authError) as e:
      entityActionFailedWarning([Ent.GROUP, group], str(e), i, count)

# gam info groups <GroupEntity>
#	[nousers] [quick] [noaliases] [groups]
#	[basic] <GroupFieldName>* [fields <GroupFieldNameList>] [nodeprecated]
#	[ciallfields|(cifields <CIGroupFieldNameList>)]
#	[roles <GroupRoleList>] [members] [managers] [owners]
#	[internal] [internaldomains <DomainList>] [external]
#	[notsuspended|suspended] [notarchived|archived]
#	[types <GroupMemberTypeList>]
#	[memberemaildisplaypattern|memberemailskippattern <REMatchPattern>]
#	[formatjson]
def doInfoGroups():
  infoGroups(getEntityList(Cmd.OB_GROUP_ENTITY))

def groupFilters(kwargs, query):
  queryTitle = ''
  if kwargs.get('domain'):
    queryTitle += f'domain={kwargs["domain"]}, '
  if query is not None:
    queryTitle += f'query="{query}", '
  if queryTitle:
    return query, queryTitle[:-2]
  return query, queryTitle

def getGroupFilters(myarg, kwargsDict, showOwnedBy):
  if getUserGroupDomainQueryFilters(myarg, kwargsDict):
    pass
  elif myarg in {'member', 'showownedby'}:
    emailAddressOrUID = getEmailAddress()
    if emailAddressOrUID != GC.Values[GC.CUSTOMER_ID].lower():
      kwargsDict['queries'] = [f'memberKey={emailAddressOrUID}']
      key = 'email' if emailAddressOrUID.find('@') != -1 else 'id'
    else:
      kwargsDict['queries'] = [f'memberKey={GC.Values[GC.CUSTOMER_ID]}']
      key = 'id'
    if myarg == 'showownedby':
      showOwnedBy['key'] = key
      showOwnedBy['value'] = emailAddressOrUID
  else:
    return False
  return True

def checkGroupShowOwnedBy(showOwnedBy, members):
  for member in members:
    if (member.get('role', Ent.ROLE_MEMBER) == Ent.ROLE_OWNER) and (member.get(showOwnedBy['key'], '').lower() == showOwnedBy['value']):
      return True
  return False

def getGroupMatchPatterns(myarg, matchPatterns, ciGroupsAPI):
  if myarg == 'emailmatchpattern':
    matchPatterns['email'] = {'not': checkArgumentPresent('not'), 'pattern': getREPattern(re.IGNORECASE)}
  elif myarg == 'namematchpattern':
    matchPatterns['name' if not ciGroupsAPI else 'displayName'] = {'not': checkArgumentPresent('not'), 'pattern': getREPattern(re.IGNORECASE|re.UNICODE)}
  elif myarg == 'descriptionmatchpattern':
    matchPatterns['description'] = {'not': checkArgumentPresent('not'), 'pattern': getREPattern(re.IGNORECASE|re.UNICODE)}
  elif not ciGroupsAPI and myarg == 'admincreatedmatch':
    matchPatterns['adminCreated'] = getBoolean(None)
  else:
    return False
  return True

def updateFieldsForGroupMatchPatterns(matchPatterns, fieldsList, csvPF=None):
  for field in ['name', 'description', 'adminCreated']:
    if field in matchPatterns:
      if csvPF is not None:
        csvPF.AddField(field, GROUP_FIELDS_CHOICE_MAP, fieldsList)
      else:
        fieldsList.append(field)

def clearUnneededGroupMatchPatterns(matchPatterns):
  for field in ['name', 'displayName', 'description', 'adminCreated']:
    matchPatterns.pop(field, None)

def checkGroupMatchPatterns(groupEmail, group, matchPatterns):
  for field, match in iter(matchPatterns.items()):
    if field == 'email':
      if not match['not']:
        if not match['pattern'].match(groupEmail):
          return False
      else:
        if match['pattern'].match(groupEmail):
          return False
    elif field == 'adminCreated':
      if match != group[field]:
        return False
    else: # field in {'name', 'displayName', 'description'}:
      if not match['not']:
        if not match['pattern'].match(group[field]):
          return False
      else:
        if match['pattern'].match(group[field]):
          return False
  return True

MEMBERS_TITLES = {
  'combined': {
    'total': ['TotalCount', ''],
    Ent.ROLE_MEMBER: ['MembersCount', 'Members'],
    Ent.ROLE_MANAGER: ['ManagersCount', 'Managers'],
    Ent.ROLE_OWNER: ['OwnersCount', 'Owners']
    },
  'internal': {
    'total': ['TotalInternalCount', ''],
    Ent.ROLE_MEMBER: ['InternalMembersCount', 'InternalMembers'],
    Ent.ROLE_MANAGER: ['InternalManagersCount', 'InternalManagers'],
    Ent.ROLE_OWNER: ['InternalOwnersCount', 'InternalOwners']
    },
  'external': {
    'total': ['TotalExternalCount', ''],
    Ent.ROLE_MEMBER: ['ExternalMembersCount', 'ExternalMembers'],
    Ent.ROLE_MANAGER: ['ExternalManagersCount', 'ExternalManagers'],
    Ent.ROLE_OWNER: ['ExternalOwnersCount', 'ExternalOwners']
    }
  }

def setMemberDisplayTitles(memberDisplayOptions, csvPF):
  if memberDisplayOptions['totalCount']:
    csvPF.AddTitles(MEMBERS_TITLES['combined']['total'][0])
  if not memberDisplayOptions['internal'] and not memberDisplayOptions['external']:
    memberDisplayOptions['categories'].append('combined')
  else:
    if memberDisplayOptions['internal']:
      memberDisplayOptions['categories'].append('internal')
    if memberDisplayOptions['external']:
      memberDisplayOptions['categories'].append('external')
  for category in memberDisplayOptions['categories']:
    if memberDisplayOptions['totalCount'] and category != 'combined':
      csvPF.AddTitles(MEMBERS_TITLES[category]['total'][0])
    for role in Ent.ROLE_LIST:
      if memberDisplayOptions[role]['show']:
        csvPF.AddTitles(MEMBERS_TITLES[category][role][0])
        if not memberDisplayOptions[role]['countOnly']:
          csvPF.AddTitles(MEMBERS_TITLES[category][role][1])

def setMemberDisplaySortTitles(memberDisplayOptions, sortTitles):
  if memberDisplayOptions['totalCount']:
    sortTitles.append(MEMBERS_TITLES['total'][0])
  for category in memberDisplayOptions['categories']:
    if memberDisplayOptions['totalCount'] and category != 'combined':
      sortTitles.append(MEMBERS_TITLES[category]['total'][0])
    for role in Ent.ROLE_LIST:
      if memberDisplayOptions[role]['show']:
        sortTitles.append(MEMBERS_TITLES[category][role][0])
      if not memberDisplayOptions[role]['countOnly']:
        sortTitles.append(MEMBERS_TITLES[category][role][1])

def addMemberInfoToRow(row, groupMembers, typesSet, memberOptions, memberDisplayOptions, delimiter,
                       isSuspended, isArchived, ciGroupsAPI):
  membersInfo = {
    'combined': {'totalTitle': 'TotalCount',
                 Ent.ROLE_MEMBER: {'titles': ['MembersCount', 'Members'],
                                   'count': 0, 'email': []},
                 Ent.ROLE_MANAGER: {'titles': ['ManagersCount', 'Managers'],
                                    'count': 0, 'email': []},
                 Ent.ROLE_OWNER: {'titles': ['OwnersCount', 'Owners'],
                                  'count': 0, 'email': []}},
    'internal': {'totalTitle': 'TotalInternalCount',
                 Ent.ROLE_MEMBER: {'titles': ['InternalMembersCount', 'InternalMembers'],
                                   'count': 0, 'email': []},
                 Ent.ROLE_MANAGER: {'titles': ['InternalManagersCount', 'InternalManagers'],
                                    'count': 0, 'email': []},
                 Ent.ROLE_OWNER: {'titles': ['InternalOwnersCount', 'InternalOwners'],
                                  'count': 0, 'email': []}},
    'external': {'totalTitle': 'TotalExternalCount',
                 Ent.ROLE_MEMBER: {'titles': ['ExternalMembersCount', 'ExternalMembers'],
                                   'count': 0, 'email': []},
                 Ent.ROLE_MANAGER: {'titles': ['ExternalManagersCount', 'ExternalManagers'],
                                    'count': 0, 'email': []},
                 Ent.ROLE_OWNER: {'titles': ['ExternalOwnersCount', 'ExternalOwners'],
                                  'count': 0, 'email': []}}}
  checkMatch = checkMemberMatch if not ciGroupsAPI else checkCIMemberMatch
  for member in groupMembers:
    if not ciGroupsAPI:
      member_email = member.get('email', member.get('id', None))
    else:
      member_email = member.get('preferredMemberKey', {}).get('id', member['name'])
    if not member_email:
      writeStderr(f' Not sure what to do with: {member}\n')
      continue
    if not memberDisplayOptions['showCategory']:
      category = 'combined'
    else:
      if member_email.find('@') > 0:
        _, domain = member_email.lower().split('@', 1)
        category = 'internal' if domain in memberDisplayOptions['internalDomains'] else 'external'
      else:
        category = 'internal'
      if not memberDisplayOptions[category]:
        continue
    if ((member['type'] in typesSet) and
        (ciGroupsAPI or _checkMemberIsSuspendedIsArchived(member, isSuspended, isArchived)) and
        checkMatch(member, memberOptions)):
      role = member.get('role', Ent.ROLE_MEMBER)
      if role not in {Ent.ROLE_MEMBER, Ent.ROLE_MANAGER, Ent.ROLE_OWNER}:
        role = Ent.ROLE_MEMBER
      if memberDisplayOptions[role]['show']:
        membersInfo[category][role]['count'] += 1
        if not memberDisplayOptions[role]['countOnly']:
          membersInfo[category][role]['email'].append(member_email)
  totalCount = 0
  for category in memberDisplayOptions['categories']:
    categoryCount = 0
    for role in Ent.ROLE_LIST:
      if memberDisplayOptions[role]['show']:
        categoryCount += membersInfo[category][role]['count']
        row[membersInfo[category][role]['titles'][0]] = membersInfo[category][role]['count']
        if not memberDisplayOptions[role]['countOnly']:
          row[membersInfo[category][role]['titles'][1]] = delimiter.join(membersInfo[category][role]['email'])
    if memberDisplayOptions['totalCount'] and category != 'combined':
      row[membersInfo[category]['totalTitle']] = categoryCount
    totalCount += categoryCount
  if memberDisplayOptions['totalCount']:
    row['TotalCount'] = totalCount

PRINT_GROUPS_JSON_TITLES = ['email', 'JSON']

# gam print groups [todrive <ToDriveAttribute>*]
#	[([domain|domains <DomainNameEntity>] ([member|showownedby <EmailItem>]|[(query <QueryGroup>)|(queries <QueryUserList>)]))|
#	 (group|group_ns|group_susp <GroupItem>)|
#	 (select <GroupEntity>)]
#	[emailmatchpattern [not] <REMatchPattern>] [namematchpattern [not] <REMatchPattern>]
#	[descriptionmatchpattern [not] <REMatchPattern>] (matchsetting [not] <GroupAttribute>)*
#	[admincreatedmatch <Boolean>]
#	[maxresults <Number>]
#	[allfields|([basic] [settings] <GroupFieldName>* [fields <GroupFieldNameList>])]
#	[ciallfields|(cifields <CIGroupFieldNameList>)]
#	[nodeprecated]
#	[roles <GroupRoleList>]
#	[members|memberscount] [managers|managerscount] [owners|ownerscount] [totalcount] [countsonly]
#	[internal] [internaldomains <DomainList>] [external]
#	[includederivedmembership]
#	[notsuspended|suspended] [notarchived|archived]
#	[types <GroupMemberTypeList>]
#	[memberemaildisplaypattern|memberemailskippattern <REMatchPattern>]
#	[convertcrnl] [delimiter <Character>] [sortheaders]
#	[formatjson [quotechar <Character>]]
# 	[showitemcountonly]
def doPrintGroups():
  def _printGroupRow(groupEntity, groupSettings, groupMembers):
    nonlocal itemCount
    row = {}
    if matchSettings:
      if not isinstance(groupSettings, dict):
        return
      for key, match in iter(matchSettings.items()):
        gvalue = groupSettings.get(key)
        if match['notvalues'] and gvalue in match['notvalues']:
          return
        if match['values'] and gvalue not in match['values']:
          return
    if showOwnedBy and not checkGroupShowOwnedBy(showOwnedBy, groupMembers):
      return
    if showItemCountOnly:
      itemCount += 1
      return
    if deprecatedAttributesSet and isinstance(groupSettings, dict):
      deprecatedKeys = []
      for key in groupSettings:
        if key in deprecatedAttributesSet:
          deprecatedKeys.append(key)
      for key in deprecatedKeys:
        groupSettings.pop(key)
    if FJQC.formatJSON:
      row['email'] = groupEntity['email']
      row['JSON'] = json.dumps(cleanJSON(groupEntity), ensure_ascii=False, sort_keys=True)
      if rolesSet and groupMembers is not None:
        row['JSON-members'] = json.dumps(groupMembers, ensure_ascii=False, sort_keys=True)
      if isinstance(groupSettings, dict):
        row['JSON-settings'] = json.dumps(cleanJSON(groupSettings), ensure_ascii=False, sort_keys=True)
      groupCloudIdentity = ciGroups.get(row['email'], {})
      if groupCloudIdentity:
        row['JSON-cloudIdentity'] = json.dumps(cleanJSON(groupCloudIdentity, timeObjects=CIGROUP_TIME_OBJECTS), ensure_ascii=False, sort_keys=True)
      csvPF.WriteRowNoFilter(row)
      return
    for field in groupFieldsLists['cd']:
      if field in groupEntity:
        if isinstance(groupEntity[field], list):
          row[field] = delimiter.join(groupEntity[field])
        elif convertCRNL and field in GROUP_FIELDS_WITH_CRS_NLS:
          row[field] = escapeCRsNLs(groupEntity[field])
        else:
          row[field] = groupEntity[field]
    if rolesSet and groupMembers is not None:
      addMemberInfoToRow(row, groupMembers, typesSet, memberOptions, memberDisplayOptions, delimiter,
                         isSuspended, isArchived, False)
    if isinstance(groupSettings, dict):
      for key, value in iter(groupSettings.items()):
        if key not in {'kind', 'etag', 'email', 'name', 'description'}:
          if value is None:
            value = ''
          csvPF.AddTitles(key)
          if convertCRNL and key in GROUP_FIELDS_WITH_CRS_NLS:
            row[key] = escapeCRsNLs(value)
          else:
            row[key] = value
    groupCloudEntity = ciGroups.get(row['email'], {})
    if groupCloudEntity:
      for k, v in iter(groupCloudEntity.pop('labels', {}).items()):
        if v == '':
          groupCloudEntity[f'labels{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{k}'] = True
        else:
          groupCloudEntity[f'labels{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{k}'] = v
      for key, value in sorted(iter(flattenJSON({'cloudIdentity': groupCloudEntity}, flattened={}, timeObjects=CIGROUP_TIME_OBJECTS).items())):
        csvPF.AddTitles(key)
        row[key] = value
    csvPF.WriteRow(row)

  def _callbackProcessGroupBasic(request_id, response, exception):
    ri = request_id.splitlines()
    i = int(ri[RI_I])
    if exception is not None:
      http_status, reason, message = checkGAPIError(exception)
      if reason not in GAPI.DEFAULT_RETRY_REASONS+GAPI.GROUP_GET_RETRY_REASONS:
        if reason in GAPI.GROUP_GET_THROW_REASONS:
          entityUnknownWarning(Ent.GROUP, ri[RI_ENTITY], i, int(ri[RI_COUNT]))
        else:
          errMsg = getHTTPError({}, http_status, reason, message)
          entityActionFailedWarning([Ent.GROUP, ri[RI_ENTITY], Ent.GROUP, None], errMsg, i, int(ri[RI_COUNT]))
        return
      waitOnFailure(1, 10, reason, message)
      try:
        response = callGAPI(cd.groups(), 'get',
                            throwReasons=GAPI.GROUP_GET_THROW_REASONS, retryReasons=GAPI.GROUP_GET_RETRY_REASONS,
                            groupKey=ri[RI_ENTITY], fields=cdfields)
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.systemError) as e:
        entityActionFailedWarning([Ent.GROUP, ri[RI_ENTITY], Ent.GROUP, None], str(e), i, int(ri[RI_COUNT]))
        return
    entityList.append(response)

  def _callbackProcessGroupMembers(request_id, response, exception):
    ri = request_id.splitlines()
    i = int(ri[RI_I])
    totalItems = 0
    items = 'members'
    pageMessage = getPageMessageForWhom(forWhom=ri[RI_ENTITY], showFirstLastItems=True)
    if exception is not None:
      http_status, reason, message = checkGAPIError(exception)
      if reason not in GAPI.DEFAULT_RETRY_REASONS+GAPI.MEMBERS_RETRY_REASONS:
        errMsg = getHTTPError({}, http_status, reason, message)
        entityActionFailedWarning([Ent.GROUP, ri[RI_ENTITY], ri[RI_ROLE], None], errMsg, i, int(ri[RI_COUNT]))
        groupData[i]['required'] -= 1
        return
      waitOnFailure(1, 10, reason, message)
      try:
        response = callGAPI(cd.members(), 'list',
                            throwReasons=GAPI.MEMBERS_THROW_REASONS, retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                            includeDerivedMembership=memberOptions[MEMBEROPTION_INCLUDEDERIVEDMEMBERSHIP],
                            groupKey=ri[RI_ENTITY], roles=ri[RI_ROLE], fields='nextPageToken,members(email,id,role,type,status)',
                            maxResults=GC.Values[GC.MEMBER_MAX_RESULTS])
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.invalid, GAPI.forbidden, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.GROUP, ri[RI_ENTITY], ri[RI_ROLE], None], str(e), i, int(ri[RI_COUNT]))
        groupData[i]['required'] -= 1
        return
    while True:
      pageToken, totalItems = _processGAPIpagesResult(response, items, groupData[i][items], totalItems, pageMessage, 'email', ri[RI_ROLE])
      if not pageToken:
        _finalizeGAPIpagesResult(pageMessage)
        break
      try:
        response = callGAPI(cd.members(), 'list',
                            throwReasons=GAPI.MEMBERS_THROW_REASONS, retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                            pageToken=pageToken,
                            includeDerivedMembership=memberOptions[MEMBEROPTION_INCLUDEDERIVEDMEMBERSHIP],
                            groupKey=ri[RI_ENTITY], roles=ri[RI_ROLE], fields='nextPageToken,members(email,id,role,type,status)',
                            maxResults=GC.Values[GC.MEMBER_MAX_RESULTS])
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.invalid, GAPI.forbidden, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.GROUP, ri[RI_ENTITY], ri[RI_ROLE], None], str(e), i, int(ri[RI_COUNT]))
        break
    groupData[i]['required'] -= 1

  def _callbackProcessGroupSettings(request_id, response, exception):
    ri = request_id.splitlines()
    i = int(ri[RI_I])
    if exception is not None:
      http_status, reason, message = checkGAPIError(exception)
      if reason not in GAPI.DEFAULT_RETRY_REASONS+GAPI.GROUP_SETTINGS_RETRY_REASONS:
        errMsg = getHTTPError({}, http_status, reason, message)
        entityActionFailedWarning([Ent.GROUP, ri[RI_ENTITY], Ent.GROUP_SETTINGS, None], errMsg, i, int(ri[RI_COUNT]))
        groupData[i]['required'] -= 1
        return
      waitOnFailure(1, 10, reason, message)
      try:
        response = callGAPI(gs.groups(), 'get',
                            throwReasons=GAPI.GROUP_SETTINGS_THROW_REASONS, retryReasons=GAPI.GROUP_SETTINGS_RETRY_REASONS,
                            groupUniqueId=mapGroupEmailForSettings(ri[RI_ENTITY]), fields=gsfields)
      except GAPI.notFound:
        entityActionFailedWarning([Ent.GROUP, ri[RI_ENTITY], Ent.GROUP_SETTINGS, None], Msg.DOES_NOT_EXIST, i, int(ri[RI_COUNT]))
        response = {}
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
              GAPI.backendError, GAPI.invalid, GAPI.invalidParameter, GAPI.invalidInput, GAPI.badRequest, GAPI.permissionDenied,
              GAPI.systemError, GAPI.serviceLimit, GAPI.serviceNotAvailable, GAPI.authError) as e:
        entityActionFailedWarning([Ent.GROUP, ri[RI_ENTITY], Ent.GROUP_SETTINGS, None], str(e), i, int(ri[RI_COUNT]))
        response = {}
    groupData[i]['settings'] = response
    groupData[i]['required'] -= 1

  def _writeCompleteRows():
    complete = [k for k in groupData if groupData[k]['required'] == 0]
    for k in complete:
      _printGroupRow(groupData[k]['entity'], groupData[k]['settings'], groupData[k]['members'])
      del groupData[k]

  cd = buildGAPIObject(API.DIRECTORY)
  ci = None
  kwargsDict = initUserGroupDomainQueryFilters()
  convertCRNL = GC.Values[GC.CSV_OUTPUT_CONVERT_CR_NL]
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  getCloudIdentity = getSettings = showCIgroupKey = sortHeaders = False
  memberDisplayOptions = initPGGroupMemberDisplayOptions()
  maxResults = None
  groupFieldsLists = {'cd': ['email'], 'ci': [], 'gs': []}
  csvPF = CSVPrintFile(groupFieldsLists['cd'])
  FJQC = FormatJSONQuoteChar(csvPF)
  rolesSet = set()
  typesSet = set()
  memberOptions = initMemberOptions()
  entitySelection = isSuspended = isArchived = None
  showOwnedBy = {}
  matchPatterns = {}
  matchSettings = {}
  deprecatedAttributesSet = set()
  ciGroups = {}
  showItemCountOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif getGroupFilters(myarg, kwargsDict, showOwnedBy):
      pass
    elif getGroupMatchPatterns(myarg, matchPatterns, False):
      pass
    elif myarg in {'group', 'groupns', 'groupsusp'}:
      entitySelection = [getString(Cmd.OB_EMAIL_ADDRESS)]
      if myarg == 'groupns':
        isSuspended = False
      elif myarg == 'groupsusp':
        isSuspended = True
    elif myarg == 'select':
      entitySelection = getEntityList(Cmd.OB_GROUP_ENTITY)
    elif myarg in SUSPENDED_ARGUMENTS:
      isSuspended = _getIsSuspended(myarg)
    elif myarg in ARCHIVED_ARGUMENTS:
      isArchived = _getIsArchived(myarg)
    elif myarg == 'maxresults':
      maxResults = getInteger(minVal=1, maxVal=200)
    elif myarg == 'nodeprecated':
      deprecatedAttributesSet.update([attr[0] for attr in iter(GROUP_DISCOVER_ATTRIBUTES.values())])
      deprecatedAttributesSet.update([attr[0] for attr in iter(GROUP_ASSIST_CONTENT_ATTRIBUTES.values())])
      deprecatedAttributesSet.update([attr[0] for attr in iter(GROUP_MODERATE_CONTENT_ATTRIBUTES.values())])
      deprecatedAttributesSet.update([attr[0] for attr in iter(GROUP_MODERATE_MEMBERS_ATTRIBUTES.values())])
      deprecatedAttributesSet.update([attr[0] for attr in iter(GROUP_DEPRECATED_ATTRIBUTES.values())])
    elif myarg in {'convertcrnl', 'converttextnl', 'convertfooternl'}:
      convertCRNL = True
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    elif myarg == 'basic':
      sortHeaders = True
      for field in GROUP_FIELDS_CHOICE_MAP:
        csvPF.AddField(field, GROUP_FIELDS_CHOICE_MAP, groupFieldsLists['cd'])
    elif myarg in {'ciallfields', 'allcifields'}:
      sortHeaders = True
      groupFieldsLists['ci'] = []
      for field in CIGROUP_FIELDS_CHOICE_MAP:
        addFieldToFieldsList(field, CIGROUP_FIELDS_CHOICE_MAP, groupFieldsLists['ci'])
    elif myarg == 'settings':
      getSettings = sortHeaders = True
    elif myarg == 'allfields':
      getSettings = sortHeaders = True
      groupFieldsLists['cd'] = []
      groupFieldsLists['gs'] = []
      for field in GROUP_FIELDS_CHOICE_MAP:
        csvPF.AddField(field, GROUP_FIELDS_CHOICE_MAP, groupFieldsLists['cd'])
    elif myarg == 'sortheaders':
      sortHeaders = getBoolean()
    elif myarg in GROUP_FIELDS_CHOICE_MAP:
      csvPF.AddField(myarg, GROUP_FIELDS_CHOICE_MAP, groupFieldsLists['cd'])
    elif myarg in GROUP_ATTRIBUTES_SET:
      attrProperties = getGroupAttrProperties(myarg)
      csvPF.AddField(myarg, {myarg: attrProperties[0]}, groupFieldsLists['gs'])
    elif myarg == 'fields':
      for field in _getFieldsList():
        if field in GROUP_FIELDS_CHOICE_MAP:
          csvPF.AddField(field, GROUP_FIELDS_CHOICE_MAP, groupFieldsLists['cd'])
        else:
          attrProperties = getGroupAttrProperties(field)
          if attrProperties is None:
            invalidChoiceExit(field, list(GROUP_FIELDS_CHOICE_MAP)+list(GROUP_ATTRIBUTES_SET), True)
          csvPF.AddField(field, {field: attrProperties[0]}, groupFieldsLists['gs'])
    elif myarg == 'cifields':
      for field in _getFieldsList():
        if field in CIGROUP_FIELDS_CHOICE_MAP:
          addFieldToFieldsList(field, CIGROUP_FIELDS_CHOICE_MAP, groupFieldsLists['ci'])
        else:
          invalidChoiceExit(field, list(CIGROUP_FIELDS_CHOICE_MAP), True)
    elif myarg == 'matchsetting':
      valueList = getChoice({'not': 'notvalues'}, mapChoice=True, defaultChoice='values')
      matchBody = {}
      getGroupAttrValue(getString(Cmd.OB_FIELD_NAME).lower(), matchBody)
      for key, value in iter(matchBody.items()):
        matchSettings.setdefault(key, {'notvalues': [], 'values': []})
        matchSettings[key][valueList].append(value)
    elif getPGGroupRolesMemberDisplayOptions(myarg, rolesSet, memberDisplayOptions):
      pass
    elif getGroupMemberTypes(myarg, typesSet):
      pass
    elif getMemberMatchOptions(myarg, memberOptions):
      pass
    elif myarg == 'includederivedmembership':
      memberOptions[MEMBEROPTION_INCLUDEDERIVEDMEMBERSHIP] = True
    elif myarg == 'showitemcountonly':
      showItemCountOnly = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  if not typesSet:
    typesSet = ALL_GROUP_MEMBER_TYPES
  updateFieldsForGroupMatchPatterns(matchPatterns, groupFieldsLists['cd'], csvPF)
  if groupFieldsLists['cd']:
    cdfields = ','.join(set(groupFieldsLists['cd']))
    cdfieldsnp = f'nextPageToken,groups({cdfields})'
  else:
    cdfields = cdfieldsnp = None
  if matchSettings:
    groupFieldsLists['gs'].extend(list(matchSettings))
  if groupFieldsLists['gs']:
    getSettings = True
    gsfields = ','.join(set(groupFieldsLists['gs']))
  else:
    gsfields = None
  if getSettings:
    gs = buildGAPIObject(API.GROUPSSETTINGS)
  if groupFieldsLists['ci']:
    setTrueCustomerId(cd)
    getCloudIdentity = True
    showCIgroupKey = 'groupKey' in groupFieldsLists['ci']
    if not showCIgroupKey:
      groupFieldsLists['ci'].append('groupKey(id)')
    cifields = ','.join(set(groupFieldsLists['ci']))
    ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
  getRolesSet = rolesSet.copy()
  if showOwnedBy:
    getRolesSet.add(Ent.ROLE_OWNER)
  getRoles = ','.join(sorted(getRolesSet))
  showDetails = getRoles or getSettings or getCloudIdentity
  if rolesSet:
    setMemberDisplayTitles(memberDisplayOptions, csvPF)
  if FJQC.formatJSON:
    sortHeaders = False
    csvPF.SetJSONTitles(PRINT_GROUPS_JSON_TITLES)
    if rolesSet:
      csvPF.AddJSONTitle('JSON-members')
    if getSettings:
      csvPF.AddJSONTitle('JSON-settings')
    if getCloudIdentity:
      csvPF.AddJSONTitle('JSON-cloudIdentity')
  if entitySelection is None:
    entityList = []
    for kwargsQuery in makeUserGroupDomainQueryFilters(kwargsDict):
      kwargs = kwargsQuery[0]
      query  = kwargsQuery[1]
      query, pquery = groupFilters(kwargs, query)
      printGettingAllAccountEntities(Ent.GROUP, pquery)
      try:
        entityList.extend(callGAPIpages(cd.groups(), 'list', 'groups',
                                        pageMessage=getPageMessage(showFirstLastItems=True), messageAttribute='email',
                                        throwReasons=GAPI.GROUP_LIST_USERKEY_THROW_REASONS,
                                        retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                        orderBy='email', query=query, fields=cdfieldsnp, maxResults=maxResults, **kwargs))
      except (GAPI.invalidMember, GAPI.invalidInput) as e:
        if not invalidMember(query):
          entityActionFailedExit([Ent.GROUP, None], str(e))
      except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.forbidden, GAPI.badRequest):
        if kwargs.get('domain'):
          badRequestWarning(Ent.GROUP, Ent.DOMAIN, kwargs['domain'])
        else:
          accessErrorExit(cd)
  else:
    svcargs = dict([('groupKey', None), ('fields', cdfields)]+GM.Globals[GM.EXTRA_ARGS_LIST])
    cdmethod = getattr(cd.groups(), 'get')
    cdbatch = cd.new_batch_http_request(callback=_callbackProcessGroupBasic)
    cdbcount = 0
    entityList = []
    i = 0
    count = len(entitySelection)
    for groupEntity in entitySelection:
      i += 1
      groupEmail = normalizeEmailAddressOrUID(groupEntity)
      svcparms = svcargs.copy()
      svcparms['groupKey'] = groupEmail
      printGettingEntityItem(Ent.GROUP, groupEmail, i, count)
      cdbatch.add(cdmethod(**svcparms), request_id=batchRequestID(groupEmail, i, count, 0, 0, None))
      cdbcount += 1
      if cdbcount >= GC.Values[GC.BATCH_SIZE]:
        executeBatch(cdbatch)
        cdbatch = cd.new_batch_http_request(callback=_callbackProcessGroupBasic)
        cdbcount = 0
    if cdbcount > 0:
      cdbatch.execute()
  required = 0
  if getRoles:
    required += 1
    svcargs = dict([('groupKey', None), ('roles', getRoles), ('fields', 'nextPageToken,members(email,id,role,type,status)'),
                    ('includeDerivedMembership', memberOptions[MEMBEROPTION_INCLUDEDERIVEDMEMBERSHIP]),
                    ('maxResults', GC.Values[GC.MEMBER_MAX_RESULTS])]+GM.Globals[GM.EXTRA_ARGS_LIST])
  if getSettings:
    required += 1
    svcargsgs = dict([('groupUniqueId', None), ('fields', gsfields)]+GM.Globals[GM.EXTRA_ARGS_LIST])
  cdmethod = getattr(cd.members(), 'list')
  cdbatch = cd.new_batch_http_request(callback=_callbackProcessGroupMembers)
  cdbcount = 0
  if getSettings:
    gsmethod = getattr(gs.groups(), 'get')
    gsbatch = gs.new_batch_http_request(callback=_callbackProcessGroupSettings)
    gsbcount = 0
  groupData = {}
  itemCount = 0
  i = 0
  count = len(entityList)
  for groupEntity in entityList:
    i += 1
    groupEmail = groupEntity['email']
    if not checkGroupMatchPatterns(groupEmail, groupEntity, matchPatterns):
      continue
    if not showDetails:
      _printGroupRow(groupEntity, None, None)
      continue
    if getCloudIdentity:
      _, name, groupEmail = convertGroupEmailToCloudID(ci, groupEmail, i, count)
      printGettingEntityItemForWhom(Ent.CLOUD_IDENTITY_GROUP, groupEmail, i, count)
      if name:
        try:
          ciGroup = callGAPI(ci.groups(), 'get',
                             throwReasons=GAPI.CIGROUP_GET_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                             name=name, fields=cifields)
          key = ciGroup['groupKey']['id']
          if not showCIgroupKey:
            ciGroup.pop('groupKey')
          ciGroups[key] = ciGroup
        except (GAPI.notFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
                GAPI.forbidden, GAPI.badRequest, GAPI.invalid,
                GAPI.systemError, GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
          entityActionFailedWarning([Ent.GROUP, groupEmail, Ent.CLOUD_IDENTITY_GROUP, None], str(e), i, count)
    groupData[i] = {'entity': groupEntity, 'cloudIdentity': {}, 'settings': getSettings, 'members': [], 'required': required}
    if getRoles:
      printGettingEntityItemForWhom(getRoles, groupEmail, i, count)
      svcparms = svcargs.copy()
      svcparms['groupKey'] = groupEmail
      cdbatch.add(cdmethod(**svcparms), request_id=batchRequestID(groupEmail, i, count, 0, 0, None, getRoles))
      cdbcount += 1
      if cdbcount >= GC.Values[GC.BATCH_SIZE]:
        executeBatch(cdbatch)
        cdbatch = cd.new_batch_http_request(callback=_callbackProcessGroupMembers)
        cdbcount = 0
        _writeCompleteRows()
    if getSettings:
      if not GroupIsAbuseOrPostmaster(groupEmail):
        printGettingEntityItemForWhom(Ent.GROUP_SETTINGS, groupEmail, i, count)
        svcparmsgs = svcargsgs.copy()
        svcparmsgs['groupUniqueId'] = mapGroupEmailForSettings(groupEmail)
        gsbatch.add(gsmethod(**svcparmsgs), request_id=batchRequestID(groupEmail, i, count, 0, 0, None))
        gsbcount += 1
        if gsbcount >= GC.Values[GC.BATCH_SIZE]:
          executeBatch(gsbatch)
          gsbatch = gs.new_batch_http_request(callback=_callbackProcessGroupSettings)
          gsbcount = 0
          _writeCompleteRows()
      else:
        groupData[i]['settings'] = False
        groupData[i]['required'] -= 1
  if cdbcount > 0:
    cdbatch.execute()
  if getSettings and gsbcount > 0:
    gsbatch.execute()
  _writeCompleteRows()
  if showItemCountOnly:
    writeStdout(f'{itemCount}\n')
    return
  if sortHeaders:
    sortTitles = ['email']+GROUP_INFO_PRINT_ORDER+['aliases', 'nonEditableAliases']
    if getSettings:
      sortTitles += sorted([attr[0] for attr in iter(GROUP_SETTINGS_ATTRIBUTES.values())])
      for key in GROUP_MERGED_ATTRIBUTES_PRINT_ORDER:
        sortTitles.append(key)
        if not deprecatedAttributesSet:
          sortTitles += sorted([attr[0] for attr in iter(GROUP_MERGED_TO_COMPONENT_MAP[key].values())])
      if not deprecatedAttributesSet:
        sortTitles += sorted([attr[0] for attr in iter(GROUP_DEPRECATED_ATTRIBUTES.values())])
    if rolesSet:
      setMemberDisplaySortTitles(memberDisplayOptions, sortTitles)
    csvPF.SetSortTitles(sortTitles)
  csvPF.writeCSVfile('Groups')

def mapCIGroupMemberFieldNames(member):
  member['email'] = member['preferredMemberKey'].pop('id')
  if not member['preferredMemberKey']:
    member.pop('preferredMemberKey')
  if 'name' in member:
    member['id'] = member.pop('name')

INFO_GROUPMEMBERS_FIELDS = ['role', 'type', 'status', 'delivery_settings']

# gam <UserTypeEntity> info member <GroupEntity>
def infoGroupMembers(entityList, ciGroupsAPI=False):
  if not ciGroupsAPI:
    cd = buildGAPIObject(API.DIRECTORY)
    ci = None
    fieldsList = INFO_GROUPMEMBERS_FIELDS
    fields = ','.join(fieldsList)
  else:
    ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
  entityType = GROUP_CIGROUP_ENTITYTYPE_MAP[ciGroupsAPI]
  groups = getEntityList(Cmd.OB_GROUP_ENTITY)
  checkForExtraneousArguments()
  groupsLists = groups if isinstance(groups, dict) else None
  i, count, entityList = getEntityArgument(entityList)
  for user in entityList:
    i += 1
    memberKey = normalizeEmailAddressOrUID(user)
    if groupsLists:
      groups = groupsLists[user]
    jcount = len(groups)
    entityPerformActionNumItems([Ent.MEMBER, memberKey], jcount, entityType, i, count)
    Ind.Increment()
    j = 0
    for group in groups:
      j += 1
      if not ciGroupsAPI:
        try:
          ci, _, groupKey = convertGroupCloudIDToEmail(ci, group, i, count)
          result = callGAPI(cd.members(), 'get',
                            throwReasons=GAPI.MEMBERS_THROW_REASONS+[GAPI.MEMBER_NOT_FOUND], retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                            groupKey=groupKey, memberKey=memberKey, fields=fields)
          result.setdefault('role', Ent.ROLE_MEMBER)
          printEntity([entityType, groupKey], j, jcount)
          Ind.Increment()
          for field in INFO_GROUPMEMBERS_FIELDS:
            printKeyValueList([field, result[field]])
          Ind.Decrement()
        except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden, GAPI.serviceNotAvailable) as e:
          entityActionFailedWarning([entityType, group], str(e), j, jcount)
        except GAPI.memberNotFound:
          entityActionFailedWarning([entityType, group, Ent.MEMBER, memberKey], Msg.NOT_AN_ENTITY.format(Ent.Singular(Ent.MEMBER)), j, jcount)
      else:
        _, groupKey, group = convertGroupEmailToCloudID(ci, group, j, jcount)
        if not groupKey:
          continue
        try:
          memberName = callGAPI(ci.groups().memberships(), 'lookup',
                                throwReasons=GAPI.GROUP_GET_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED],
                                parent=groupKey, memberKey_id=memberKey, fields='name').get('name')
          result = callGAPI(ci.groups().memberships(), 'get',
                            name=memberName)
          printEntity([entityType, group], j, jcount)
          Ind.Increment()
          getCIGroupMemberRoleFixType(result)
          kvList = ['role', result['role']]
          if 'expireTime' in result:
            kvList.extend(['expireTime', formatLocalTime(result['expireTime'])])
          printKeyValueList(kvList)
          printKeyValueList(['type', result['type']])
          for field in ['createTime', 'updateTime']:
            printKeyValueList([field, formatLocalTime(result[field])])
          if 'deliverySetting' in result:
            printKeyValueList(['deliverySetting', result['deliverySetting']])
          Ind.Decrement()
        except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden) as e:
          entityActionFailedWarning([entityType, groupKey], str(e), j, jcount)
        except (GAPI.notFound, GAPI.memberNotFound, GAPI.permissionDenied):
          entityActionFailedWarning([entityType, groupKey, Ent.MEMBER, memberKey], Msg.NOT_AN_ENTITY.format(Ent.Singular(Ent.MEMBER)), j, jcount)
    Ind.Decrement()

# gam info member <UserTypeEntity> <GroupEntity>
def doInfoGroupMembers():
  infoGroupMembers(getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)[1], False)

def getGroupMembersEntityList(cd, entityList, matchPatterns, fieldsList, kwargsDict):
  if entityList is None:
    updateFieldsForGroupMatchPatterns(matchPatterns, fieldsList)
    entityList = []
    for kwargsQuery in makeUserGroupDomainQueryFilters(kwargsDict):
      kwargs = kwargsQuery[0]
      query  = kwargsQuery[1]
      query, pquery = groupFilters(kwargs, query)
      printGettingAllAccountEntities(Ent.GROUP, pquery)
      try:
        entityList.extend(callGAPIpages(cd.groups(), 'list', 'groups',
                                        pageMessage=getPageMessage(showFirstLastItems=True), messageAttribute='email',
                                        throwReasons=GAPI.GROUP_LIST_USERKEY_THROW_REASONS,
                                        retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                        orderBy='email', query=query, fields=f'nextPageToken,groups({",".join(set(fieldsList))})', **kwargs))
      except (GAPI.invalidMember, GAPI.invalidInput) as e:
        if not invalidMember(query):
          entityActionFailedExit([Ent.GROUP, None], str(e))
      except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.forbidden, GAPI.badRequest):
        if kwargs.get('domain'):
          badRequestWarning(Ent.GROUP, Ent.DOMAIN, kwargs['domain'])
        else:
          accessErrorExit(cd)
  else:
    clearUnneededGroupMatchPatterns(matchPatterns)
  return entityList

def getGroupMembers(cd, groupEmail, memberRoles, membersList, membersSet, i, count,
                    memberOptions, memberDisplayOptions, level, typesSet):
  def _getMemberDeliverySettings(member):
    if 'delivery_settings' not in member:
      try:
        member['delivery_settings'] = callGAPI(cd.members(), 'get',
                                               throwReasons=GAPI.MEMBERS_THROW_REASONS+[GAPI.MEMBER_NOT_FOUND],
                                               retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                                               groupKey=groupEmail, memberKey=member['id'], fields='delivery_settings')['delivery_settings']
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden, GAPI.serviceNotAvailable):
        pass
      except GAPI.memberNotFound:
        pass
    else:
      memberOptions[MEMBEROPTION_GETDELIVERYSETTINGS] = False

  printGettingAllEntityItemsForWhom(memberRoles if memberRoles else Ent.ROLE_MANAGER_MEMBER_OWNER, groupEmail, i, count)
  validRoles, listRoles, listFields = _getRoleVerification(memberRoles, 'nextPageToken,members(email,id,role,status,type,delivery_settings)')
  if not groupEmail.startswith('space/'):
    try:
      groupMembers = callGAPIpages(cd.members(), 'list', 'members',
                                   pageMessage=getPageMessageForWhom(),
                                   throwReasons=GAPI.MEMBERS_THROW_REASONS, retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                                   includeDerivedMembership=memberOptions[MEMBEROPTION_INCLUDEDERIVEDMEMBERSHIP],
                                   groupKey=groupEmail, roles=listRoles, fields=listFields, maxResults=GC.Values[GC.MEMBER_MAX_RESULTS])
    except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden, GAPI.serviceNotAvailable):
      entityUnknownWarning(Ent.GROUP, groupEmail, i, count)
      return
  else:
    groupMembers =  _getChatSpaceMembers(cd, groupEmail, '')
  checkCategory = memberDisplayOptions['showCategory']
  if not memberOptions[MEMBEROPTION_RECURSIVE]:
    if memberOptions[MEMBEROPTION_NODUPLICATES]:
      for member in groupMembers:
        if (_checkMemberRoleIsSuspendedIsArchived(member, validRoles, memberOptions[MEMBEROPTION_ISSUSPENDED], memberOptions[MEMBEROPTION_ISARCHIVED]) and
            (not checkCategory or _checkMemberCategory(member, memberDisplayOptions)) and
            member['id'] not in membersSet):
          if memberOptions[MEMBEROPTION_GETDELIVERYSETTINGS]:
            _getMemberDeliverySettings(member)
          membersSet.add(member['id'])
          if member['type'] in typesSet and checkMemberMatch(member, memberOptions):
            membersList.append(member)
    else:
      for member in groupMembers:
        if (_checkMemberRoleIsSuspendedIsArchived(member, validRoles, memberOptions[MEMBEROPTION_ISSUSPENDED], memberOptions[MEMBEROPTION_ISARCHIVED]) and
            (not checkCategory or _checkMemberCategory(member, memberDisplayOptions))):
          if memberOptions[MEMBEROPTION_GETDELIVERYSETTINGS]:
            _getMemberDeliverySettings(member)
          if member['type'] in typesSet and checkMemberMatch(member, memberOptions):
            membersList.append(member)
  elif memberOptions[MEMBEROPTION_NODUPLICATES]:
    groupMemberList = []
    for member in groupMembers:
      if member['type'] != Ent.TYPE_GROUP:
        if ((member['type'] in typesSet and
             checkMemberMatch(member, memberOptions) and
             _checkMemberRoleIsSuspendedIsArchived(member, validRoles, memberOptions[MEMBEROPTION_ISSUSPENDED], memberOptions[MEMBEROPTION_ISARCHIVED]) and
             (not checkCategory or _checkMemberCategory(member, memberDisplayOptions)) and
             member['id'] not in membersSet)):
          if memberOptions[MEMBEROPTION_GETDELIVERYSETTINGS]:
            _getMemberDeliverySettings(member)
          membersSet.add(member['id'])
          member['level'] = level
          member['subgroup'] = groupEmail
          membersList.append(member)
      else:
        if member['id'] not in membersSet:
          if memberOptions[MEMBEROPTION_GETDELIVERYSETTINGS]:
            _getMemberDeliverySettings(member)
          membersSet.add(member['id'])
          if (member['type'] in typesSet and
              checkMemberMatch(member, memberOptions) and
              (not checkCategory or _checkMemberCategory(member, memberDisplayOptions))):
            member['level'] = level
            member['subgroup'] = groupEmail
            membersList.append(member)
          groupMemberList.append(member['email'])
    for member in groupMemberList:
      getGroupMembers(cd, member, memberRoles, membersList, membersSet, i, count,
                      memberOptions, memberDisplayOptions, level+1, typesSet)
  else:
    for member in groupMembers:
      if member['type'] != Ent.TYPE_GROUP:
        if ((member['type'] in typesSet) and
            checkMemberMatch(member, memberOptions) and
            _checkMemberRoleIsSuspendedIsArchived(member, validRoles,
                                                  memberOptions[MEMBEROPTION_ISSUSPENDED],
                                                  memberOptions[MEMBEROPTION_ISARCHIVED]) and
            (not checkCategory or _checkMemberCategory(member, memberDisplayOptions))):
          if memberOptions[MEMBEROPTION_GETDELIVERYSETTINGS]:
            _getMemberDeliverySettings(member)
          member['level'] = level
          member['subgroup'] = groupEmail
          membersList.append(member)
      else:
        if (member['type'] in typesSet and
            checkMemberMatch(member, memberOptions) and
            (not checkCategory or _checkMemberCategory(member, memberDisplayOptions))):
          member['level'] = level
          member['subgroup'] = groupEmail
          membersList.append(member)
        getGroupMembers(cd, member['email'], memberRoles, membersList, membersSet, i, count,
                        memberOptions, memberDisplayOptions, level+1, typesSet)

GROUPMEMBERS_FIELDS_CHOICE_MAP = {
  'delivery': 'delivery_settings',
  'deliverysettings': 'delivery_settings',
  'email': 'email',
  'group': 'group',
  'groupemail': 'group',
  'id': 'id',
  'name': 'name',
  'role': 'role',
  'status': 'status',
  'type': 'type',
  'useremail': 'email',
  }

GROUPMEMBERS_DEFAULT_FIELDS = ['group', 'type', 'role', 'id', 'status', 'email']

# gam print group-members [todrive <ToDriveAttribute>*]
#	[([domain|domains <DomainNameEntity>] ([member|showownedby <EmailItem>]|[(query <QueryGroup>)|(queries <QueryUserList>)]))|
#	 (group|group_ns|group_susp <GroupItem>)|
#	 (select <GroupEntity>)]
#	[emailmatchpattern [not] <REMatchPattern>] [namematchpattern [not] <REMatchPattern>]
#	[descriptionmatchpattern [not] <REMatchPattern>]
#	[roles <GroupRoleList>] [members] [managers] [owners]
#	[internal] [internaldomains <DomainList>] [external]
#	[notsuspended|suspended] [notarchived|archived]
#	[types <GroupMemberTypeList>]
#	[memberemaildisplaypattern|memberemailskippattern <REMatchPattern>]
#	[membernames] [showdeliverysettings]
#	<MembersFieldName>* [fields <MembersFieldNameList>]
#	[userfields <UserFieldNameList>]
#	[allschemas|(schemas|custom|customschemas <SchemaNameList>)]
#	[(recursive [noduplicates])|includederivedmembership] [nogroupemail]
#	[peoplelookup|(peoplelookupuser <EmailAddress>)]
#	[unknownname <String>] [cachememberinfo [Boolean]]
#	[formatjson [quotechar <Character>]]
def doPrintGroupMembers():
  def getNameFromPeople(memberId):
    try:
      info = callGAPI(people.people(), 'get',
                      throwReasons=[GAPI.NOT_FOUND]+GAPI.PEOPLE_ACCESS_THROW_REASONS,
                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                      resourceName=f'people/{memberId}', personFields='names')
      if 'names' in info:
        for sourceType in ['PROFILE', 'CONTACT']:
          for name in info['names']:
            if name['metadata']['source']['type'] == sourceType:
              return name['displayName']
    except (GAPI.notFound, GAPI.serviceNotAvailable, GAPI.forbidden, GAPI.permissionDenied, GAPI.failedPrecondition):
      pass
    return unknownName

  cd = buildGAPIObject(API.DIRECTORY)
  ci = None
  people = None
  memberOptions = initMemberOptions()
  memberDisplayOptions = initIPSGMGroupMemberDisplayOptions()
  groupColumn = True
  customerKey = GC.Values[GC.CUSTOMER_ID]
  kwargsDict = initUserGroupDomainQueryFilters()
  subTitle = f'{Msg.ALL} {Ent.Plural(Ent.GROUP)}'
  fieldsList = []
  csvPF = CSVPrintFile('group')
  FJQC = FormatJSONQuoteChar(csvPF)
  entityList = None
  showOwnedBy = {}
  cdfieldsList = ['email']
  userFieldsList = []
  schemaParms = _initSchemaParms('basic')
  rolesSet = set()
  typesSet = set()
  matchPatterns = {}
  showDeliverySettings = False
  cacheMemberInfo = False
  memberInfo = {}
  memberNames = {}
  unknownName = UNKNOWN
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif getGroupFilters(myarg, kwargsDict, showOwnedBy):
      pass
    elif getGroupMatchPatterns(myarg, matchPatterns, False):
      pass
    elif myarg in {'group', 'groupns', 'groupsusp'}:
      entityList = [getString(Cmd.OB_EMAIL_ADDRESS)]
      subTitle = f'{Ent.Singular(Ent.GROUP)}={entityList[0]}'
      if myarg == 'groupns':
        memberOptions[MEMBEROPTION_ISSUSPENDED] = False
      elif myarg == 'groupsusp':
        memberOptions[MEMBEROPTION_ISSUSPENDED] = True
    elif myarg == 'select':
      entityList = getEntityList(Cmd.OB_GROUP_ENTITY)
      subTitle = f'{Msg.SELECTED} {Ent.Plural(Ent.GROUP)}'
    elif myarg in SUSPENDED_ARGUMENTS:
      memberOptions[MEMBEROPTION_ISSUSPENDED] = _getIsSuspended(myarg)
    elif myarg in ARCHIVED_ARGUMENTS:
      memberOptions[MEMBEROPTION_ISARCHIVED] = _getIsArchived(myarg)
    elif getIPSGMGroupRolesMemberDisplayOptions(myarg, rolesSet, memberDisplayOptions):
      pass
    elif getGroupMemberTypes(myarg, typesSet):
      pass
    elif getMemberMatchOptions(myarg, memberOptions):
      pass
    elif csvPF.GetFieldsListTitles(myarg, GROUPMEMBERS_FIELDS_CHOICE_MAP, fieldsList, initialField='email'):
      pass
    elif myarg == 'membernames':
      memberOptions[MEMBEROPTION_MEMBERNAMES] = True
    elif myarg == 'showdeliverysettings':
      showDeliverySettings = True
    elif myarg == 'userfields':
      for field in _getFieldsList():
        if field in USER_FIELDS_CHOICE_MAP:
          csvPF.AddField(field, USER_FIELDS_CHOICE_MAP, userFieldsList)
        else:
          invalidChoiceExit(field, USER_FIELDS_CHOICE_MAP, True)
    elif myarg in {'allschemas', 'custom', 'schemas', 'customschemas'}:
      if myarg == 'allschemas':
        schemaParms = _initSchemaParms('full')
      else:
        _getSchemaNameList(schemaParms)
      userFieldsList.append('customSchemas')
    elif myarg == 'noduplicates':
      memberOptions[MEMBEROPTION_NODUPLICATES] = True
    elif myarg == 'recursive':
      memberOptions[MEMBEROPTION_RECURSIVE] = True
      memberOptions[MEMBEROPTION_INCLUDEDERIVEDMEMBERSHIP] = False
    elif myarg == 'includederivedmembership':
      memberOptions[MEMBEROPTION_INCLUDEDERIVEDMEMBERSHIP] = True
      memberOptions[MEMBEROPTION_RECURSIVE] = False
    elif myarg == 'nogroupemail':
      groupColumn = False
    elif myarg == 'peoplelookup':
      people = buildGAPIObject(API.PEOPLE)
    elif myarg == 'peoplelookupuser':
      _, people = buildGAPIServiceObject(API.PEOPLE, getEmailAddress())
      if not people:
        return
    elif myarg == 'unknownname':
      unknownName = getString(Cmd.OB_STRING)
    elif myarg == 'cachememberinfo':
      cacheMemberInfo = getBoolean()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  if not typesSet:
    typesSet = {Ent.TYPE_USER} if memberOptions[MEMBEROPTION_RECURSIVE] else ALL_GROUP_MEMBER_TYPES
  entityList = getGroupMembersEntityList(cd, entityList, matchPatterns, cdfieldsList, kwargsDict)
  if not fieldsList:
    for field in GROUPMEMBERS_DEFAULT_FIELDS:
      csvPF.AddField(field, {field: field}, fieldsList)
    if showDeliverySettings:
      field = 'delivery_settings'
      csvPF.AddField(field, {field: field}, fieldsList)
  elif 'name'in fieldsList:
    memberOptions[MEMBEROPTION_MEMBERNAMES] = True
    fieldsList.remove('name')
  if 'group' in fieldsList:
    fieldsList.remove('group')
  if not groupColumn:
    csvPF.RemoveTitles(['group'])
  if memberDisplayOptions['showCategory']:
    csvPF.AddTitles('category')
  if userFieldsList:
    if not memberOptions[MEMBEROPTION_MEMBERNAMES] and 'name.fullName' in userFieldsList:
      memberOptions[MEMBEROPTION_MEMBERNAMES] = True
  if memberOptions[MEMBEROPTION_MEMBERNAMES]:
    if 'name.fullName' not in userFieldsList:
      userFieldsList.append('name.fullName')
    csvPF.AddTitles('name')
    csvPF.RemoveTitles([f'name{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}fullName'])
  if FJQC.formatJSON:
    if groupColumn:
      csvPF.SetJSONTitles(['group', 'JSON'])
    else:
      csvPF.SetJSONTitles(['JSON'])
  memberOptions[MEMBEROPTION_GETDELIVERYSETTINGS] = 'delivery_settings' in fieldsList
  userFields = getFieldsFromFieldsList(userFieldsList)
  if not rolesSet:
    rolesSet = ALL_GROUP_ROLES
  getRolesSet = rolesSet.copy()
  if showOwnedBy:
    getRolesSet.add(Ent.ROLE_OWNER)
  getRoles = ','.join(sorted(getRolesSet))
  level = 0
  setCustomerMemberEmail = 'email' in fieldsList
  i = 0
  count = len(entityList)
  for group in entityList:
    i += 1
    if isinstance(group, dict):
      groupEmail = group['email']
    else:
      groupEmail = convertUIDtoEmailAddress(group, cd, 'group', ciGroupsAPI=True)
      ci, _, groupEmail = convertGroupCloudIDToEmail(ci, groupEmail, i, count)
    if not checkGroupMatchPatterns(groupEmail, group, matchPatterns):
      continue
    membersList = []
    membersSet = set()
    getGroupMembers(cd, groupEmail, getRoles, membersList, membersSet, i, count,
                    memberOptions, memberDisplayOptions, level, typesSet)
    if showOwnedBy and not checkGroupShowOwnedBy(showOwnedBy, membersList):
      continue
    for member in membersList:
      if member['role'] not in rolesSet:
        continue
      memberId = member['id']
      row = {}
      if groupColumn:
        row['group'] = groupEmail
      if memberOptions[MEMBEROPTION_RECURSIVE]:
        row['level'] = member['level']
        row['subgroup'] = member['subgroup']
      for title in fieldsList:
        row[title] = member.get(title, '')
      if setCustomerMemberEmail and (memberId == customerKey):
        row['email'] = memberId
      if memberDisplayOptions['showCategory']:
        row['category'] = member['category']
      memberType = member.get('type')
      if userFieldsList:
        if memberOptions[MEMBEROPTION_MEMBERNAMES]:
          row['name'] = unknownName
        if memberType == Ent.TYPE_USER:
          try:
            if not cacheMemberInfo or memberId not in memberInfo:
              mbinfo = callGAPI(cd.users(), 'get',
                                throwReasons=GAPI.USER_GET_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE, GAPI.FAILED_PRECONDITION],
                                retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                userKey=memberId, projection=schemaParms['projection'], customFieldMask=schemaParms['customFieldMask'],
                                fields=userFields)
              if memberOptions[MEMBEROPTION_MEMBERNAMES]:
                mname = mbinfo['name'].pop('fullName', unknownName)
                row['name'] = mname
                if not mbinfo['name']:
                  mbinfo.pop('name')
                if cacheMemberInfo:
                  memberNames[memberId] = mname
              if cacheMemberInfo:
                memberInfo[memberId] = mbinfo
            else:
              if memberOptions[MEMBEROPTION_MEMBERNAMES]:
                row['name'] = memberNames[memberId]
              mbinfo = memberInfo.get(memberId, {})
            if not FJQC.formatJSON:
              csvPF.WriteRowTitles(flattenJSON(mbinfo, flattened=row))
            else:
              row.update(mbinfo)
              fjrow = {}
              if groupColumn:
                fjrow['group'] = groupEmail
              fjrow['JSON'] = json.dumps(cleanJSON(row, skipObjects=USER_SKIP_OBJECTS, timeObjects=USER_TIME_OBJECTS),
                                         ensure_ascii=False, sort_keys=True)
              csvPF.WriteRowNoFilter(fjrow)
            continue
          except GAPI.userNotFound:
            if memberOptions[MEMBEROPTION_MEMBERNAMES]:
              if people:
                if memberId not in memberNames:
                  memberNames[memberId] = getNameFromPeople(memberId)
              else:
                memberNames[memberId] = unknownName
              row['name'] = memberNames[memberId]
          except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
                  GAPI.badRequest, GAPI.backendError, GAPI.systemError, GAPI.serviceNotAvailable, GAPI.failedPrecondition):
            if memberOptions[MEMBEROPTION_MEMBERNAMES] and cacheMemberInfo:
              memberNames[memberId] = unknownName
        elif memberType == Ent.TYPE_GROUP:
          if memberOptions[MEMBEROPTION_MEMBERNAMES]:
            try:
              if not cacheMemberInfo or memberId not in memberNames:
                row['name'] = callGAPI(cd.groups(), 'get',
                                       throwReasons=GAPI.GROUP_GET_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE, GAPI.FAILED_PRECONDITION],
                                       retryReasons=GAPI.GROUP_GET_RETRY_REASONS,
                                       groupKey=memberId, fields='name')['name']
                if cacheMemberInfo:
                  memberNames[memberId] = row['name']
              else:
                row['name'] = memberNames[memberId]
            except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest,
                    GAPI.invalid, GAPI.systemError, GAPI.serviceNotAvailable, GAPI.failedPrecondition):
              if memberOptions[MEMBEROPTION_MEMBERNAMES] and cacheMemberInfo:
                memberNames[memberId] = unknownName
        elif memberType == Ent.TYPE_CUSTOMER:
          if memberOptions[MEMBEROPTION_MEMBERNAMES]:
            try:
              if not cacheMemberInfo or memberId not in memberNames:
                row['name'] = callGAPI(cd.customers(), 'get',
                                       throwReasons=[GAPI.BAD_REQUEST, GAPI.INVALID_INPUT, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                                       customerKey=memberId, fields='customerDomain')['customerDomain']
                if cacheMemberInfo:
                  memberNames[memberId] = row['name']
              else:
                row['name'] = memberNames[memberId]
            except (GAPI.badRequest, GAPI.invalidInput, GAPI.resourceNotFound, GAPI.forbidden):
              if memberOptions[MEMBEROPTION_MEMBERNAMES] and cacheMemberInfo:
                memberNames[memberId] = unknownName
      if not FJQC.formatJSON:
        csvPF.WriteRow(row)
      else:
        fjrow = {}
        if groupColumn:
          fjrow['group'] = groupEmail
        fjrow['JSON'] = json.dumps(cleanJSON(row, skipObjects=USER_SKIP_OBJECTS, timeObjects=USER_TIME_OBJECTS),
                                   ensure_ascii=False, sort_keys=True)
        csvPF.WriteRowNoFilter(fjrow)
  if not FJQC.formatJSON:
    sortTitles = GROUPMEMBERS_DEFAULT_FIELDS
    if memberDisplayOptions['showCategory']:
      sortTitles.append('category')
    csvPF.SetSortTitles(sortTitles)
    csvPF.SortTitles()
    csvPF.SetSortTitles([])
    if memberOptions[MEMBEROPTION_RECURSIVE]:
      csvPF.MoveTitlesToEnd(['level', 'subgroup'])
  csvPF.writeCSVfile(f'Group Members ({subTitle})')

# gam show group-members
#	[([domain|domains <DomainNameEntity>] ([member|showownedby <EmailItem>]|[(query <QueryGroup>)|(queries <QueryUserList>)]))|
#	 (group|group_ns|group_susp <GroupItem>)|
#	 (select <GroupEntity>)]
#	[emailmatchpattern [not] <REMatchPattern>] [namematchpattern [not] <REMatchPattern>]
#	[descriptionmatchpattern [not] <REMatchPattern>]
#	[roles <GroupRoleList>] [members] [managers] [owners] [depth <Number>]
#	[internal] [internaldomains <DomainList>] [external]
#	[notsuspended|suspended] [notarchived|archived]
#	[types <GroupMemberTypeList>]
#	[memberemaildisplaypattern|memberemailskippattern <REMatchPattern>]
#	[includederivedmembership]
def doShowGroupMembers():
  def _roleOrder(key):
    return {Ent.ROLE_OWNER: 0, Ent.ROLE_MANAGER: 1, Ent.ROLE_MEMBER: 2}.get(key, 3)

  def _typeOrder(key):
    return {Ent.TYPE_CUSTOMER: 0, Ent.TYPE_USER: 1, Ent.TYPE_GROUP: 2, Ent.TYPE_EXTERNAL: 3}.get(key, 4)

  def _statusOrder(key):
    return {'ACTIVE': 0, 'SUSPENDED': 1, 'UNKNOWN': 2}.get(key, 3)

  def _showGroup(groupEmail, depth):
    try:
      membersList = callGAPIpages(cd.members(), 'list', 'members',
                                  throwReasons=GAPI.MEMBERS_THROW_REASONS, retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                                  includeDerivedMembership=includeDerivedMembership,
                                  groupKey=groupEmail, fields='nextPageToken,members(email,id,role,status,type)', maxResults=GC.Values[GC.MEMBER_MAX_RESULTS])
      if showOwnedBy and not checkGroupShowOwnedBy(showOwnedBy, membersList):
        return
    except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden, GAPI.serviceNotAvailable):
      if depth == 0:
        entityUnknownWarning(Ent.GROUP, groupEmail, i, count)
      return
    if depth == 0:
      printEntity([Ent.GROUP, groupEmail], i, count)
    if depth == 0 or Ent.TYPE_GROUP in typesSet:
      Ind.Increment()
    for member in sorted(membersList, key=lambda k: (_roleOrder(k.get('role', Ent.ROLE_MEMBER)), _typeOrder(k['type']), _statusOrder(k.get('status', '')))):
      if (_checkMemberIsSuspendedIsArchived(member, memberOptions[MEMBEROPTION_ISSUSPENDED], memberOptions[MEMBEROPTION_ISARCHIVED]) and
          (not checkCategory or _checkMemberCategory(member, memberDisplayOptions))):
        if (member.get('role', Ent.ROLE_MEMBER) in rolesSet and
            member['type'] in typesSet and
            checkMemberMatch(member, memberOptions)):
          memberDetails = f'{member.get("role", Ent.ROLE_MEMBER)}, {member["type"]}, {member.get("email", member["id"])}'
          if checkCategory:
            memberDetails += f', {member["category"]}'
          memberDetails += f' , {member.get("status", "")}'
          printKeyValueList([memberDetails])
        if not includeDerivedMembership and (member['type'] == Ent.TYPE_GROUP) and (maxdepth == -1 or depth < maxdepth):
          _showGroup(member['email'], depth+1)
    if depth == 0 or Ent.TYPE_GROUP in typesSet:
      Ind.Decrement()

  cd = buildGAPIObject(API.DIRECTORY)
  ci = None
  kwargsDict = initUserGroupDomainQueryFilters()
  entityList = None
  showOwnedBy = {}
  cdfieldsList = ['email']
  rolesSet = set()
  typesSet = set()
  memberOptions = initMemberOptions()
  memberDisplayOptions = initIPSGMGroupMemberDisplayOptions()
  matchPatterns = {}
  maxdepth = -1
  includeDerivedMembership = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if getGroupFilters(myarg, kwargsDict, showOwnedBy):
      pass
    elif getGroupMatchPatterns(myarg, matchPatterns, False):
      pass
    elif myarg in {'group', 'groupns', 'groupsusp'}:
      entityList = [getString(Cmd.OB_EMAIL_ADDRESS)]
      if myarg == 'groupns':
        memberOptions[MEMBEROPTION_ISSUSPENDED] = False
      elif myarg == 'groupsusp':
        memberOptions[MEMBEROPTION_ISSUSPENDED] = True
    elif myarg == 'select':
      entityList = getEntityList(Cmd.OB_GROUP_ENTITY)
    elif myarg in SUSPENDED_ARGUMENTS:
      memberOptions[MEMBEROPTION_ISSUSPENDED] = _getIsSuspended(myarg)
    elif myarg in ARCHIVED_ARGUMENTS:
      memberOptions[MEMBEROPTION_ISARCHIVED] = _getIsArchived(myarg)
    elif getIPSGMGroupRolesMemberDisplayOptions(myarg, rolesSet, memberDisplayOptions):
      pass
    elif getGroupMemberTypes(myarg, typesSet):
      pass
    elif getMemberMatchOptions(myarg, memberOptions):
      pass
    elif myarg == 'depth':
      maxdepth = getInteger(minVal=-1)
    elif myarg == 'includederivedmembership':
      includeDerivedMembership = True
    else:
      unknownArgumentExit()
  if not rolesSet:
    rolesSet = ALL_GROUP_ROLES
  if not typesSet:
    typesSet = ALL_GROUP_MEMBER_TYPES
  checkCategory = memberDisplayOptions['showCategory']
  entityList = getGroupMembersEntityList(cd, entityList, matchPatterns, cdfieldsList, kwargsDict)
  i = 0
  count = len(entityList)
  for group in entityList:
    i += 1
    if isinstance(group, dict):
      groupEmail = group['email']
    else:
      groupEmail = convertUIDtoEmailAddress(group, cd, 'group', ciGroupsAPI=True)
      ci, _, groupEmail = convertGroupCloudIDToEmail(ci, groupEmail, i, count)
    if checkGroupMatchPatterns(groupEmail, group, matchPatterns):
      _showGroup(groupEmail, 0)

def getGroupParents(cd, groupParents, groupEmail, groupName, kwargs):
  groupParents[groupEmail] = {'name': groupName, 'parents': []}
  _setUserGroupArgs(groupEmail, kwargs)
  try:
    entityList = callGAPIpages(cd.groups(), 'list', 'groups',
                               throwReasons=GAPI.GROUP_LIST_USERKEY_THROW_REASONS,
                               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                               orderBy='email', fields='nextPageToken,groups(email,name)', **kwargs)
    for parentGroup in entityList:
      groupParents[groupEmail]['parents'].append(parentGroup['email'])
      if parentGroup['email'] not in groupParents:
        getGroupParents(cd, groupParents, parentGroup['email'], parentGroup['name'], kwargs)
  except (GAPI.invalidMember, GAPI.invalidInput):
    badRequestWarning(Ent.GROUP, Ent.MEMBER, groupEmail)
  except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.forbidden, GAPI.badRequest):
    accessErrorExit(cd)

def showGroupParents(groupParents, groupEmail, role, i, count):
  kvList = [groupEmail, f'{groupParents[groupEmail]["name"]}']
  if role:
    kvList.extend([Ent.Singular(Ent.ROLE), role])
  printKeyValueListWithCount(kvList, i, count)
  Ind.Increment()
  for parentEmail in groupParents[groupEmail]['parents']:
    showGroupParents(groupParents, parentEmail, None, 0, 0)
  Ind.Decrement()

def addJsonGroupParents(groupParents, userGroup, groupEmail):
  userGroup.setdefault('parents', [])
  for parentEmail in groupParents[groupEmail]['parents']:
    userGroup['parents'].append({'email': parentEmail, 'name': groupParents[parentEmail]['name'], 'parents': []})
    addJsonGroupParents(groupParents, userGroup['parents'][-1], parentEmail)

def printGroupParents(groupParents, groupEmail, row, csvPF, delimiter, showParentsAsList):
  if groupParents[groupEmail]['parents']:
    for parentEmail in groupParents[groupEmail]['parents']:
      row['parents'].append({'email': parentEmail, 'name': groupParents[parentEmail]['name']})
      printGroupParents(groupParents, parentEmail, row, csvPF, delimiter, showParentsAsList)
      del row['parents'][-1]
  else:
    if not showParentsAsList:
      csvPF.WriteRowTitles(flattenJSON(row))
    else:
      crow = row.copy()
      if 'Role' in row:
        crow['Role'] = row['Role']
      parents = crow.pop('parents')
      crow['ParentsCount'] = len(parents)
      crow['Parents'] = delimiter.join([parent['email'] for parent in parents])
      crow['ParentsName'] = delimiter.join([parent['name'] for parent in parents])
      csvPF.WriteRow(flattenJSON(crow))

# gam print grouptree <GroupEntity> [todrive <ToDriveAttribute>*]
#	[showparentsaslist [<Boolean>]] [delimiter <Character>]
#	[formatjson [quotechar <Character>]]
# gam show grouptree <GroupEntity>
#	[formatjson]
def doPrintShowGroupTree():
  cd = buildGAPIObject(API.DIRECTORY)
  kwargs = {'customer': GC.Values[GC.CUSTOMER_ID]}
  csvPF = CSVPrintFile(['Group', 'Name']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  showParentsAsList = False
  entityList = getEntityList(Cmd.OB_GROUP_ENTITY)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif csvPF and myarg == 'delimiter':
      delimiter = getCharacter()
    elif csvPF and myarg == 'showparentsaslist':
      showParentsAsList = getBoolean()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if csvPF and not FJQC.formatJSON:
    if not showParentsAsList:
      csvPF.SetIndexedTitles(['parents'])
    else:
      csvPF.AddTitles(['ParentsCount', 'Parents', 'ParentsName'])
  groupParents = {}
  i = 0
  count = len(entityList)
  if not csvPF and not FJQC.formatJSON:
    performActionNumItems(count, Ent.GROUP_TREE)
  for group in entityList:
    i += 1
    groupEmail = normalizeEmailAddressOrUID(group)
    if groupEmail not in groupParents:
      try:
        groupName = callGAPI(cd.groups(), 'get',
                             throwReasons=GAPI.GROUP_GET_THROW_REASONS, retryReasons=GAPI.GROUP_GET_RETRY_REASONS,
                             groupKey=groupEmail, fields='name')['name']
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest,
              GAPI.invalid, GAPI.systemError) as e:
        entityActionFailedWarning([Ent.GROUP, groupEmail], str(e), i, count)
        continue
      getGroupParents(cd, groupParents, groupEmail, groupName, kwargs)
    if not FJQC.formatJSON:
      if not csvPF:
        showGroupParents(groupParents, groupEmail, None, i, count)
      else:
        row = {'Group': groupEmail, 'Name': groupParents[groupEmail]['name'], 'parents': []}
        printGroupParents(groupParents, groupEmail, row, csvPF, delimiter, showParentsAsList)
    else:
      groupInfo = {'email': groupEmail, 'name': groupParents[groupEmail]['name'], 'parents': []}
      addJsonGroupParents(groupParents, groupInfo, groupEmail)
      if not csvPF:
        printLine(json.dumps(cleanJSON(groupInfo), ensure_ascii=False, sort_keys=True))
      else:
        row = flattenJSON(groupInfo)
        if csvPF.CheckRowTitles(row):
          csvPF.WriteRowNoFilter({'Group': groupEmail, 'Name': groupParents[groupEmail]['name'],
                                  'JSON': json.dumps(cleanJSON(groupInfo),
                                                     ensure_ascii=False, sort_keys=True)})
  if csvPF:
    csvPF.writeCSVfile('Group Tree')

# gam create cigroup <EmailAddress>
#	[copyfrom <GroupItem>] <GroupAttribute>
#	[makeowner] [alias|aliases <CIGroupAliasList>]
#	[security|makesecuritygroup]
#	[dynamic <QueryDynamicGroup>]
def doCreateCIGroup():
  doCreateGroup(ciGroupsAPI=True)

# gam update cigroups <GroupEntity> [email <EmailAddress>]
#	[copyfrom <GroupItem>] <GroupAttribute>*
#	[security|makesecuritygroup|dynamicsecurity|makedynamicsecuritygroup]
#	[dynamic <QueryDynamicGroup>]
#	[memberrestrictions <QueryMemberRestrictions>]
#	[locked|unlocked]
# gam update cigroups <GroupEntity> add|create [<GroupRole>]
#	[usersonly|groupsonly]
#	[notsuspended|suspended] [notarchived|archived]
#	[expire|expires <Time>] [preview] [actioncsv]
#	<UserTypeEntity>
# gam update cigroups <GroupEntity> delete|remove [<GroupRole>]
#	[usersonly|groupsonly]
#	[notsuspended|suspended] [notarchived|archived]
#	[preview] [actioncsv]
#	<UserTypeEntity>
# gam update cigroups <GroupEntity> sync [<GroupRole>|ignorerole]
#	[usersonly|groupsonly] [addonly|removeonly]
#	[notsuspended|suspended] [notarchived|archived]
#	[expire|expires <Time>] [preview] [actioncsv]
#	<UserTypeEntity>
# gam update cigroups <GroupEntity> update [<GroupRole>]
#	[usersonly|groupsonly]
#	[notsuspended|suspended] [notarchived|archived]
#	[expire|expires <Time>] [preview] [actioncsv]
#	<UserTypeEntity>
# gam update cigroups <GroupEntity> clear [member] [manager] [owner]
#	[usersonly|groupsonly]
#	[emailclearpattern|emailretainpattern <REMatchPattern>]
#	[preview] [actioncsv]
def doUpdateCIGroups():

  def _getExpireTime(role):
    if role == Ent.ROLE_MEMBER and checkArgumentPresent(['expire', 'expires']):
      return getTimeOrDeltaFromNow()
    return None

  def _getPreviewActionCSV():
    preview = checkArgumentPresent('preview')
    if checkArgumentPresent('actioncsv'):
      csvPF = CSVPrintFile(GROUP_PREVIEW_TITLES)
    else:
      csvPF = None
    return (preview, csvPF)

  def _validateSubkeyRoleGetMembers(group, role, origGroup, groupMemberLists, i, count):
    roleLower = role.lower()
    if roleLower in GROUP_ROLES_MAP:
      return (GROUP_ROLES_MAP[roleLower], groupMemberLists[origGroup][role])
    entityActionNotPerformedWarning([entityType, group, Ent.ROLE, role], Msg.INVALID_ROLE.format(','.join(sorted(GROUP_ROLES_MAP))), i, count)
    return (None, None)

  def _getRoleGroupMemberType(defaultRole=Ent.ROLE_MEMBER, allowIgnoreRole=False):
    if not allowIgnoreRole or not checkArgumentPresent(['ignorerole']):
      role = getChoice(GROUP_ROLES_MAP, defaultChoice=defaultRole, mapChoice=True)
    else:
      role = Ent.ROLE_ALL
    groupMemberType = getChoice({'usersonly': Ent.TYPE_USER, 'groupsonly': Ent.TYPE_GROUP}, defaultChoice='ALL', mapChoice=True)
    return (role, groupMemberType)

  def _getMemberEmail(member):
    if member['type'] == Ent.TYPE_CUSTOMER:
      return member['id']
    return member.get('preferredMemberKey', {}).get('id', '')

  def checkDynamicGroup(ci, group, i, count):
    try:
      result = callGAPI(ci.groups(), 'get',
                        throwReasons=GAPI.CIGROUP_GET_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                        name=group, fields='labels')
      if CIGROUP_DYNAMIC_LABEL in result.get('labels', {}):
        entityActionNotPerformedWarning([entityType, group], Msg.DYNAMIC_GROUP_MEMBERSHIP_CANNOT_BE_MODIFIED, i, count)
        return True
      return False
    except (GAPI.notFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
            GAPI.forbidden, GAPI.badRequest, GAPI.invalid,
            GAPI.systemError, GAPI.permissionDenied, GAPI.serviceNotAvailable):
      return True

# Convert foo@googlemail.com to foo@gmail.com; eliminate periods in name for foo.bar@gmail.com
  def _cleanConsumerAddress(emailAddress, mapCleanToOriginal):
    atLoc = emailAddress.find('@')
    if atLoc > 0:
      if emailAddress[atLoc+1:] in {'gmail.com', 'googlemail.com'}:
        cleanEmailAddress = emailAddress[:atLoc].replace('.', '')+'@gmail.com'
        if cleanEmailAddress != emailAddress:
          mapCleanToOriginal[cleanEmailAddress] = emailAddress
          return cleanEmailAddress
    return emailAddress

  def _previewAction(group, members, role, jcount, action):
    Ind.Increment()
    j = 0
    for member in members:
      j += 1
      entityActionPerformed([entityType, group, role, member], j, jcount)
    Ind.Decrement()
    if csvPF:
      for member in members:
        csvPF.WriteRow({'group': group, 'email': member, 'role': role, 'action': Act.PerformedName(action), 'message': Act.PREVIEW})

  def _showSuccess(group, member, role, expireTime, j, jcount, optMsg=None):
    kvList = []
    if role is not None and role != 'None':
      kvList.append(f'{Ent.Singular(Ent.ROLE)}: {role}')
    if expireTime:
      kvList.extend(['expireTime', formatLocalTime(expireTime)])
    if optMsg:
      kvList.append(optMsg)
    entityActionPerformedMessage([entityType, group, Ent.MEMBER, member], ', '.join(kvList), j, jcount)
    if csvPF:
      csvPF.WriteRow({'group': group, 'email': member, 'role': role, 'action': Act.Performed(), 'message': Act.SUCCESS})

  def _showFailure(group, member, role, errMsg, j, jcount):
    entityActionFailedWarning([entityType, group, Ent.MEMBER, member], errMsg, j, jcount)
    if csvPF:
      csvPF.WriteRow({'group': group, 'email': member, 'role': role, 'action': Act.Failed(), 'message': errMsg})

  def _batchAddGroupMembers(group, i, count, addMembers, role, expireTime):
    Act.Set([Act.ADD, Act.ADD_PREVIEW][preview])
    jcount = len(addMembers)
    entityPerformActionNumItems([entityType, group], jcount, role, i, count)
    if jcount == 0:
      return
    if preview:
      _previewAction(group, addMembers, role, jcount, Act.ADD)
      return
    Ind.Increment()
    j = 0
    for member in addMembers:
      j += 1
      body = {'preferredMemberKey': {'id': member}, 'roles': [{'name': Ent.ROLE_MEMBER}]}
      if role != Ent.ROLE_MEMBER:
        body['roles'].append({'name': role})
      elif expireTime not in {None, NEVER_TIME}:
        body['roles'][0]['expiryDetail'] = {'expireTime': expireTime}
      try:
        callGAPI(ci.groups().memberships(), 'create',
                 throwReasons=GAPI.MEMBERS_THROW_REASONS+[GAPI.DUPLICATE, GAPI.MEMBER_NOT_FOUND, GAPI.RESOURCE_NOT_FOUND,
                                                          GAPI.INVALID_MEMBER, GAPI.CYCLIC_MEMBERSHIPS_NOT_ALLOWED,
                                                          GAPI.CONDITION_NOT_MET, GAPI.FAILED_PRECONDITION, GAPI.PERMISSION_DENIED,
                                                          GAPI.ALREADY_EXISTS, GAPI.CONFLICT],
                 parent=group, body=body, fields='')
        _showSuccess(group, member, role, expireTime, j, jcount)
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden):
        entityUnknownWarning(entityType, group, i, count)
      except (GAPI.duplicate, GAPI.memberNotFound, GAPI.resourceNotFound,
              GAPI.invalidMember, GAPI.cyclicMembershipsNotAllowed, GAPI.conditionNotMet,
              GAPI.failedPrecondition, GAPI.permissionDenied, GAPI.alreadyExists) as e:
        _showFailure(group, member, role, str(e), j, jcount)
      except GAPI.conflict:
        _showSuccess(group, member, role, expireTime, j, jcount, Msg.ACTION_MAY_BE_DELAYED)
    Ind.Decrement()

  def _batchRemoveGroupMembers(group, i, count, removeMembers, role):
    Act.Set([Act.REMOVE, Act.REMOVE_PREVIEW][preview])
    jcount = len(removeMembers)
    entityPerformActionNumItems([entityType, group], jcount, role, i, count)
    if jcount == 0:
      return
    if preview:
      _previewAction(group, removeMembers, role, jcount, Act.REMOVE)
      return
    Ind.Increment()
    j = 0
    for member in removeMembers:
      j += 1
      memberEmail = member['email']
      try:
        callGAPI(ci.groups().memberships(), 'delete',
                 throwReasons=GAPI.MEMBERS_THROW_REASONS+[GAPI.FAILED_PRECONDITION],
                 name=member['name'])
        _showSuccess(group, memberEmail, role, None, j, jcount)
      except (GAPI.memberNotFound, GAPI.invalidMember, GAPI.failedPrecondition, GAPI.permissionDenied) as e:
        _showFailure(group, memberEmail, role, str(e), j, jcount)
    Ind.Decrement()

  cd = buildGAPIObject(API.DIRECTORY)
  ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
  cib = None
  entityType = Ent.CLOUD_IDENTITY_GROUP
  csvPF = None
  getBeforeUpdate = preview = False
  entityList = getEntityList(Cmd.OB_GROUP_ENTITY)
  CL_subCommand = getChoice(UPDATE_GROUP_SUBCMDS, defaultChoice=None)
  lockGroup = None
  if not CL_subCommand:
    gs_body = {}
    ci_body = {}
    se_body = {}
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg == 'email':
        ci_body['groupKey'] = {'id': getEmailAddress(noUid=True)}
      elif myarg == 'getbeforeupdate':
        getBeforeUpdate = True
      elif myarg == 'dynamic':
        ci_body.setdefault('dynamicGroupMetadata', {'queries': []})
        ci_body['dynamicGroupMetadata']['queries'].append({'resourceType': 'USER',
                                                           'query': getString(Cmd.OB_QUERY)})
      elif myarg in {'security', 'makesecuritygroup'}:
        ci_body['labels'] = {CIGROUP_DISCUSSION_FORUM_LABEL: '',
                             CIGROUP_SECURITY_LABEL: ''}
      elif myarg in {'dynamicsecurity', 'makedynamicsecuritygroup'}:
        ci_body['labels'] = {CIGROUP_DISCUSSION_FORUM_LABEL: '',
                             CIGROUP_DYNAMIC_LABEL: '',
                             CIGROUP_SECURITY_LABEL: ''}
      elif myarg in {'lockedsecurity', 'makelockedsecuritygroup'}:
        ci_body['labels'] = {CIGROUP_DISCUSSION_FORUM_LABEL: '',
                             CIGROUP_LOCKED_LABEL: '',
                             CIGROUP_SECURITY_LABEL: ''}
      elif myarg in ['memberrestriction', 'memberrestrictions']:
        query = getString(Cmd.OB_QUERY, minLen=0)
        member_types = {'USER': '1', 'SERVICE_ACCOUNT': '2', 'GROUP': '3',}
        for key, val in iter(member_types.items()):
          query = query.replace(key, val)
        se_body['memberRestriction'] = {'query': query}
      elif myarg == 'locked':
        lockGroup = True
      elif myarg == 'unlocked':
        lockGroup = False
      elif myarg == 'json':
        gs_body.update(getJSON(GROUP_JSON_SKIP_FIELDS))
      elif myarg == 'accesstype':
        gs_body.update(getChoice(GROUP_ACCESS_TYPE_CHOICE_MAP, mapChoice=True))
      else:
        getGroupAttrValue(myarg, gs_body)
    if gs_body:
      gs = buildGAPIObject(API.GROUPSSETTINGS)
      gs_body = getSettingsFromGroup(cd, ','.join(entityList), gs, gs_body)
      for k, v in iter(GROUP_CIGROUP_FIELDS_MAP.items()):
        if k in gs_body:
          ci_body[v] = gs_body.pop(k)
      if gs_body:
        if not getBeforeUpdate:
          settings = gs_body
      elif not ci_body:
        return
    elif not ci_body and not se_body and lockGroup is None:
      return
    Act.Set(Act.UPDATE)
    i = 0
    count = len(entityList)
    for group in entityList:
      i += 1
      ci, _, group = convertGroupCloudIDToEmail(ci, group, i, count)
      if gs_body and not GroupIsAbuseOrPostmaster(group):
        try:
          if group.find('@') == -1: # group settings API won't take uid so we make sure cd API is used so that we can grab real email.
            group = callGAPI(cd.groups(), 'get',
                             throwReasons=GAPI.GROUP_GET_THROW_REASONS, retryReasons=GAPI.GROUP_GET_RETRY_REASONS,
                             groupKey=group, fields='email')['email']
          if getBeforeUpdate:
            settings = callGAPI(gs.groups(), 'get',
                                throwReasons=GAPI.GROUP_SETTINGS_THROW_REASONS, retryReasons=GAPI.GROUP_SETTINGS_RETRY_REASONS,
                                groupUniqueId=mapGroupEmailForSettings(group), fields='*')
            settings.update(gs_body)
          if not checkReplyToCustom(group, settings, i, count):
            continue
        except GAPI.notFound:
          entityActionFailedWarning([entityType, group], Msg.DOES_NOT_EXIST, i, count)
          continue
        except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
                GAPI.backendError, GAPI.invalid, GAPI.invalidInput, GAPI.badRequest, GAPI.permissionDenied,
                GAPI.systemError, GAPI.serviceLimit, GAPI.serviceNotAvailable, GAPI.authError) as e:
          entityActionFailedWarning([entityType, group], str(e), i, count)
          continue
      if gs_body and not GroupIsAbuseOrPostmaster(group):
        try:
          callGAPI(gs.groups(), 'update',
                   bailOnInvalidError='messageModerationLevel' in settings,
                   throwReasons=GAPI.GROUP_SETTINGS_THROW_REASONS, retryReasons=GAPI.GROUP_SETTINGS_RETRY_REASONS,
                   groupUniqueId=mapGroupEmailForSettings(group), body=settings, fields='')
        except GAPI.notFound:
          entityActionFailedWarning([entityType, group], Msg.DOES_NOT_EXIST, i, count)
          continue
        except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.backendError,
                GAPI.invalid, GAPI.invalidArgument, GAPI.invalidAttributeValue, GAPI.invalidInput, GAPI.badRequest, GAPI.permissionDenied,
                GAPI.systemError, GAPI.serviceLimit, GAPI.serviceNotAvailable, GAPI.authError) as e:
          entityActionFailedWarning([entityType, group], str(e), i, count)
          continue
        except GAPI.required:
          entityActionFailedWarning([entityType, group], Msg.INVALID_JSON_SETTING, i, count)
          continue
      if ci_body or se_body or lockGroup is not None:
        _, name, _ = convertGroupEmailToCloudID(ci, group, i, count)
        if not name:
          continue
        cipl = ci
        twoUpdates = False
        if 'labels' in ci_body or lockGroup is not None:
          try:
            cigInfo = callGAPI(ci.groups(), 'get',
                               throwReasons=GAPI.CIGROUP_GET_THROW_REASONS,
                               retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                               name=name, fields='labels')
          except (GAPI.notFound, GAPI.groupNotFound, GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.backendError,
                  GAPI.invalid, GAPI.invalidMember, GAPI.invalidParameter, GAPI.invalidInput, GAPI.forbidden, GAPI.badRequest,
                  GAPI.permissionDenied, GAPI.systemError, GAPI.serviceLimit, GAPI.serviceNotAvailable) as e:
            entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, group], str(e), i, count)
            continue
          # If a group currently isn't a security group or locked, and we want to add security and locked,
          # we have to do two commands to meet a beta requirement
          ci_body.setdefault('labels', {})
          if ((CIGROUP_SECURITY_LABEL not in cigInfo['labels']) and
              (CIGROUP_LOCKED_LABEL not in cigInfo['labels']) and
              ((CIGROUP_SECURITY_LABEL in ci_body['labels']) and
               ((CIGROUP_LOCKED_LABEL in ci_body['labels']) or lockGroup))):
            twoUpdates = True
          ci_body['labels'].update(cigInfo['labels'])
          if lockGroup is not None:
            if lockGroup:
              if CIGROUP_LOCKED_LABEL not in ci_body['labels']:
                ci_body['labels'][CIGROUP_LOCKED_LABEL] = ''
            else:
              if CIGROUP_LOCKED_LABEL in ci_body['labels']:
                ci_body['labels'].pop(CIGROUP_LOCKED_LABEL)
          if CIGROUP_LOCKED_LABEL in ci_body['labels']:
            if cib is None:
              cib = buildGAPIObject(API.CLOUDIDENTITY_GROUPS_BETA)
            cipl = cib
        if ci_body:
          try:
            if twoUpdates:
              ci_body['labels'].pop(CIGROUP_LOCKED_LABEL)
              callGAPI(cipl.groups(), 'patch',
                       throwReasons=GAPI.CIGROUP_UPDATE_THROW_REASONS,
                       retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                       name=name, body=ci_body, updateMask=','.join(list(ci_body.keys())))
              ci_body['labels'][CIGROUP_LOCKED_LABEL] = ''
            callGAPI(cipl.groups(), 'patch',
                     throwReasons=GAPI.CIGROUP_UPDATE_THROW_REASONS,
                     retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                     name=name, body=ci_body, updateMask=','.join(list(ci_body.keys())))
          except (GAPI.notFound, GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
                  GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.invalidInput, GAPI.invalidArgument,
                  GAPI.systemError, GAPI.permissionDenied, GAPI.failedPrecondition, GAPI.serviceNotAvailable) as e:
            entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, group], str(e), i, count)
            continue
        if se_body:
          # It seems like a bug that API requires /securitySettings appended to name.
          # We'll see if Google servers change this at some point.
          try:
            callGAPI(ci.groups(), 'updateSecuritySettings',
                     throwReasons=GAPI.CIGROUP_UPDATE_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                     name=f'{name}/securitySettings', updateMask='member_restriction.query', body=se_body)
          except (GAPI.notFound, GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
                  GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.invalidInput, GAPI.invalidArgument,
                  GAPI.systemError, GAPI.permissionDenied, GAPI.failedPrecondition, GAPI.serviceNotAvailable) as e:
            entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, group], str(e), i, count)
            continue
      entityActionPerformed([entityType, group], i, count)
  elif CL_subCommand in {'create', 'add'}:
    baseRole, groupMemberType = _getRoleGroupMemberType()
    isSuspended, isArchived = _getOptionalIsSuspendedIsArchived()
    expireTime = _getExpireTime(baseRole)
    preview, csvPF = _getPreviewActionCSV()
    _, addMembers = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS,
                                      isSuspended=isSuspended, isArchived=isArchived, groupMemberType=groupMemberType)
    groupMemberLists = addMembers if isinstance(addMembers, dict) else None
    subkeyRoleField = GM.Globals[GM.CSV_SUBKEY_FIELD]
    checkForExtraneousArguments()
    i = 0
    count = len(entityList)
    for group in entityList:
      i += 1
      roleList = [baseRole]
      if groupMemberLists:
        if not subkeyRoleField:
          addMembers = groupMemberLists[group]
        else:
          roleList = groupMemberLists[group]
      origGroup = group
      _, parent, group = checkGroupExists(cd, ci, True, group, i, count)
      if not group or checkDynamicGroup(ci, parent, i, count):
        continue
      for role in roleList:
        if groupMemberLists and subkeyRoleField:
          role, addMembers = _validateSubkeyRoleGetMembers(group, role, origGroup, groupMemberLists, i, count)
          if role is None:
            continue
        _batchAddGroupMembers(parent, i, count,
                              [convertUIDtoEmailAddress(member, cd=cd, emailTypes='any',
                                                        checkForCustomerId=True, ciGroupsAPI=True) for member in addMembers],
                              role, expireTime)
  elif CL_subCommand in {'delete', 'remove'}:
    baseRole, groupMemberType = _getRoleGroupMemberType()
    isSuspended, isArchived = _getOptionalIsSuspendedIsArchived()
    preview, csvPF = _getPreviewActionCSV()
    _, removeMembers = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS,
                                         isSuspended=isSuspended, isArchived=isArchived, groupMemberType=groupMemberType)
    groupMemberLists = removeMembers if isinstance(removeMembers, dict) else None
    subkeyRoleField = GM.Globals[GM.CSV_SUBKEY_FIELD]
    checkForExtraneousArguments()
    i = 0
    count = len(entityList)
    for group in entityList:
      i += 1
      roleList = [baseRole]
      if groupMemberLists:
        if not subkeyRoleField:
          removeMembers = groupMemberLists[group]
        else:
          roleList = groupMemberLists[group]
      origGroup = group
      _, parent, group = checkGroupExists(cd, ci, True, group, i, count)
      if not group or checkDynamicGroup(ci, parent, i, count):
        continue
      for role in roleList:
        if groupMemberLists and subkeyRoleField:
          role, removeMembers = _validateSubkeyRoleGetMembers(group, role, origGroup, groupMemberLists, i, count)
          if role is None:
            continue
        Act.Set([Act.DELETE, Act.DELETE_PREVIEW][preview])
        jcount = len(removeMembers)
        entityPerformActionNumItems([entityType, group], jcount, Ent.MEMBER, i, count)
        if jcount == 0:
          continue
        if preview:
          _previewAction(group, removeMembers, role or Ent.ROLE_USER, jcount, Act.DELETE)
          continue
        Ind.Increment()
        j = 0
        for member in removeMembers:
          j += 1
          memberEmail = convertUIDtoEmailAddress(member, cd=cd, emailTypes='any',
                                                 checkForCustomerId=True, ciGroupsAPI=True)
          try:
            memberName = callGAPI(ci.groups().memberships(), 'lookup',
                                  throwReasons=GAPI.CIGROUP_GET_THROW_REASONS,
                                  parent=parent, memberKey_id=memberEmail, fields='name').get('name')
            callGAPI(ci.groups().memberships(), 'delete',
                     throwReasons=GAPI.MEMBERS_THROW_REASONS+[GAPI.FAILED_PRECONDITION],
                     name=memberName)
            _showSuccess(group, memberEmail, role, None, j, jcount)
          except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden,
                  GAPI.notFound, GAPI.memberNotFound, GAPI.permissionDenied, GAPI.invalidMember, GAPI.failedPrecondition) as e:
            _showFailure(group, memberEmail, role, str(e), j, jcount)
        Ind.Decrement()
  elif CL_subCommand == 'sync':
    baseRole, groupMemberType = _getRoleGroupMemberType(allowIgnoreRole=True)
    ignoreRole = baseRole == Ent.ROLE_ALL
    syncOperation = getSyncOperation()
    isSuspended, isArchived = _getOptionalIsSuspendedIsArchived()
    expireTime = _getExpireTime(baseRole)
    preview, csvPF = _getPreviewActionCSV()
    _, syncMembers = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS,
                                       isSuspended=isSuspended, isArchived=isArchived, groupMemberType=groupMemberType)
    groupMemberLists = syncMembers if isinstance(syncMembers, dict) else None
    subkeyRoleField = GM.Globals[GM.CSV_SUBKEY_FIELD]
    syncMembersSets = {}
    syncMembersMaps = {}
    currentMembersSets = {}
    currentMembersMaps = {}
    if groupMemberLists is None:
      syncMembersSets[baseRole] = set()
      syncMembersMaps[baseRole] = {}
      for member in syncMembers:
        syncMembersSets[baseRole].add(_cleanConsumerAddress(convertUIDtoEmailAddress(member, cd=cd, emailTypes='any',
                                                                                     checkForCustomerId=True, ciGroupsAPI=True), syncMembersMaps[baseRole]))
    checkForExtraneousArguments()
    i = 0
    count = len(entityList)
    for group in entityList:
      i += 1
      origGroup = group
      _, parent, group = checkGroupExists(cd, ci, True, group, i, count)
      if not group or checkDynamicGroup(ci, parent, i, count):
        continue
      if groupMemberLists is None:
        roleList = [baseRole]
      else:
        if not subkeyRoleField:
          roleList = [baseRole]
        else:
          roleList = groupMemberLists[origGroup]
        for role in roleList:
          role = role.upper()
          syncMembersSets[role] = set()
          syncMembersMaps[role] = {}
      rolesSet = set()
      for role in roleList:
        origRole = role
        role = role.upper()
        if groupMemberLists is None:
          rolesSet.add(role)
        else:
          if not subkeyRoleField:
            rolesSet.add(role)
            syncMembers = groupMemberLists[origGroup]
          else:
            role, syncMembers = _validateSubkeyRoleGetMembers(group, origRole, origGroup, groupMemberLists, i, count)
            if role is None:
              continue
            rolesSet.add(role)
          for member in syncMembers:
            syncMembersSets[role].add(_cleanConsumerAddress(convertUIDtoEmailAddress(member, cd=cd, emailTypes='any',
                                                                                     checkForCustomerId=True), syncMembersMaps[role]))
      if not rolesSet:
        continue
      memberRoles = ','.join(sorted(rolesSet))
      printGettingAllEntityItemsForWhom(memberRoles, group, entityType=entityType)
      try:
        result = callGAPIpages(ci.groups().memberships(), 'list', 'memberships',
                               pageMessage=getPageMessageForWhom(),
                               throwReasons=GAPI.MEMBERS_THROW_REASONS, retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                               parent=parent, view='FULL',
                               fields='nextPageToken,memberships(name,preferredMemberKey(id),roles(name),type)',
                               pageSize=GC.Values[GC.MEMBER_MAX_RESULTS])
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden):
        entityUnknownWarning(Ent.CLOUD_IDENTITY_GROUP, group)
        continue
      except (GAPI.conditionNotMet, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, group], str(e))
        continue
      currentMembersNames = {}
      for role in rolesSet:
        currentMembersSets[role] = set()
        currentMembersMaps[role] = {}
      for member in result:
        getCIGroupMemberRoleFixType(member)
        role = member['role'] if not ignoreRole else Ent.ROLE_ALL
        email = member.get('preferredMemberKey', {}).get('id', '')
        if groupMemberType in ('ALL', member['type']) and role in rolesSet:
          cleanAddress = _cleanConsumerAddress(email, currentMembersMaps[role])
          currentMembersSets[role].add(cleanAddress)
          currentMembersNames[cleanAddress] = member['name']
      del result
      if syncOperation != 'addonly':
        for role in rolesSet:
          _batchRemoveGroupMembers(parent, i, count,
                                   [{'name': currentMembersNames[emailAddress],
                                     'email': currentMembersMaps[role].get(emailAddress, emailAddress)} for emailAddress in currentMembersSets[role]-syncMembersSets[role]],
                                   role)
      if syncOperation != 'removeonly':
        for role in [Ent.ROLE_OWNER, Ent.ROLE_MANAGER, Ent.ROLE_MEMBER, Ent.ROLE_ALL]:
          if role in rolesSet:
            _batchAddGroupMembers(parent, i, count,
                                  [syncMembersMaps[role].get(emailAddress, emailAddress) for emailAddress in syncMembersSets[role]-currentMembersSets[role]],
                                  role if role != Ent.ROLE_ALL else Ent.ROLE_MEMBER, expireTime)
  elif CL_subCommand == 'update':
    baseRole, groupMemberType = _getRoleGroupMemberType()
    isSuspended, isArchived = _getOptionalIsSuspendedIsArchived()
    expireTime = _getExpireTime(baseRole)
    preview, csvPF = _getPreviewActionCSV()
    _, updateMembers = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS,
                                         isSuspended=isSuspended, isArchived=isArchived, groupMemberType=groupMemberType)
    groupMemberLists = updateMembers if isinstance(updateMembers, dict) else None
    subkeyRoleField = GM.Globals[GM.CSV_SUBKEY_FIELD]
    checkForExtraneousArguments()
    i = 0
    count = len(entityList)
    for group in entityList:
      i += 1
      roleList = [baseRole]
      if groupMemberLists:
        if not subkeyRoleField:
          updateMembers = groupMemberLists[group]
        else:
          roleList = groupMemberLists[group]
      origGroup = group
      _, parent, group = checkGroupExists(cd, ci, True, group, i, count)
      if not group or checkDynamicGroup(ci, parent, i, count):
        continue
      for role in roleList:
        if groupMemberLists and subkeyRoleField:
          role, updateMembers = _validateSubkeyRoleGetMembers(group, role, origGroup, groupMemberLists, i, count)
          if role is None:
            continue
        Act.Set([Act.UPDATE, Act.UPDATE_PREVIEW][preview])
        jcount = len(updateMembers)
        entityPerformActionNumItems([entityType, group], jcount, Ent.MEMBER, i, count)
        if jcount == 0:
          continue
        if preview:
          _previewAction(group, updateMembers, role, jcount, Act.UPDATE)
          continue
        Ind.Increment()
        j = 0
        for member in updateMembers:
          j += 1
          memberEmail = convertUIDtoEmailAddress(member, cd=cd, emailTypes='any',
                                                 checkForCustomerId=True, ciGroupsAPI=True)
          try:
            memberName = callGAPI(ci.groups().memberships(), 'lookup',
                                  throwReasons=GAPI.CIGROUP_GET_THROW_REASONS,
                                  parent=parent, memberKey_id=memberEmail, fields='name').get('name')
          except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden,
                  GAPI.notFound, GAPI.memberNotFound, GAPI.permissionDenied, GAPI.invalidMember) as e:
            _showFailure(group, memberEmail, role, str(e), j, jcount)
            continue
          preUpdateRoles = []
          addRoles = []
          removeRoles = []
          postUpdateRoles = []
          memberRoles = callGAPI(ci.groups().memberships(), 'get',
                                 name=memberName, fields='name,preferredMemberKey,roles,type')
          getCIGroupMemberRoleFixType(memberRoles)
          current_roles = [crole['name'] for crole in memberRoles['roles']]
          # When upgrading role, strip any expiryDetail from member before role changes
          if role != Ent.ROLE_MEMBER:
            if 'expireTime' in memberRoles:
              preUpdateRoles.append({'fieldMask': 'expiryDetail.expireTime',
                                     'membershipRole': {'name': Ent.ROLE_MEMBER, 'expiryDetail': {'expireTime': None}}})
          # When downgrading role or simply updating member expireTime, update expiryDetail after role changes
          elif expireTime:
            postUpdateRoles.append({'fieldMask': 'expiryDetail.expireTime',
                                    'membershipRole': {'name': role, 'expiryDetail': {'expireTime': expireTime if expireTime != NEVER_TIME else None}}})
          for crole in current_roles:
            if crole not in {Ent.ROLE_MEMBER, role}:
              removeRoles.append(crole)
          if role not in current_roles:
            new_role = {'name': role}
            if role == Ent.ROLE_MEMBER and expireTime not in {None, NEVER_TIME}:
              new_role['expiryDetail'] = {'expireTime': expireTime}
              postUpdateRoles = []
            addRoles.append(new_role)
          bodys = []
          if preUpdateRoles:
            bodys.append({'updateRolesParams': preUpdateRoles})
          if addRoles:
            bodys.append({'addRoles': addRoles})
          if removeRoles:
            bodys.append({'removeRoles': removeRoles})
          if postUpdateRoles:
            bodys.append({'updateRolesParams': postUpdateRoles})
          errors = False
          for body in bodys:
            try:
              callGAPI(ci.groups().memberships(), 'modifyMembershipRoles',
                       throwReasons=[GAPI.MEMBER_NOT_FOUND, GAPI.INVALID_MEMBER, GAPI.FAILED_PRECONDITION,
                                     GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                       name=memberName, body=body)
            except (GAPI.memberNotFound, GAPI.invalidMember, GAPI.failedPrecondition,
                    GAPI.invalidArgument, GAPI.permissionDenied) as e:
              _showFailure(group, memberEmail, role, str(e), j, jcount)
              errors = True
              break
          if not errors:
            _showSuccess(group, memberEmail, role, None, j, jcount)
        Ind.Decrement()
  else: #clear
    rolesSet = set()
    groupMemberType = 'ALL'
    isSuspended = None
    emailMatchPattern = None
    clearMatch = True
    qualifier = ''
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg in GROUP_ROLES_MAP:
        rolesSet.add(GROUP_ROLES_MAP[myarg])
      elif myarg == 'usersonly':
        groupMemberType = Ent.TYPE_USER
      elif myarg == 'groupsonly':
        groupMemberType = Ent.TYPE_GROUP
      elif myarg in {'emailclearpattern', 'emailretainpattern'}:
        emailMatchPattern = getREPattern(re.IGNORECASE)
        clearMatch = myarg == 'emailclearpattern'
      elif myarg == 'preview':
        preview = True
      elif myarg == 'actioncsv':
        csvPF = CSVPrintFile(GROUP_PREVIEW_TITLES)
      else:
        unknownArgumentExit()
    Act.Set(Act.REMOVE)
    if not rolesSet:
      rolesSet.add(Ent.ROLE_MEMBER)
    memberRoles = ','.join(sorted(rolesSet))
    i = 0
    count = len(entityList)
    for group in entityList:
      i += 1
      _, parent, group = checkGroupExists(cd, ci, True, group, i, count)
      if not group or checkDynamicGroup(ci, parent, i, count):
        continue
      printGettingAllEntityItemsForWhom(memberRoles, group, qualifier=qualifier, entityType=entityType)
      try:
        result = callGAPIpages(ci.groups().memberships(), 'list', 'memberships',
                               pageMessage=getPageMessageForWhom(),
                               throwReasons=GAPI.MEMBERS_THROW_REASONS, retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                               parent=parent, view='FULL',
                               fields='nextPageToken,memberships(name,preferredMemberKey(id),roles(name),type)',
                               pageSize=GC.Values[GC.MEMBER_MAX_RESULTS])
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden):
        entityUnknownWarning(entityType, group, i, count)
        continue
      except (GAPI.conditionNotMet, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([entityType, group], str(e), i, count)
        continue
      removeMembers = {}
      for role in rolesSet:
        removeMembers[role] = []
      for member in result:
        getCIGroupMemberRoleFixType(member)
        memberName = member['name']
        memberEmail = _getMemberEmail(member)
        role = member['role']
        if groupMemberType in ('ALL', member['type']) and role in rolesSet:
          if emailMatchPattern is None:
            removeMembers[role].append({'name': memberName, 'email': memberEmail})
          elif member['type'] == Ent.TYPE_CUSTOMER:
            pass
          elif emailMatchPattern.match(email):
            if clearMatch:
              removeMembers[role].append({'name': memberName, 'email': memberEmail})
          else:
            if not clearMatch:
              removeMembers[role].append({'name': memberName, 'email': memberEmail})
      del result
      for role in rolesSet:
        _batchRemoveGroupMembers(group, i, count, removeMembers[role], role)
  if csvPF:
    csvPF.writeCSVfile('Cloud Identity Group Updates')

# gam delete cigroups <GroupEntity>
def doDeleteCIGroups():
  doDeleteGroups(ciGroupsAPI=True)

CIGROUP_MEMBER_TYPES_MAP = {
  'cbcmbrowser': Ent.TYPE_CBCM_BROWSER,
  'chromeosdevice': Ent.TYPE_OTHER,
  'customer': Ent.TYPE_CUSTOMER,
  'group': Ent.TYPE_GROUP,
  'other': Ent.TYPE_OTHER,
  'serviceaccount': Ent.TYPE_SERVICE_ACCOUNT,
  'user': Ent.TYPE_USER,
  }
ALL_CIGROUP_MEMBER_TYPES = {
  Ent.TYPE_CBCM_BROWSER, Ent.TYPE_CUSTOMER, Ent.TYPE_GROUP,
  Ent.TYPE_OTHER, Ent.TYPE_SERVICE_ACCOUNT, Ent.TYPE_USER}

def getCIGroupMemberTypes(myarg, typesSet):
  if myarg in {'type', 'types'}:
    for gtype in getString(Cmd.OB_GROUP_TYPE_LIST).lower().replace('_', '').replace(',', ' ').split():
      if gtype in CIGROUP_MEMBER_TYPES_MAP:
        typesSet.add(CIGROUP_MEMBER_TYPES_MAP[gtype])
      else:
        invalidChoiceExit(gtype, CIGROUP_MEMBER_TYPES_MAP, True)
  else:
    return False
  return True

# gam info cigroups <GroupEntity>
#	[nousers|membertree] [quick] [noaliases] [nojoindate] [showupdatedate]
#	[nosecurity|nosecuritysettings]
#	[allfields|<CIGroupFieldName>*|(fields <CIGroupFieldNameList>)]
#	[roles <GroupRoleList>] [members] [managers] [owners]
#	[internal] [internaldomains <DomainList>] [external]
#	[types <CIGroupMemberTypeList>]
#	[memberemaildisplaypattern|memberemailskippattern <REMatchPattern>]
#	[formatjson]
def doInfoCIGroups():
  def printCIGroupMemberTree(group_id, showRole):
    if not group_id in cachedGroupMembers:
      try:
        cachedGroupMembers[group_id] = callGAPIpages(ci.groups().memberships(), 'list', 'memberships',
                                                     throwReasons=GAPI.MEMBERS_THROW_REASONS,
                                                     retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                                                     parent=group_id, view='FULL',
                                                     fields='nextPageToken,memberships(*)',
                                                     pageSize=GC.Values[GC.MEMBER_MAX_RESULTS])
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden):
        entityUnknownWarning(Ent.CLOUD_IDENTITY_GROUP, group_id, i, count)
        return
      except (GAPI.conditionNotMet, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, group_id], str(e), i, count)
        return
    for member in cachedGroupMembers[group_id]:
      member_id = member.get('name', '')
      member_id = member_id.split('/')[-1]
      member_email = member.get('preferredMemberKey', {}).get('id')
      member_type = member.get('type', 'USER').lower()
      if showRole:
        getCIGroupMemberRoleFixType(member)
        printKeyValueList([member['role'].lower(), f'{member_email} ({member_type})'])
      else:
        writeStdout(f'{Ind.Spaces()}{member_email} ({member_type})\n')
      if member_type == 'group':
        Ind.Increment()
        printCIGroupMemberTree(f'groups/{member_id}', False)
        Ind.Decrement()

  def initGroupFieldsLists():
    if not groupFieldsLists['ci']:
      groupFieldsLists['ci'] = ['groupKey']

  ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
  entityList = getEntityList(Cmd.OB_GROUP_ENTITY)
  getAliases = getSecuritySettings = getUsers = True
  showJoinDate = True
  showMemberTree = showUpdateDate = False
  FJQC = FormatJSONQuoteChar()
  members = []
  groupFieldsLists = {'ci': None}
  entityType = Ent.MEMBER
  rolesSet = set()
  typesSet = set()
  memberOptions = initMemberOptions()
  memberDisplayOptions = initIPSGMGroupMemberDisplayOptions()
  cachedGroupMembers = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'quick':
      getAliases = getUsers = False
    elif myarg == 'nousers':
      getUsers = False
    elif myarg == 'membertree':
      showMemberTree = True
    elif getIPSGMGroupRolesMemberDisplayOptions(myarg, rolesSet, memberDisplayOptions):
      getUsers = True
    elif getCIGroupMemberTypes(myarg, typesSet):
      pass
    elif getMemberMatchOptions(myarg, memberOptions):
      pass
    elif myarg == 'noaliases':
      getAliases = False
    elif myarg in {'allfields', 'ciallfields', 'allcifields'}:
      if not groupFieldsLists['ci']:
        groupFieldsLists['ci'] = []
      for field in CIGROUP_FIELDS_CHOICE_MAP:
        addFieldToFieldsList(field, CIGROUP_FIELDS_CHOICE_MAP, groupFieldsLists['ci'])
    elif myarg in CIGROUP_FIELDS_CHOICE_MAP:
      initGroupFieldsLists()
      addFieldToFieldsList(myarg, CIGROUP_FIELDS_CHOICE_MAP, groupFieldsLists['ci'])
    elif myarg in {'fields', 'cifields'}:
      initGroupFieldsLists()
      for field in _getFieldsList():
        if field in CIGROUP_FIELDS_CHOICE_MAP:
          addFieldToFieldsList(field, CIGROUP_FIELDS_CHOICE_MAP, groupFieldsLists['ci'])
        else:
          invalidChoiceExit(field, CIGROUP_FIELDS_CHOICE_MAP, True)
    elif myarg == 'nojoindate':
      showJoinDate = False
    elif myarg == 'showupdatedate':
      showUpdateDate = True
    elif myarg in ['nosecurity', 'nosecuritysettings']:
      getSecuritySettings = False
    else:
      FJQC.GetFormatJSON(myarg)
  if not typesSet:
    typesSet = ALL_CIGROUP_MEMBER_TYPES
  fields = getFieldsFromFieldsList(groupFieldsLists['ci'])
  if not showJoinDate and not showUpdateDate:
    view = 'BASIC'
    pageSize = 1000
  else:
    view = 'FULL'
    pageSize = 500
  checkCategory = memberDisplayOptions['showCategory']
  i = 0
  count = len(entityList)
  for group in entityList:
    i += 1
    _, name, group = convertGroupEmailToCloudID(ci, group, i, count)
    if not name:
      continue
    try:
      cigInfo = callGAPI(ci.groups(), 'get',
                         throwReasons=GAPI.CIGROUP_GET_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                         name=name, fields=fields)
      group = cigInfo['groupKey']['id']
      if not getAliases:
        cigInfo.pop('additionalGroupKeys', None)
      if getUsers and not showMemberTree:
        result = callGAPIpages(ci.groups().memberships(), 'list', 'memberships',
                               throwReasons=GAPI.MEMBERS_THROW_REASONS, retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                               parent=name, view=view, fields='*', pageSize=pageSize)
        members = []
        for member in result:
          getCIGroupMemberRoleFixType(member)
          if (member['type'] in typesSet and
              _checkMemberRole(member, rolesSet) and
              checkCIMemberMatch(member, memberOptions) and
              (not checkCategory or _checkCIMemberCategory(member, memberDisplayOptions))):
            members.append(member)
      if getSecuritySettings:
        cigInfo['SecuritySettings'] = callGAPI(ci.groups(), 'getSecuritySettings',
                                               throwReasons=GAPI.CIGROUP_GET_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                                               name=f'{name}/securitySettings', readMask='*')
      if FJQC.formatJSON:
        if getUsers and not showMemberTree:
          cigInfo['members'] = members
        printLine(json.dumps(cleanJSON(cigInfo, timeObjects=CIGROUP_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
        continue
      _showCIGroup(cigInfo, group, i, count)
      if getUsers and not showMemberTree:
        Ind.Increment()
        printEntitiesCount(entityType, members)
        Ind.Increment()
        for member in members:
          memberEmail = member.get('preferredMemberKey', {}).get('id', member['name'])
          getCIGroupMemberRoleFixType(member)
          memberDetails = [member['role'].lower(), f'{memberEmail} ({member["type"].lower()})']
          if checkCategory:
            memberDetails[1] += f' ({member["category"]})'
          if showJoinDate:
            memberDetails.extend(['joined', formatLocalTime(member['createTime']) if 'createTime' in member else UNKNOWN])
          if showUpdateDate:
            memberDetails.extend(['updated', formatLocalTime(member['updateTime']) if 'updateTime' in member else UNKNOWN])
          if 'expireTime' in member:
            memberDetails.extend(['expires', formatLocalTime(member['expireTime'])])
          printKeyValueList(memberDetails)
        Ind.Decrement()
        printKeyValueList([Msg.TOTAL_ITEMS_IN_ENTITY.format(Ent.Plural(entityType), Ent.Singular(Ent.CLOUD_IDENTITY_GROUP)), len(members)])
        Ind.Decrement()
      elif showMemberTree:
        Ind.Increment()
        printEntity([Ent.MEMBERSHIP_TREE, ''])
        Ind.Increment()
        printCIGroupMemberTree(name, True)
        Ind.Decrement()
        Ind.Decrement()
    except GAPI.notFound:
      entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, group], Msg.DOES_NOT_EXIST, i, count)
    except (GAPI.groupNotFound, GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.backendError,
            GAPI.invalid, GAPI.invalidMember, GAPI.invalidParameter, GAPI.invalidInput, GAPI.forbidden, GAPI.badRequest,
            GAPI.permissionDenied, GAPI.systemError, GAPI.serviceLimit, GAPI.serviceNotAvailable) as e:
      entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, group], str(e), i, count)

def checkCIGroupShowOwnedBy(showOwnedBy, members):
  for member in members:
    if member['preferredMemberKey']['id'] == showOwnedBy:
      if member['role'] == Ent.ROLE_OWNER:
        return True
  return False

def updateFieldsForCIGroupMatchPatterns(matchPatterns, fieldsList, csvPF=None):
  for field in ['displayName', 'description']:
    if field in matchPatterns:
      if csvPF is not None:
        csvPF.AddField(field, CIGROUP_FIELDS_CHOICE_MAP, fieldsList)
      else:
        fieldsList.append(field)

CIPOLICY_TIME_OBJECTS = {'createTime', 'updateTime'}

def _filterPolicies(ci, pageMessage, ifilter):
  try:
    policies = callGAPIpages(ci.policies(), 'list', 'policies',
                             pageMessage=pageMessage,
                             throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                             filter=ifilter,
                             fields='nextPageToken,policies(name,policyQuery(group,orgUnit,sortOrder),type,setting)',
                             pageSize=100)
    # Google returns unordered results, sort them by setting type
    return sorted(policies, key=lambda p: p.get('setting', {}).get('type', ''))
  except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied) as e:
    entityActionFailedWarning([Ent.POLICY, ifilter], str(e))
    return []

# Policies where GAM should offer additional guidance and information
CIPOLICY_ADDITIONAL_WARNINGS = {
  'settings/drive_and_docs.external_sharing': {
    'warningType': 'SUPERSEDED_POLICY',
    'warningMessage': 'CAUTION: Drive Sharing settings are superseded by Drive Trust Rules if Trust Rules has been enabled for your domain. Drive Trust Rule settings are not available in the Policy API today so GAM is not able to check if Trust Rules is enabled and if the settings/drive_and_docs.external_sharing policies are actually in effect for your domain. If Drive Trust Rules is enabled for your domain then this settings/drive_and_docs.external_sharing policy does not accurately reflect your current Drive sharing settings.'
  }
}

def _getPolicyAppNameFromId(httpObj, app):
  app['applicationName'] = UNKNOWN
  appId = app['applicationId']
  url = f'https://workspace.google.com/marketplace/app/_/{appId}'
  try:
    resp, content = httpObj.request(url, 'GET')
  except:
    return
  if resp.status != 200:
    return
  if isinstance(content, bytes):
    content = content.decode()
  pattern = f'https://workspace.google.com/marketplace/app/(.+?)/{appId}'
  a = re.search(pattern, content)
  if a:
    app['applicationName'] = a.group(1)

def _cleanPolicy(policy, add_warnings, no_appnames,
                 groupEmailPattern, orgUnitPathPattern,
                 cd, groups_ci):
  # convert any wordlists into spaced strings to reduce output complexity
  if policy['setting']['type'] == 'settings/detector.word_list':
    policy['setting']['value']['wordList'] = ' '.join(policy['setting']['value']['wordList']['words'])
  # get application name for application id
  if policy['setting']['type'] == 'settings/workspace_marketplace.apps_allowlist' and not no_appnames:
    httpObj = getHttpObj(timeout=10)
    for app in policy['setting']['value'].get('apps', []):
      _getPolicyAppNameFromId(httpObj, app)
  # add any warnings to applicable policies
  if add_warnings and policy['setting']['type'] in CIPOLICY_ADDITIONAL_WARNINGS:
    policy['warning'] = CIPOLICY_ADDITIONAL_WARNINGS[policy['setting']['type']]
  if groupId := policy['policyQuery'].get('group'):
    _, _, policy['policyQuery']['groupEmail'] = convertGroupCloudIDToEmail(groups_ci, groupId)
    # all groups are in the root OU so the orgUnit attribute is useless
    policy['policyQuery'].pop('orgUnit', None)
    if groupEmailPattern is not None:
      return  groupEmailPattern.match(policy['policyQuery']['groupEmail'])
    if orgUnitPathPattern is not None:
      return False
  elif orgId := policy['policyQuery'].get('orgUnit'):
    policy['policyQuery']['orgUnitPath'] = convertOrgUnitIDtoPath(cd, orgId)
    if orgUnitPathPattern is not None:
      return orgUnitPathPattern.match(policy['policyQuery']['orgUnitPath'])
    if groupEmailPattern is not None:
      return False
  return True

def _showPolicy(policy, FJQC, i=0, count=0):
  if FJQC is not None and FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(policy, timeObjects=CIPOLICY_TIME_OBJECTS),
                         ensure_ascii=False,
                         sort_keys=True))
    return
  printEntity([Ent.POLICY, policy['name']], i, count)
  Ind.Increment()
  policy.pop('name')
  showJSON(None, policy, timeObjects=CIPOLICY_TIME_OBJECTS)
  printBlankLine()
  Ind.Decrement()

def _showPolicies(policies, FJQC, add_warnings, no_appnames,
                  groupEmailPattern, orgUnitPathPattern,
                  cd, groups_ci):
  count = len(policies)
  if groupEmailPattern is None and orgUnitPathPattern is None:
    performActionNumItems(count, Ent.POLICY)
  else:
    performActionModifierNumItems(Msg.MAXIMUM_OF, count, Ent.POLICY)
  Ind.Increment()
  i = 0
  for policy in policies:
    i += 1
    if _cleanPolicy(policy, add_warnings, no_appnames,
                    groupEmailPattern, orgUnitPathPattern,
                    cd, groups_ci):
      _showPolicy(policy, FJQC, i, count)
  Ind.Decrement()

def _checkPoliciesWithDASA():
  if GC.Values[GC.ENABLE_DASA]:
    systemErrorExit(USAGE_ERROR_RC,
                    Msg.COMMAND_NOT_COMPATIBLE_WITH_ENABLE_DASA.format(Act.ToPerform().lower(), Cmd.ARG_CIPOLICIES))

# gam info policies <CIPolicyNameEntity>
#	[nowarnings] [noappnames]
#	[formatjson]
def doInfoCIPolicies():
  _checkPoliciesWithDASA()
  groups_ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
  ci = buildGAPIObject(API.CLOUDIDENTITY_POLICY)
  cd = buildGAPIObject(API.DIRECTORY)
  entityList = getEntityList(Cmd.OB_CIPOLICY_NAME_ENTITY)
  FJQC = FormatJSONQuoteChar()
  add_warnings = True
  no_appnames = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'nowarnings':
      add_warnings = False
    elif myarg == 'noappnames':
      no_appnames=True
    else:
      FJQC.GetFormatJSON(myarg)
  i = 0
  count = len(entityList)
  for pname in entityList:
    i += 1
    if pname.startswith('policies/'):
      try:
        policies  = [callGAPI(ci.policies(), 'get',
                              bailOnInternalError=True,
                              throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED, GAPI.INTERNAL_ERROR],
                              name=pname,
                              fields='name,policyQuery(group,orgUnit,sortOrder),type,setting')]
      except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied, GAPI.internalError) as e:
        entityActionFailedWarning([Ent.POLICY, pname], str(e), i, count)
        continue
    else:
      if pname.startswith('settings/'):
        pname = pname.split('/')[1]
      ifilter = f"setting.type.matches('{pname}')"
      printGettingAllAccountEntities(Ent.POLICY, ifilter)
      policies = _filterPolicies(ci, getPageMessage(), ifilter)
    _showPolicies(policies, FJQC, add_warnings, no_appnames,
                  None, None, cd, groups_ci)

# gam print policies [todrive <ToDriveAttribute>*]
#	[filter <String>]  [nowarnings] [noappnames]
#	[group <REMatchPattern>] [ou|org|orgunit <REMatchPattern>]
#	[formatjson [quotechar <Character>]]
# gam show policies
#	[filter <String>]  [nowarnings] [noappnames]
#	[group <REMatchPattern>] [ou|org|orgunit <REMatchPattern>]
#	[formatjson]
def doPrintShowCIPolicies():

  def _printPolicy(policy):
    row = flattenJSON(policy, timeObjects=CIPOLICY_TIME_OBJECTS)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'name': policy['name'],
                              'JSON': json.dumps(cleanJSON(policy, timeObjects=CIPOLICY_TIME_OBJECTS),
                                                 ensure_ascii=False,
                                                 sort_keys=True)})

  _checkPoliciesWithDASA()
  groups_ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
  ci = buildGAPIObject(API.CLOUDIDENTITY_POLICY)
  cd = buildGAPIObject(API.DIRECTORY)
  csvPF = CSVPrintFile(['name']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  ifilter = None
  add_warnings = True
  no_appnames = False
  groupEmailPattern = orgUnitPathPattern = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'filter':
      ifilter = getString(Cmd.OB_STRING)
    elif myarg == 'nowarnings':
      add_warnings = False
    elif myarg == 'noappnames':
      no_appnames = True
    elif myarg == 'group':
      groupEmailPattern = getREPattern(re.IGNORECASE)
    elif myarg in {'ou', 'org', 'orgunit'}:
      orgUnitPathPattern = getREPattern(re.IGNORECASE)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  printGettingAllAccountEntities(Ent.POLICY, ifilter)
  policies = _filterPolicies(ci, getPageMessage(), ifilter)
  if not csvPF:
    _showPolicies(policies, FJQC, add_warnings, no_appnames,
                  groupEmailPattern, orgUnitPathPattern,
                  cd, groups_ci)
  else:
    for policy in policies:
      if _cleanPolicy(policy, add_warnings, no_appnames,
                      groupEmailPattern, orgUnitPathPattern,
                      cd, groups_ci):
        _printPolicy(policy)
  if csvPF:
    csvPF.writeCSVfile('Policies')

PRINT_CIGROUPS_JSON_TITLES = ['email', 'JSON']

# gam print cigroups [todrive <ToDriveAttribute>*]
#	[(cimember|ciowner <UserItem>)|(select <GroupEntity>)|(query <String>)]
#	[showownedby <UserItem>]
#	[emailmatchpattern [not] <REMatchPattern>] [namematchpattern [not] <REMatchPattern>]
#	[descriptionmatchpattern [not] <REMatchPattern>]
#	[basic|allfields|(<CIGroupFieldName>* [fields <CIGroupFieldNameList>])]
#	[roles <GroupRoleList>] [memberrestrictions]
#	[members|memberscount] [managers|managerscount] [owners|ownerscount] [totalcount] [countsonly]
#	[internal] [internaldomains <DomainList>] [external]
#	[types <CIGroupMemberTypeList>]
#	[memberemaildisplaypattern|memberemailskippattern <REMatchPattern>]
#	[convertcrnl] [delimiter <Character>]
#	[formatjson [quotechar <Character>]]
# 	[showitemcountonly]
def doPrintCIGroups():
  def _printGroupRow(groupEntity, groupMembers):
    nonlocal itemCount
    for member in groupMembers:
      getCIGroupMemberRoleFixType(member)
    if showOwnedBy and not checkCIGroupShowOwnedBy(showOwnedBy, groupMembers):
      return
    if showItemCountOnly:
      itemCount += 1
      return
    if not keepName:
      groupEntity.pop('name', None)
    row = {}
    if FJQC.formatJSON:
      row['email'] = groupEntity['groupKey']['id'].lower()
      row['JSON'] = json.dumps(cleanJSON(groupEntity, timeObjects=CIGROUP_TIME_OBJECTS), ensure_ascii=False, sort_keys=True)
      if rolesSet and groupMembers is not None:
        row['JSON-members'] = json.dumps(groupMembers, ensure_ascii=False, sort_keys=True)
      csvPF.WriteRowNoFilter(row)
      return
    mapCIGroupFieldNames(groupEntity)
    for k, v in iter(groupEntity.pop('labels', {}).items()):
      if v == '':
        groupEntity[f'labels{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{k}'] = True
      else:
        groupEntity[f'labels{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{k}'] = v
    for key, value in sorted(iter(flattenJSON(groupEntity, flattened={}, timeObjects=CIGROUP_TIME_OBJECTS).items())):
      csvPF.AddTitles(key)
      row[key] = value
    if rolesSet and groupMembers is not None:
      addMemberInfoToRow(row, groupMembers, typesSet, memberOptions, memberDisplayOptions, delimiter,
                         False, False, True)
    csvPF.WriteRow(row)

  ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
  setTrueCustomerId()
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  memberRestrictions = sortHeaders = False
  memberDisplayOptions = initPGGroupMemberDisplayOptions()
  pageSize = 500
  parent = f'customers/{GC.Values[GC.CUSTOMER_ID]}'
  groupFieldsLists = {'ci': ['groupKey']}
  csvPF = CSVPrintFile(['email'])
  FJQC = FormatJSONQuoteChar(csvPF)
  rolesSet = set()
  typesSet = set()
  memberOptions = initMemberOptions()
  entitySelection = groupMembers = memberQuery = query = showOwnedBy = None
  matchPatterns = {}
  showItemCountOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'showownedby':
      showOwnedBy = convertUIDtoEmailAddress(getEmailAddress(), emailTypes=['user'])
    elif myarg in {'cimember', 'enterprisemember', 'ciowner'}:
      emailAddress = convertUIDtoEmailAddress(getEmailAddress(), emailTypes=['user', 'group'])
      memberQuery = f"member_key_id == '{emailAddress}' && CIGROUP_DISCUSSION_FORUM_LABEL in labels"
      entitySelection = None
      if myarg == 'ciowner':
        showOwnedBy = emailAddress
    elif myarg == 'query':
      query = getString(Cmd.OB_QUERY)
      entitySelection = None
    elif getGroupMatchPatterns(myarg, matchPatterns, True):
      pass
    elif myarg == 'select':
      entitySelection = getEntityList(Cmd.OB_GROUP_ENTITY)
      query = None
    elif myarg == 'maxresults':
      pageSize = getInteger(minVal=1, maxVal=500)
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    elif myarg in {'allfields', 'ciallfields', 'allcifields'}:
      sortHeaders = True
      groupFieldsLists = {'ci': []}
      for field in CIGROUP_FIELDS_CHOICE_MAP:
        addFieldToFieldsList(field, CIGROUP_FIELDS_CHOICE_MAP, groupFieldsLists['ci'])
    elif myarg == 'basic':
      sortHeaders = True
      groupFieldsLists = {'ci': ['*']}
    elif myarg == 'sortheaders':
      sortHeaders = getBoolean()
    elif myarg in CIGROUP_FIELDS_CHOICE_MAP:
      csvPF.AddField(myarg, CIGROUP_FIELDS_CHOICE_MAP, groupFieldsLists['ci'])
    elif myarg in {'fields', 'cifields'}:
      for field in _getFieldsList():
        if field in CIGROUP_FIELDS_CHOICE_MAP:
          csvPF.AddField(field, CIGROUP_FIELDS_CHOICE_MAP, groupFieldsLists['ci'])
        else:
          invalidChoiceExit(field, list(CIGROUP_FIELDS_CHOICE_MAP), True)
    elif getPGGroupRolesMemberDisplayOptions(myarg, rolesSet, memberDisplayOptions):
      pass
    elif getCIGroupMemberTypes(myarg, typesSet):
      pass
    elif getMemberMatchOptions(myarg, memberOptions):
      pass
    elif myarg == 'memberrestrictions':
      memberRestrictions = True
    elif myarg == 'showitemcountonly':
      showItemCountOnly = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  if not typesSet:
    typesSet = ALL_CIGROUP_MEMBER_TYPES
  csvPF.MapTitles('name', 'id')
  csvPF.MapTitles('displayName', 'name')
  csvPF.RemoveTitles('labels')
  updateFieldsForCIGroupMatchPatterns(matchPatterns, groupFieldsLists['ci'], csvPF)
  if groupFieldsLists['ci'] and groupFieldsLists['ci'][0] != '*':
    keepName = 'name' in groupFieldsLists['ci']
    groupFieldsLists['ci'].append('name')
    fields = ','.join(set(groupFieldsLists['ci']))
  else:
    keepName = True
    fields = '*'
  fieldsnp = f'nextPageToken,groups({fields})'
  getRolesSet = rolesSet.copy()
  if showOwnedBy:
    getRolesSet.add(Ent.ROLE_OWNER)
  getRoles = ','.join(sorted(getRolesSet))
  if rolesSet:
    setMemberDisplayTitles(memberDisplayOptions, csvPF)
  if FJQC.formatJSON:
    sortHeaders = False
    csvPF.SetJSONTitles(PRINT_CIGROUPS_JSON_TITLES)
    if rolesSet:
      csvPF.AddJSONTitle('JSON-members')
  if memberQuery:
    printGettingAllAccountEntities(Ent.CLOUD_IDENTITY_GROUP, memberQuery)
    try:
      result = callGAPIpages(ci.groups().memberships(), 'searchTransitiveGroups', 'memberships',
                             pageMessage=getPageMessage(showFirstLastItems=True), messageAttribute=['groupKey', 'id'],
                             throwReasons=GAPI.CIGROUP_LIST_USERKEY_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                             parent='groups/-', query=memberQuery,
                             fields='nextPageToken,memberships(group,groupKey(id),relationType)', pageSize=pageSize)
      entitySelection = [{'email': entity['groupKey']['id'], 'name': entity['group']} for entity in result if entity['relationType'] == 'DIRECT']
    except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
            GAPI.forbidden, GAPI.badRequest, GAPI.invalid,
            GAPI.systemError, GAPI.permissionDenied, GAPI.invalidArgument, GAPI.serviceNotAvailable) as e:
      entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, None], str(e))
      return
  getFullFieldsList = []
  if entitySelection is None:
    if groupFieldsLists['ci']:
      for field in groupFieldsLists['ci']:
        if field in CIGROUP_FULL_FIELDS:
          getFullFieldsList.append(field)
    else:
      getFullFieldsList = list(CIGROUP_FULL_FIELDS)
    getFullFields = ','.join(getFullFieldsList)#
    cipl = ci
    if query:
      method = 'search'
      if 'parent' not in query:
        query += f" && parent == '{parent}'"
      kwargs = {'query': query}
      if CIGROUP_LOCKED_LABEL in query:
        cipl = buildGAPIObject(API.CLOUDIDENTITY_GROUPS_BETA)
    else:
      method = 'list'
      kwargs = {'parent': parent}
    printGettingAllAccountEntities(Ent.CLOUD_IDENTITY_GROUP, query)
    try:
      entityList = callGAPIpages(cipl.groups(), method, 'groups',
                                 pageMessage=getPageMessage(showFirstLastItems=True), messageAttribute=['groupKey', 'id'],
                                 throwReasons=GAPI.CIGROUP_LIST_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                                 view='FULL', fields=fieldsnp, pageSize=pageSize, **kwargs)
    except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
            GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.invalidArgument,
            GAPI.systemError, GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
      entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, None], str(e))
      return
  else:
    getFullFields = ''
    entityList = []
    i = 0
    count = len(entitySelection)
    for group in entitySelection:
      i += 1
      if isinstance(group, dict):
        name = group['name']
        groupEmail = group['email']
      else:
        _, name, groupEmail = convertGroupEmailToCloudID(ci, group, i, count)
      printGettingEntityItemForWhom(Ent.CLOUD_IDENTITY_GROUP, groupEmail, i, count)
      if name:
        try:
          ciGroup = callGAPI(ci.groups(), 'get',
                             throwReasons=GAPI.CIGROUP_GET_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                             name=name, fields=fields)
          entityList.append(ciGroup)
        except (GAPI.notFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
                GAPI.forbidden, GAPI.badRequest, GAPI.invalid,
                GAPI.systemError, GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
          entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, groupEmail], str(e), i, count)
  itemCount = 0
  i = 0
  count = len(entityList)
  for groupEntity in entityList:
    i += 1
    groupEmail = groupEntity['groupKey']['id'].lower()
    if not checkGroupMatchPatterns(groupEmail, groupEntity, matchPatterns):
      continue
    if getFullFields:
      try:
        fullInfo = callGAPI(ci.groups(), 'get',
                            throwReasons=GAPI.CIGROUP_GET_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                            name=groupEntity['name'], fields=getFullFields)
        groupEntity.update(fullInfo)
      except (GAPI.notFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
              GAPI.forbidden, GAPI.badRequest, GAPI.invalid,
              GAPI.systemError, GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, groupEmail], str(e), i, count)
    groupMembers = {}
    if getRoles:
      printGettingEntityItemForWhom(getRoles, groupEmail, i, count)
      try:
        groupMembers = callGAPIpages(ci.groups().memberships(), 'list', 'memberships',
                                     throwReasons=GAPI.MEMBERS_THROW_REASONS, retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                                     pageMessage=getPageMessage(), messageAttribute=['preferredMemberKey', 'id'],
                                     parent=groupEntity['name'], view='FULL', fields='*', pageSize=pageSize)
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden):
        entityUnknownWarning(Ent.CLOUD_IDENTITY_GROUP, groupEmail, i, count)
      except (GAPI.conditionNotMet, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, groupEmail], str(e), i, count)
    if memberRestrictions:
      printGettingEntityItemForWhom(Ent.MEMBER_RESTRICTION, groupEmail, i, count)
      try:
        secInfo = callGAPI(ci.groups(), 'getSecuritySettings',
                           throwReasons=GAPI.CIGROUP_GET_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                           name=f"{groupEntity['name']}/securitySettings", readMask='*')
        if 'memberRestriction' in secInfo:
          groupEntity['memberRestrictionQuery'] = secInfo['memberRestriction'].get('query', '')
          groupEntity['memberRestrictionEvaluation'] = secInfo['memberRestriction'].get('evaluation', {}).get('state', '')
      except (GAPI.notFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
              GAPI.forbidden, GAPI.badRequest, GAPI.invalid,
              GAPI.systemError, GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, groupEmail], str(e), i, count)
    _printGroupRow(groupEntity, groupMembers)
  if showItemCountOnly:
    writeStdout(f'{itemCount}\n')
    return
  if sortHeaders:
    sortTitles = ['email']+CIGROUP_PRINT_ORDER
    if rolesSet:
      setMemberDisplaySortTitles(memberDisplayOptions, sortTitles)
    csvPF.SetSortTitles(sortTitles)
  csvPF.SortRows('email', False)
  csvPF.writeCSVfile('Cloud Identity Groups')

# gam <UserTypeEntity> info cimember <GroupEntity>
def infoCIGroupMembers(entityList):
  infoGroupMembers(entityList, True)

# gam info cimember <UserTypeEntity> <GroupEntity>
def doInfoCIGroupMembers():
  infoGroupMembers(getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)[1], True)

def getCIGroupMembersEntityList(ci, entityList, query, subTitle, matchPatterns, fieldsList, csvPF):
  if query:
    printGettingAllAccountEntities(Ent.CLOUD_IDENTITY_GROUP, query)
    parent = 'groups/-'
    try:
      result = callGAPIpages(ci.groups().memberships(), 'searchTransitiveGroups', 'memberships',
                             pageMessage=getPageMessage(showFirstLastItems=True), messageAttribute=['groupKey', 'id'],
                             throwReasons=GAPI.CIGROUP_LIST_USERKEY_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                             parent=parent, query=query,
                             fields='nextPageToken,memberships(groupKey(id),relationType)', pageSize=500)
      entityList = [entity['groupKey']['id'] for entity in result if entity['relationType'] == 'DIRECT']
    except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
            GAPI.forbidden, GAPI.badRequest, GAPI.invalid,
            GAPI.systemError, GAPI.permissionDenied, GAPI.invalidArgument, GAPI.serviceNotAvailable) as e:
      entityActionFailedExit([Ent.CLOUD_IDENTITY_GROUP, parent], str(e))
  elif entityList is None:
    updateFieldsForCIGroupMatchPatterns(matchPatterns, fieldsList, csvPF)
    printGettingAllAccountEntities(Ent.CLOUD_IDENTITY_GROUP, subTitle)
    parent = f'customers/{GC.Values[GC.CUSTOMER_ID]}'
    try:
      entityList = callGAPIpages(ci.groups(), 'list', 'groups',
                                 pageMessage=getPageMessage(showFirstLastItems=True), messageAttribute=['groupKey', 'id'],
                                 throwReasons=GAPI.CIGROUP_LIST_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                                 parent=parent, view='FULL',
                                 fields=f'nextPageToken,groups({",".join(set(fieldsList))})', pageSize=500)
    except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
            GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.invalidArgument,
            GAPI.systemError, GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
      entityActionFailedExit([Ent.CLOUD_IDENTITY_GROUP, parent], str(e))
  else:
    clearUnneededGroupMatchPatterns(matchPatterns)
  return entityList

def getCIGroupTransitiveMembers(ci, groupName, membersList, i, count):
  try:
    groupMembers = callGAPIpages(ci.groups().memberships(), 'searchTransitiveMemberships', 'memberships',
                                 throwReasons=GAPI.CIGROUP_LIST_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                                 parent=groupName,
                                 fields='nextPageToken,memberships(*)', pageSize=GC.Values[GC.MEMBER_MAX_RESULTS])
  except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
          GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.invalidArgument, GAPI.systemError, GAPI.serviceNotAvailable):
    entityUnknownWarning(Ent.CLOUD_IDENTITY_GROUP, groupName, i, count)
    return False
  except GAPI.permissionDenied as e:
    entityActionFailedExit([Ent.CLOUD_IDENTITY_GROUP, groupName], str(e))
    return False
  for member in groupMembers:
    membersList.append(getCIGroupTransitiveMemberRoleFixType(groupName, member))
  return True

def getCIGroupMembers(ci, groupName, memberRoles, membersList, membersSet, i, count,
                      memberOptions, memberDisplayOptions, level, typesSet, groupEmail, kwargs, cd):
  nameToPrint = groupEmail if groupEmail else groupName
  printGettingAllEntityItemsForWhom(memberRoles if memberRoles else Ent.ROLE_MANAGER_MEMBER_OWNER, nameToPrint, i, count)
  validRoles = _getCIRoleVerification(memberRoles)
  if memberOptions[MEMBEROPTION_INCLUDEDERIVEDMEMBERSHIP]:
    groupMembers = []
    if not getCIGroupTransitiveMembers(ci, groupName, groupMembers, i, count):
      return
    for member in groupMembers:
      if _checkMemberRole(member, validRoles):
        if member['type'] in typesSet and checkCIMemberMatch(member, memberOptions):
          membersList.append(member)
    return
  if not groupEmail.startswith('space/'):
    try:
      groupMembers = callGAPIpages(ci.groups().memberships(), 'list', 'memberships',
                                   pageMessage=getPageMessageForWhom(),
                                   throwReasons=GAPI.CIGROUP_LIST_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                                   parent=groupName, **kwargs)
    except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
            GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.invalidArgument, GAPI.systemError,
            GAPI.permissionDenied, GAPI.serviceNotAvailable):
      entityUnknownWarning(Ent.CLOUD_IDENTITY_GROUP, nameToPrint, i, count)
      return
  else:
    if cd is None:
      cd = buildGAPIObject(API.DIRECTORY)
    groupMembers = _getChatSpaceMembers(cd, groupEmail, groupName)
  checkCategory = memberDisplayOptions['showCategory']
  if not memberOptions[MEMBEROPTION_RECURSIVE]:
    if memberOptions[MEMBEROPTION_NODUPLICATES]:
      for member in groupMembers:
        getCIGroupMemberRoleFixType(member)
        memberName = member.get('preferredMemberKey', {}).get('id', '')
        if (_checkMemberRole(member, validRoles) and
            (not checkCategory or _checkCIMemberCategory(member, memberDisplayOptions)) and
            memberName not in membersSet):
          membersSet.add(memberName)
          if member['type'] in typesSet and checkCIMemberMatch(member, memberOptions):
            membersList.append(member)
    else:
      for member in groupMembers:
        getCIGroupMemberRoleFixType(member)
        if (_checkMemberRole(member, validRoles) and
            (not checkCategory or _checkCIMemberCategory(member, memberDisplayOptions))):
          if member['type'] in typesSet and checkCIMemberMatch(member, memberOptions):
            membersList.append(member)
  elif memberOptions[MEMBEROPTION_NODUPLICATES]:
    groupMemberList = []
    for member in groupMembers:
      getCIGroupMemberRoleFixType(member)
      memberName = member.get('preferredMemberKey', {}).get('id', '')
      if member['type'] != Ent.TYPE_GROUP:
        if (member['type'] in typesSet and
            checkCIMemberMatch(member, memberOptions) and
            _checkMemberRole(member, validRoles) and
            (not checkCategory or _checkCIMemberCategory(member, memberDisplayOptions)) and
            memberName not in membersSet):
          membersSet.add(memberName)
          member['level'] = level
          member['subgroup'] = nameToPrint
          membersList.append(member)
      else:
        if memberName not in membersSet:
          membersSet.add(memberName)
          if (member['type'] in typesSet and
              checkCIMemberMatch(member, memberOptions) and
              (not checkCategory or _checkCIMemberCategory(member, memberDisplayOptions))):
            member['level'] = level
            member['subgroup'] = nameToPrint
            membersList.append(member)
          _, gname = member['name'].rsplit('/', 1)
          groupMemberList.append((f'groups/{gname}', memberName))
    for member in groupMemberList:
      getCIGroupMembers(ci, member[0], memberRoles, membersList, membersSet, i, count,
                        memberOptions, memberDisplayOptions, level+1, typesSet, member[1], kwargs, cd)
  else:
    for member in groupMembers:
      getCIGroupMemberRoleFixType(member)
      memberName = member.get('preferredMemberKey', {}).get('id', '')
      if member['type'] != Ent.TYPE_GROUP:
        if (member['type'] in typesSet and
            checkCIMemberMatch(member, memberOptions) and
            _checkMemberRole(member, validRoles) and
            (not checkCategory or _checkCIMemberCategory(member, memberDisplayOptions))):
          member['level'] = level
          member['subgroup'] = nameToPrint
          membersList.append(member)
      else:
        if (member['type'] in typesSet and
            checkCIMemberMatch(member, memberOptions) and
            (not checkCategory or _checkCIMemberCategory(member, memberDisplayOptions))):
          member['level'] = level
          member['subgroup'] = nameToPrint
          membersList.append(member)
        _, gname = member['name'].rsplit('/', 1)
        getCIGroupMembers(ci, f'groups/{gname}', memberRoles, membersList, membersSet, i, count,
                          memberOptions, memberDisplayOptions, level+1, typesSet, memberName, kwargs, cd)

CIGROUPMEMBERS_FIELDS_CHOICE_MAP = {
  'createtime': 'createTime',
  'delivery': 'deliverySetting',
  'deliverysettings': 'deliverySetting',
  'email': 'preferredMemberKey',
  'expiretime': 'expireTime',
  'id': 'name',
  'memberkey': 'preferredMemberKey',
  'name': 'name',
  'preferredmemberkey': 'preferredMemberKey',
  'role': 'roles',
  'roles': 'roles',
  'type': 'type',
  'updatetime': 'updateTime',
  'useremail': 'preferredMemberKey',
  }
CIGROUPMEMBERS_DEFAULT_FIELDS = [
  'type', 'roles', 'name', 'preferredmemberkey', 'createtime', 'updatetime', 'expiretime']
CIGROUPMEMBERS_SORT_FIELDS = [
  'type', 'role', 'id', 'email',
  'name', 'preferredMemberKey.id', 'preferredMemberKey.namespace',
  'createTime', 'updateTime', 'expireTime'
  ]
CIGROUPMEMBERS_TIME_OBJECTS = {'createTime', 'updateTime', 'expireTime'}

def _getCIListGroupMembersArgs(listView):
  if listView == 'full':
    return  {'view': 'FULL', 'pageSize': GC.Values[GC.MEMBER_MAX_RESULTS_CI_FULL],
             'fields': 'nextPageToken,memberships(*)'}
  if listView == 'basic':
    return  {'view': 'FULL', 'pageSize': GC.Values[GC.MEMBER_MAX_RESULTS_CI_FULL],
             'fields': 'nextPageToken,memberships(name,preferredMemberKey,roles,type)'}
  return {'view': 'BASIC', 'pageSize': GC.Values[GC.MEMBER_MAX_RESULTS_CI_BASIC],
             'fields': 'nextPageToken,memberships(*)'}

# gam print cigroup-members [todrive <ToDriveAttribute>*]
#	[(cimember|ciowner <UserItem>)|(cigroup <GroupItem>)|(select <GroupEntity>)]
#	[showownedby <UserItem>]
#	[emailmatchpattern [not] <REMatchPattern>] [namematchpattern [not] <REMatchPattern>]
#	[descriptionmatchpattern [not] <REMatchPattern>]
#	[roles <GroupRoleList>] [members] [managers] [owners]
#	[types <CIGroupMemberTypeList>]
#	[memberemaildisplaypattern|memberemailskippattern <REMatchPattern>]
#	<CIGroupMembersFieldName>* [fields <CIGroupMembersFieldNameList>]
#	[minimal|basic|full]
#	[(recursive [noduplicates])|includederivedmembership] [nogroupeemail]
#	[formatjson [quotechar <Character>]]
def doPrintCIGroupMembers():
  ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
  setTrueCustomerId()
  memberOptions = initMemberOptions()
  memberDisplayOptions = initIPSGMGroupMemberDisplayOptions()
  groupColumn = True
  subTitle = f'{Msg.ALL} {Ent.Plural(Ent.CLOUD_IDENTITY_GROUP)}'
  fieldsList = []
  groupFieldsLists = {'ci': ['groupKey', 'name']}
  csvPF = CSVPrintFile(['group'])
  FJQC = FormatJSONQuoteChar(csvPF)
  entityList = query = showOwnedBy = None
  rolesSet = set()
  typesSet = set()
  matchPatterns = {}
  listView = 'full'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'showownedby':
      showOwnedBy = convertUIDtoEmailAddress(getEmailAddress(), emailTypes=['user'])
    elif myarg in {'cimember', 'enterprisemember', 'ciowner'}:
      emailAddress = convertUIDtoEmailAddress(getEmailAddress(), emailTypes=['user', 'group'])
      query = f"member_key_id == '{emailAddress}' && CIGROUP_DISCUSSION_FORUM_LABEL in labels"
      entityList = None
      if myarg == 'ciowner':
        showOwnedBy = emailAddress
    elif myarg in {'cigroup', 'group'}:
      entityList = [getString(Cmd.OB_EMAIL_ADDRESS)]
      subTitle = f'{Ent.Singular(Ent.CLOUD_IDENTITY_GROUP)}={entityList[0]}'
      query = None
    elif getGroupMatchPatterns(myarg, matchPatterns, True):
      pass
    elif myarg == 'select':
      entityList = getEntityList(Cmd.OB_GROUP_ENTITY)
      subTitle = f'{Msg.SELECTED} {Ent.Plural(Ent.CLOUD_IDENTITY_GROUP)}'
      query = None
    elif getIPSGMGroupRolesMemberDisplayOptions(myarg, rolesSet, memberDisplayOptions):
      pass
    elif getCIGroupMemberTypes(myarg, typesSet):
      pass
    elif getMemberMatchOptions(myarg, memberOptions):
      pass
    elif getFieldsList(myarg, CIGROUPMEMBERS_FIELDS_CHOICE_MAP, fieldsList, initialField='preferredMemberKey'):
      pass
    elif myarg == 'noduplicates':
      memberOptions[MEMBEROPTION_NODUPLICATES] = True
    elif myarg == 'recursive':
      memberOptions[MEMBEROPTION_RECURSIVE] = True
      memberOptions[MEMBEROPTION_INCLUDEDERIVEDMEMBERSHIP] = False
    elif myarg == 'includederivedmembership':
      memberOptions[MEMBEROPTION_INCLUDEDERIVEDMEMBERSHIP] = True
      memberOptions[MEMBEROPTION_RECURSIVE] = False
    elif myarg == 'nogroupemail':
      groupColumn = False
    elif myarg in {'minimal', 'basic', 'full'}:
      listView = myarg
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  if listView == 'minimal' and memberOptions[MEMBEROPTION_RECURSIVE]:
    usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format('minimal', 'recursive'))
  if not typesSet:
    typesSet = {Ent.TYPE_USER} if memberOptions[MEMBEROPTION_RECURSIVE] else ALL_CIGROUP_MEMBER_TYPES
  fields = ','.join(set(groupFieldsLists['ci']))
  entityList = getCIGroupMembersEntityList(ci, entityList, query, subTitle, matchPatterns, groupFieldsLists['ci'], csvPF)
  if not fieldsList:
    for field in CIGROUPMEMBERS_DEFAULT_FIELDS:
      addFieldToFieldsList(field, CIGROUPMEMBERS_FIELDS_CHOICE_MAP, fieldsList)
  if not groupColumn:
    csvPF.RemoveTitles(['group'])
  if memberDisplayOptions['showCategory']:
    csvPF.AddTitles('category')
  if FJQC.formatJSON:
    if groupColumn:
      csvPF.SetJSONTitles(['group', 'JSON'])
    else:
      csvPF.SetJSONTitles(['JSON'])
  displayFieldsList = fieldsList[:]
  if 'roles' in displayFieldsList:
    displayFieldsList.remove('roles')
    displayFieldsList.append('role')
  if not rolesSet:
    rolesSet = ALL_GROUP_ROLES
  getRolesSet = rolesSet.copy()
  if showOwnedBy:
    getRolesSet.add(Ent.ROLE_OWNER)
  getRoles = ','.join(sorted(getRolesSet))
  kwargs = _getCIListGroupMembersArgs(listView)
  level = 0
  i = 0
  count = len(entityList)
  for group in entityList:
    i += 1
    if isinstance(group, dict):
      groupEntity = group
    else:
      _, name, groupEmail = convertGroupEmailToCloudID(ci, group, i, count)
      if not name:
        continue
      try:
        groupEntity = callGAPI(ci.groups(), 'get',
                               throwReasons=GAPI.CIGROUP_GET_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                               name=name, fields=fields)
      except (GAPI.notFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
              GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.systemError,
              GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, groupEmail], str(e), i, count)
        continue
    groupEmail = groupEntity['groupKey']['id'].lower()
    if not checkGroupMatchPatterns(groupEmail, groupEntity, matchPatterns):
      continue
    membersList = []
    membersSet = set()
    getCIGroupMembers(ci, groupEntity['name'], getRoles, membersList, membersSet, i, count,
                      memberOptions, memberDisplayOptions, level, typesSet, groupEmail, kwargs, None)
    if showOwnedBy and not checkCIGroupShowOwnedBy(showOwnedBy, membersList):
      continue
    for member in membersList:
      if member['role'] not in rolesSet:
        continue
      row = {}
      if groupColumn:
        row['group'] = groupEmail
      dmember = {}
      for field in displayFieldsList:
        if field in member:
          dmember[field] = member[field]
      if memberOptions[MEMBEROPTION_RECURSIVE]:
        row['level'] = member['level']
        row['subgroup'] = member['subgroup']
      if memberDisplayOptions['showCategory']:
        row['category'] = member['category']
      if listView == 'minimal':
        dmember.pop('type', None)
      mapCIGroupMemberFieldNames(dmember)
      if not FJQC.formatJSON:
        csvPF.WriteRowTitles(flattenJSON(dmember, flattened=row, timeObjects=CIGROUPMEMBERS_TIME_OBJECTS))
      else:
        row.update(dmember)
        fjrow = {}
        if groupColumn:
          fjrow['group'] = groupEmail
        fjrow['JSON'] = json.dumps(cleanJSON(row, timeObjects=CIGROUPMEMBERS_TIME_OBJECTS),
                                   ensure_ascii=False, sort_keys=True)
        csvPF.WriteRowNoFilter(fjrow)
  if not FJQC.formatJSON:
    sortTitles = ['group'] if groupColumn else []
    sortTitles.extend(CIGROUPMEMBERS_SORT_FIELDS)
    if memberDisplayOptions['showCategory']:
      emailIndex = sortTitles.index('email')
      sortTitles.insert(emailIndex+1, 'category')
    csvPF.SetSortTitles(sortTitles)
    csvPF.SortTitles()
    csvPF.SetSortTitles([])
    if memberOptions[MEMBEROPTION_RECURSIVE]:
      csvPF.MoveTitlesToEnd(['level', 'subgroup'])
  csvPF.writeCSVfile(f'Cloud Identity Group Members ({subTitle})')

# gam show cigroup-members
#	[(cimember|ciowner <UserItem>)|(cigroup <GroupItem>)|(select <GroupEntity>)]
#	[showownedby <UserItem>]
#	[emailmatchpattern [not] <REMatchPattern>] [namematchpattern [not] <REMatchPattern>]
#	[descriptionmatchpattern [not] <REMatchPattern>]
#	[roles <GroupRoleList>] [members] [managers] [owners]
#	[internal] [internaldomains <DomainList>] [external]
#	[types <CIGroupMemberTypeList>]
#	[memberemaildisplaypattern|memberemailskippattern <REMatchPattern>]
#	[minimal|basic|full]
#	[(depth <Number>) | includederivedmembership]
def doShowCIGroupMembers():
  def _roleOrder(key):
    return {Ent.ROLE_OWNER: 0, Ent.ROLE_MANAGER: 1, Ent.ROLE_MEMBER: 2}.get(key, 3)

  def _typeOrder(key):
    return {Ent.TYPE_CUSTOMER: 0, Ent.TYPE_USER: 1, Ent.TYPE_GROUP: 2,
            Ent.TYPE_CBCM_BROWSER: 3, Ent.TYPE_OTHER: 4, Ent.TYPE_EXTERNAL: 5}.get(key, 6)

  def _showGroup(groupName, groupEmail, depth):
    if includeDerivedMembership:
      membersList = []
      if not getCIGroupTransitiveMembers(ci, groupName, membersList, i, count):
        return
    else:
      try:
        membersList = callGAPIpages(ci.groups().memberships(), 'list', 'memberships',
                                    throwReasons=GAPI.CIGROUP_LIST_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                                    parent=groupName, **kwargs)
        for member in membersList:
          getCIGroupMemberRoleFixType(member)
      except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
              GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.invalidArgument, GAPI.systemError,
              GAPI.permissionDenied, GAPI.serviceNotAvailable):
        if depth == 0:
          entityUnknownWarning(Ent.CLOUD_IDENTITY_GROUP, groupEmail, i, count)
        return
    if showOwnedBy and not checkCIGroupShowOwnedBy(showOwnedBy, membersList):
      return
    if depth == 0:
      printEntity([Ent.CLOUD_IDENTITY_GROUP, groupEmail], i, count)
    if depth == 0 or Ent.TYPE_GROUP in typesSet:
      Ind.Increment()
    for member in sorted(membersList, key=lambda k: (_roleOrder(k.get('role', Ent.ROLE_MEMBER)), _typeOrder(k['type']))):
      if (_checkMemberIsSuspendedIsArchived(member, memberOptions[MEMBEROPTION_ISSUSPENDED], memberOptions[MEMBEROPTION_ISARCHIVED]) and
          (not checkCategory or _checkCIMemberCategory(member, memberDisplayOptions))):
        if (_checkMemberRole(member, rolesSet) and
            member['type'] in typesSet and
            checkCIMemberMatch(member, memberOptions)):
          if listView != 'minimal':
            memberDetails = f'{member.get("role", Ent.ROLE_MEMBER)}, {member["type"]}, {member["preferredMemberKey"]["id"]}'
          else:
            memberDetails = f'{member.get("role", Ent.ROLE_MEMBER)}, {member["preferredMemberKey"]["id"]}'
          if checkCategory:
            memberDetails += f', {member["category"]}'
          for field in ['createTime', 'updateTime', 'expireTime']:
            if field in member:
              memberDetails += f', {formatLocalTime(member[field])}'
          printKeyValueList([memberDetails])
        if not includeDerivedMembership and (member['type'] == Ent.TYPE_GROUP) and (maxdepth == -1 or depth < maxdepth):
          _, gname = member['name'].rsplit('/', 1)
          _showGroup(f'groups/{gname}', member['preferredMemberKey']['id'], depth+1)
    if depth == 0 or Ent.TYPE_GROUP in typesSet:
      Ind.Decrement()

  ci = buildGAPIObject(API.CLOUDIDENTITY_GROUPS)
  setTrueCustomerId()
  subTitle = f'{Msg.ALL} {Ent.Plural(Ent.CLOUD_IDENTITY_GROUP)}'
  groupFieldsLists = {'ci': ['groupKey', 'name']}
  entityList = query = showOwnedBy = None
  rolesSet = set()
  typesSet = set()
  memberOptions = initMemberOptions()
  memberDisplayOptions = initIPSGMGroupMemberDisplayOptions()
  matchPatterns = {}
  maxdepth = -1
  includeDerivedMembership = False
  listView = 'full'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'showownedby':
      showOwnedBy = convertUIDtoEmailAddress(getEmailAddress(), emailTypes=['user'])
    elif myarg in {'cimember', 'enterprisemember', 'ciowner'}:
      emailAddress = convertUIDtoEmailAddress(getEmailAddress(), emailTypes=['user', 'group'])
      query = f"member_key_id == '{emailAddress}' && CIGROUP_DISCUSSION_FORUM_LABEL in labels"
      entityList = None
      if myarg == 'ciowner':
        showOwnedBy = emailAddress
    elif myarg in {'cigroup', 'group'}:
      entityList = [getString(Cmd.OB_EMAIL_ADDRESS)]
      subTitle = f'{Ent.Singular(Ent.CLOUD_IDENTITY_GROUP)}={entityList[0]}'
      query = None
    elif getGroupMatchPatterns(myarg, matchPatterns, False):
      pass
    elif myarg == 'select':
      entityList = getEntityList(Cmd.OB_GROUP_ENTITY)
      subTitle = f'{Msg.SELECTED} {Ent.Plural(Ent.CLOUD_IDENTITY_GROUP)}'
      query = None
    elif getIPSGMGroupRolesMemberDisplayOptions(myarg, rolesSet, memberDisplayOptions):
      pass
    elif getCIGroupMemberTypes(myarg, typesSet):
      pass
    elif getMemberMatchOptions(myarg, memberOptions):
      pass
    elif myarg == 'depth':
      maxdepth = getInteger(minVal=-1)
    elif myarg == 'includederivedmembership':
      includeDerivedMembership = True
    elif myarg in {'minimal', 'basic', 'full'}:
      listView = myarg
    else:
      unknownArgumentExit()
  if not rolesSet:
    rolesSet = ALL_GROUP_ROLES
  if not typesSet:
    typesSet = ALL_CIGROUP_MEMBER_TYPES
  checkCategory = memberDisplayOptions['showCategory']
  fields = ','.join(set(groupFieldsLists['ci']))
  entityList = getCIGroupMembersEntityList(ci, entityList, query, subTitle, matchPatterns, groupFieldsLists['ci'], None)
  kwargs = _getCIListGroupMembersArgs(listView)
  i = 0
  count = len(entityList)
  for group in entityList:
    i += 1
    if isinstance(group, dict):
      groupEntity = group
    else:
      _, name, groupEmail = convertGroupEmailToCloudID(ci, group, i, count)
      if not name:
        continue
      try:
        groupEntity = callGAPI(ci.groups(), 'get',
                               throwReasons=GAPI.CIGROUP_GET_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
                               name=name, fields=fields)
      except (GAPI.notFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
              GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.systemError,
              GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.CLOUD_IDENTITY_GROUP, groupEmail], str(e), i, count)
        continue
    groupEmail = groupEntity['groupKey']['id'].lower()
    if checkGroupMatchPatterns(groupEmail, groupEntity, matchPatterns):
      _showGroup(groupEntity['name'], groupEmail, 0)

# gam print licenses [todrive <ToDriveAttribute>*]
#	[(products|product <ProductIDList>)|(skus|sku <SKUIDList>)|allskus|gsuite]
#	[maxresults <Integer>]
#	[countsonly]
def doPrintLicenses(returnFields=None, skus=None, countsOnly=False, returnCounts=False):
  lic = buildGAPIObject(API.LICENSING)
  setTrueCustomerId()
  customerId = _getCustomerId()
  csvPF = CSVPrintFile()
  products = []
  feed = []
  licenseCounts = []
  maxResults = GC.Values[GC.LICENSE_MAX_RESULTS]
  if not returnFields:
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if not returnCounts and myarg == 'todrive':
        csvPF.GetTodriveParameters()
      elif myarg in {'products', 'product'}:
        products = getGoogleProductList()
        skus = []
      elif myarg in {'skus', 'sku'}:
        skus = getGoogleSKUList()
        products = []
      elif myarg == 'allskus':
        skus = SKU.getAllSKUs()
        products = []
      elif myarg == 'gsuite':
        skus = SKU.getGSuiteSKUs()
        products = []
      elif myarg == 'countsonly':
        countsOnly = True
      elif myarg == 'maxresults':
        maxResults = getInteger(minVal=10, maxVal=1000)
      else:
        unknownArgumentExit()
    if not skus and not products and GM.Globals[GM.LICENSE_SKUS]:
      skus = GM.Globals[GM.LICENSE_SKUS]
    if not countsOnly:
      fields = getItemFieldsFromFieldsList('items', ['productId', 'skuId', 'userId'])
      csvPF.SetTitles(['userId', 'productId', 'productDisplay', 'skuId', 'skuDisplay'])
    else:
      fields = getItemFieldsFromFieldsList('items', ['userId'])
      if not returnCounts:
        if skus:
          csvPF.SetTitles(['productId', 'productDisplay', 'skuId', 'skuDisplay', 'licenses'])
        else:
          csvPF.SetTitles(['productId', 'productDisplay', 'licenses'])
  else:
    fields = getItemFieldsFromFieldsList('items', returnFields)
  if skus:
    for sku in skus:
      Ent.SetGetting(Ent.LICENSE)
      productId = sku[0]
      skuId = sku[1]
      productDisplay = SKU.formatProductIdDisplayName(productId)
      skuIdDisplay = SKU.formatSKUIdDisplayName(skuId)
      try:
        feed += callGAPIpages(lic.licenseAssignments(), 'listForProductAndSku', 'items',
                              pageMessage=getPageMessageForWhom(forWhom=skuIdDisplay),
                              throwReasons=[GAPI.INVALID, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                              customerId=customerId, productId=productId, skuId=skuId,
                              maxResults=maxResults, fields=fields)
        if countsOnly:
          licenseCounts.append([Ent.PRODUCT, productId, Ent.SKU, [skuId, skuIdDisplay][returnCounts], Ent.LICENSE, len(feed)])
          feed = []
      except (GAPI.invalid, GAPI.forbidden, GAPI.invalidArgument) as e:
        entityActionNotPerformedWarning([Ent.PRODUCT, productDisplay, Ent.SKU, skuIdDisplay], str(e))
  else:
    suppressErrorMsg = False
    if not products:
      suppressErrorMsg = True
      products = SKU.getSortedProductList()
    for productId in products:
      Ent.SetGetting(Ent.LICENSE)
      productDisplay = SKU.formatProductIdDisplayName(productId)
      try:
        feed += callGAPIpages(lic.licenseAssignments(), 'listForProduct', 'items',
                              pageMessage=getPageMessageForWhom(forWhom=productDisplay),
                              throwReasons=[GAPI.INVALID, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                              customerId=customerId, productId=productId,
                              maxResults=maxResults, fields=fields)
        if countsOnly:
          licenseCounts.append([Ent.PRODUCT, [productId, productDisplay][returnCounts], Ent.LICENSE, len(feed)])
          feed = []
      except (GAPI.invalid, GAPI.forbidden, GAPI.invalidArgument) as e:
        if not suppressErrorMsg:
          entityActionNotPerformedWarning([Ent.PRODUCT, productDisplay], str(e))
  if countsOnly:
    if returnCounts:
      return licenseCounts
    if skus:
      for u_license in licenseCounts:
        csvPF.WriteRow({'productId': u_license[1], 'productDisplay': SKU.productIdToDisplayName(u_license[1]),
                        'skuId': u_license[3], 'skuDisplay': SKU.skuIdToDisplayName(u_license[3]), 'licenses': u_license[5]})
    else:
      for u_license in licenseCounts:
        csvPF.WriteRow({'productId': u_license[1], 'productDisplay': SKU.productIdToDisplayName(u_license[1]), 'licenses': u_license[3]})
    csvPF.writeCSVfile('Licenses')
    return
  if returnFields:
    if returnFields == ['userId']:
      userIds = []
      for u_license in feed:
        userId = u_license.get('userId', '').lower()
        if userId:
          userIds.append(userId)
      return userIds
    userSkuIds = {}
    for u_license in feed:
      userId = u_license.get('userId', '').lower()
      skuId = u_license.get('skuId')
      if userId and skuId:
        userSkuIds.setdefault(userId, [])
        userSkuIds[userId].append(skuId)
    return userSkuIds
  for u_license in feed:
    userId = u_license.get('userId', '').lower()
    productId = u_license.get('productId', '')
    skuId = u_license.get('skuId', '')
    csvPF.WriteRow({'userId': userId,
                    'productId': productId, 'productDisplay': SKU.productIdToDisplayName(productId),
                    'skuId': skuId, 'skuDisplay': SKU.skuIdToDisplayName(skuId)})
  csvPF.writeCSVfile('Licenses')

# gam show licenses
#	[(products|product <ProductIDList>)|(skus|sku <SKUIDList>)|allskus|gsuite]
#	[maxresults <Integer>]
def doShowLicenses():
  licenseCounts = doPrintLicenses(countsOnly=True, returnCounts=True)
  for u_license in licenseCounts:
    printEntityKVList(u_license[:-2], [Ent.Plural(u_license[-2]), u_license[-1]])

# gam delete alert <AlertID>
# gam undelete alert <AlertID>
def doDeleteOrUndeleteAlert():
  alertId = getString(Cmd.OB_ALERT_ID)
  if Act.Get() == Act.DELETE:
    action = 'delete'
    kwargs = {}
  else:
    action = 'undelete'
    kwargs = {'body': {}}
  user, ac = buildGAPIServiceObject(API.ALERTCENTER, _getAdminEmail())
  if not ac:
    return
  try:
    callGAPI(ac.alerts(), action,
             throwReasons=GAPI.ALERT_THROW_REASONS+[GAPI.NOT_FOUND],
             alertId=alertId, **kwargs)
    entityActionPerformed([Ent.ALERT, alertId])
  except GAPI.notFound as e:
    entityActionFailedWarning([Ent.ALERT_ID, alertId], str(e))
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.permissionDenied):
    userAlertsServiceNotEnabledWarning(user)

ALERT_TIME_OBJECTS = {'createTime', 'startTime', 'endTime'}

def _showAlert(alert, FJQC, i=0, count=0):
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(alert, timeObjects=ALERT_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.ALERT_ID, alert['alertId']], i, count)
  Ind.Increment()
  for field in ['createTime', 'startTime', 'endTime']:
    if field in alert:
      printKeyValueList([field, formatLocalTime(alert[field])])
  for field in ['customerId', 'type', 'source', 'deleted', 'securityInvestigationToolLink']:
    if field in alert:
      printKeyValueList([field, alert[field]])
  if 'data' in alert:
    showJSON('data', alert['data'])
  Ind.Decrement()

# gam info alert <AlertID> [formatjson]
def doInfoAlert():
  alertId = getString(Cmd.OB_ALERT_ID)
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    FJQC.GetFormatJSON(myarg)
  user, ac = buildGAPIServiceObject(API.ALERTCENTER, _getAdminEmail())
  if not ac:
    return
  try:
    alert = callGAPI(ac.alerts(), 'get',
                     throwReasons=GAPI.ALERT_THROW_REASONS+[GAPI.NOT_FOUND],
                     alertId=alertId)
    _showAlert(alert, FJQC)
  except GAPI.notFound as e:
    entityActionFailedWarning([Ent.ALERT_ID, alertId], str(e))
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.permissionDenied):
    userAlertsServiceNotEnabledWarning(user)

ALERT_ORDERBY_CHOICE_MAP = {
  'createdate': 'create_time',
  'createtime': 'create_time',
  }

# gam show alerts [filter <String>] [orderby createtime [ascending|descending]]
#	[formatjson]
# gam print alerts [todrive <ToDriveAttribute>*] [filter <String>] [orderby createtime [ascending|descending]]
#	[formatjson [quotechar <Character>]]
def doPrintShowAlerts():
  csvPF = CSVPrintFile(['alertId', 'createTime']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  kwargs = {}
  OBY = OrderBy(ALERT_ORDERBY_CHOICE_MAP)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'filter':
      kwargs['filter'] = getString(Cmd.OB_STRING).replace("'", '"')
    elif myarg == 'orderby':
      OBY.GetChoice()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if csvPF and not FJQC.formatJSON:
    csvPF.SetSortTitles(['alertId', 'createTime', 'startTime', 'endTime', 'customerId', 'type', 'source', 'deleted'])
  user, ac = buildGAPIServiceObject(API.ALERTCENTER, _getAdminEmail())
  if not ac:
    return
  try:
    alerts = callGAPIpages(ac.alerts(), 'list', 'alerts',
                           throwReasons=GAPI.ALERT_THROW_REASONS+[GAPI.BAD_REQUEST, GAPI.INVALID_ARGUMENT],
                           orderBy=OBY.orderBy, **kwargs)
  except (GAPI.badRequest, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.ALERT, None], str(e))
    return
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.permissionDenied):
    userAlertsServiceNotEnabledWarning(user)
    return
  if not csvPF:
    jcount = len(alerts)
    if not FJQC.formatJSON:
      performActionNumItems(jcount, Ent.ALERT)
    Ind.Increment()
    j = 0
    for alert in alerts:
      j += 1
      _showAlert(alert, FJQC, j, jcount)
    Ind.Decrement()
  else:
    for alert in alerts:
      row = flattenJSON(alert, timeObjects=ALERT_TIME_OBJECTS)
      if not FJQC.formatJSON:
        csvPF.WriteRowTitles(row)
      elif csvPF.CheckRowTitles(row):
        csvPF.WriteRowNoFilter({'alertId': alert['alertId'],
                                'createTime': formatLocalTime(alert['createTime']),
                                'JSON': json.dumps(cleanJSON(alert, timeObjects=ALERT_TIME_OBJECTS), ensure_ascii=False, sort_keys=True)})
  if csvPF:
    csvPF.writeCSVfile('Alerts')

ALERT_TYPE_MAP = {
  'notuseful': 'NOT_USEFUL',
  'somewhatuseful': 'SOMEWHAT_USEFUL',
  'veryuseful': 'VERY_USEFUL',
  }

# gam create alertfeedback <AlertID> not_useful|somewhat_useful|very_useful
def doCreateAlertFeedback():
  user, ac = buildGAPIServiceObject(API.ALERTCENTER, _getAdminEmail())
  if not ac:
    return
  alertId = getString(Cmd.OB_ALERT_ID)
  body = {'type': getChoice(ALERT_TYPE_MAP, mapChoice=True)}
  try:
    result = callGAPI(ac.alerts().feedback(), 'create',
                      throwReasons=GAPI.ALERT_THROW_REASONS+[GAPI.NOT_FOUND],
                      alertId=alertId, body=body)
    entityActionPerformed([Ent.ALERT, alertId, Ent.ALERT_FEEDBACK_ID, result['feedbackId']])
  except GAPI.notFound as e:
    entityActionFailedWarning([Ent.ALERT_ID, alertId], str(e))
  except (GAPI.serviceNotAvailable, GAPI.authError):
    userAlertsServiceNotEnabledWarning(user)

def _showAlertFeedback(feedback, FJQC, i=0, count=0):
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(feedback, timeObjects=ALERT_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.ALERT_FEEDBACK_ID, feedback['feedbackId']], i, count)
  Ind.Increment()
  for field in ['createTime']:
    if field in feedback:
      printKeyValueList([field, formatLocalTime(feedback[field])])
  for field in ['alertId', 'customerId', 'type', 'email']:
    if field in feedback:
      printKeyValueList([field, feedback[field]])
  Ind.Decrement()

ALERT_FEEDBACK_ORDERBY_CHOICE_MAP = {
  'createdate': 'createTime',
  'createtime': 'createTime',
  }

# gam show alertfeedback [alert <AlertID>] [filter <String>] [orderby createtime [ascending|descending]]
#	[formatjson]
# gam print alertfeedback [todrive <ToDriveAttribute>*] [alert <AlertID>] [filter <String>] [orderby createtime [ascending|descending]]
#	[formatjson [quotechar <Character>]]
def doPrintShowAlertFeedback():
  csvPF = CSVPrintFile(['feedbackId', 'createTime']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  kwargs = {}
  alertId = '-'
  OBY = OrderBy(ALERT_FEEDBACK_ORDERBY_CHOICE_MAP)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'alertid':
      alertId = getString(Cmd.OB_ALERT_ID)
    elif myarg == 'filter':
      kwargs['filter'] = getString(Cmd.OB_STRING).replace("'", '"')
    elif myarg == 'orderby':
      OBY.GetChoice()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if csvPF and not FJQC.formatJSON:
    csvPF.SetSortTitles(['feedbackId', 'createTime', 'alertId', 'customerId', 'type', 'email'])
  user, ac = buildGAPIServiceObject(API.ALERTCENTER, _getAdminEmail())
  if not ac:
    return
  try:
    feedbacks = callGAPIpages(ac.alerts().feedback(), 'list', 'feedback',
                              throwReasons=GAPI.ALERT_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.BAD_REQUEST],
                              alertId=alertId, **kwargs)
  except (GAPI.notFound, GAPI.badRequest) as e:
    entityActionFailedWarning([Ent.ALERT_ID, alertId], str(e))
    return
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.permissionDenied):
    userAlertsServiceNotEnabledWarning(user)
    return
  for sk in OBY.items:
    if sk.endswith(' desc'):
      field, _ = sk.split(' ')
      reverse = True
    else:
      field = sk
      reverse = False
    feedbacks = sorted(feedbacks, key=lambda k: k[field], reverse=reverse) #pylint: disable=cell-var-from-loop
  if not csvPF:
    jcount = len(feedbacks)
    if not FJQC.formatJSON:
      performActionNumItems(jcount, Ent.ALERT_FEEDBACK)
    Ind.Increment()
    j = 0
    for feedback in feedbacks:
      j += 1
      _showAlertFeedback(feedback, FJQC, j, jcount)
    Ind.Decrement()
  else:
    for feedback in feedbacks:
      row = flattenJSON(feedback, timeObjects=ALERT_TIME_OBJECTS)
      if not FJQC.formatJSON:
        csvPF.WriteRowTitles(row)
      elif csvPF.CheckRowTitles(row):
        csvPF.WriteRowNoFilter({'feedbackId': feedback['feedbackId'],
                                'createTime': formatLocalTime(feedback['createTime']),
                                'JSON': json.dumps(cleanJSON(feedback, timeObjects=ALERT_TIME_OBJECTS),
                                                   ensure_ascii=False, sort_keys=True)})
  if csvPF:
    csvPF.writeCSVfile('Alert Feedbacks')

def ACLRuleDict(rule):
  if rule['scope']['type'] != 'default':
    return {'Scope': f'{rule["scope"]["type"]}:{rule["scope"]["value"]}', 'Role': rule['role']}
  return {'Scope': f'{rule["scope"]["type"]}', 'Role': rule['role']}

def ACLRuleKeyValueList(rule):
  if rule['scope']['type'] != 'default':
    return ['Scope', f'{rule["scope"]["type"]}:{rule["scope"]["value"]}', 'Role', rule['role']]
  return ['Scope', f'{rule["scope"]["type"]}', 'Role', rule['role']]

def formatACLRule(rule):
  return formatKeyValueList('(', ACLRuleKeyValueList(rule), ')')

def formatACLScopeRole(scope, role):
  if role:
    return formatKeyValueList('(', ['Scope', scope, 'Role', role], ')')
  return formatKeyValueList('(', ['Scope', scope], ')')

def normalizeRuleId(ruleId):
  ruleIdParts = ruleId.split(':', 1)
  if (len(ruleIdParts) == 1) or not ruleIdParts[1]:
    if ruleIdParts[0] == 'default':
      return ruleId
    if ruleIdParts[0] == 'domain':
      return f'domain:{GC.Values[GC.DOMAIN]}'
    return f'user:{normalizeEmailAddressOrUID(ruleIdParts[0], noUid=True)}'
  if ruleIdParts[0] in {'user', 'group'}:
    return f'{ruleIdParts[0]}:{normalizeEmailAddressOrUID(ruleIdParts[1], noUid=True)}'
  return ruleId

def makeRoleRuleIdBody(role, ruleId):
  ruleIdParts = ruleId.split(':', 1)
  if len(ruleIdParts) == 1:
    if ruleIdParts[0] == 'default':
      return {'role': role, 'scope': {'type': ruleIdParts[0]}}
    if ruleIdParts[0] == 'domain':
      return {'role': role, 'scope': {'type': ruleIdParts[0], 'value': GC.Values[GC.DOMAIN]}}
    return {'role': role, 'scope': {'type': 'user', 'value': ruleIdParts[0]}}
  return {'role': role, 'scope': {'type': ruleIdParts[0], 'value': ruleIdParts[1]}}

BUILDING_ADDRESS_FIELD_MAP = {
  'address': 'addressLines',
  'addresslines': 'addressLines',
  'administrativearea': 'administrativeArea',
  'city': 'locality',
  'country': 'regionCode',
  'language': 'languageCode',
  'languagecode': 'languageCode',
  'locality': 'locality',
  'postalcode': 'postalCode',
  'regioncode': 'regionCode',
  'state': 'administrativeArea',
  'sublocality': 'sublocality',
  'zipcode': 'postalCode',
  }

def _getBuildingAttributes(body):
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'id':
      body['buildingId'] = getString(Cmd.OB_BUILDING_ID, maxLen=100)
    elif myarg == 'name':
      body['buildingName'] = getString(Cmd.OB_STRING, maxLen=100)
    elif myarg in {'lat', 'latitude'}:
      body.setdefault('coordinates', {})
      body['coordinates']['latitude'] = getFloat(minVal=-180.0, maxVal=180.0)
    elif myarg in {'long', 'lng', 'longitude'}:
      body.setdefault('coordinates', {})
      body['coordinates']['longitude'] = getFloat(minVal=-180.0, maxVal=180.0)
    elif myarg == 'description':
      body['description'] = getString(Cmd.OB_STRING)
    elif myarg == 'floors':
      body['floorNames'] = getString(Cmd.OB_STRING).split(',')
    elif myarg in BUILDING_ADDRESS_FIELD_MAP:
      myarg = BUILDING_ADDRESS_FIELD_MAP[myarg]
      body.setdefault('address', {})
      if myarg == 'addressLines':
        body['address'][myarg] = getStringWithCRsNLs().split('\n')
      elif myarg == 'languageCode':
        body['address'][myarg] = getLanguageCode(LANGUAGE_CODES_MAP)
      else:
        body['address'][myarg] = getString(Cmd.OB_STRING, minLen=0)
    else:
      unknownArgumentExit()
  return body

# gam create building <Name> <BuildingAttribute>*
def doCreateBuilding():
  cd = buildGAPIObject(API.DIRECTORY)
  body = _getBuildingAttributes({'buildingId': str(uuid.uuid4()),
                                 'buildingName': getString(Cmd.OB_NAME, maxLen=100),
                                 'floorNames': ['1']})
  try:
    callGAPI(cd.resources().buildings(), 'insert',
             throwReasons=[GAPI.DUPLICATE, GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
             customer=GC.Values[GC.CUSTOMER_ID], body=body)
    entityActionPerformed([Ent.BUILDING_ID, body['buildingId'], Ent.BUILDING, body['buildingName']])
  except GAPI.duplicate:
    entityDuplicateWarning([Ent.BUILDING_ID, body['buildingId'], Ent.BUILDING, body['buildingName']])
  except GAPI.invalidInput as e:
    entityActionFailedWarning([Ent.BUILDING_ID, body['buildingId'], Ent.BUILDING, body['buildingName']], str(e))
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden):
    accessErrorExit(cd)

def _makeBuildingIdNameMap(cd=None):
  GM.Globals[GM.MAKE_BUILDING_ID_NAME_MAP] = False
  if cd is None:
    cd = buildGAPIObject(API.DIRECTORY)
  try:
    buildings = callGAPIpages(cd.resources().buildings(), 'list', 'buildings',
                              throwReasons=[GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                              customer=GC.Values[GC.CUSTOMER_ID],
                              fields='nextPageToken,buildings(buildingId,buildingName)')
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden):
    accessErrorExit(cd)
  for building in buildings:
    GM.Globals[GM.MAP_BUILDING_ID_TO_NAME][building['buildingId']] = building['buildingName']
    GM.Globals[GM.MAP_BUILDING_NAME_TO_ID][building['buildingName']] = building['buildingId']

def _getBuildingByNameOrId(cd, minLen=1, allowNV=False):
  which_building = getString(Cmd.OB_BUILDING_ID, minLen=minLen)
  if not which_building or (minLen == 0 and which_building in {'id:', 'uid:'}):
    return ''
  cg = UID_PATTERN.match(which_building)
  if cg:
    return cg.group(1)
  if allowNV and which_building.startswith('nv:'):
    return which_building[3:]
  if GM.Globals[GM.MAKE_BUILDING_ID_NAME_MAP]:
    _makeBuildingIdNameMap(cd)
# Exact name match, return ID
  if which_building in GM.Globals[GM.MAP_BUILDING_NAME_TO_ID]:
    return GM.Globals[GM.MAP_BUILDING_NAME_TO_ID][which_building]
# No exact name match, check for case insensitive name matches
  which_building_lower = which_building.lower()
  ci_matches = []
  for buildingName, buildingId in iter(GM.Globals[GM.MAP_BUILDING_NAME_TO_ID].items()):
    if buildingName.lower() == which_building_lower:
      ci_matches.append({'buildingName': buildingName, 'buildingId': buildingId})
# One match, return ID
  if len(ci_matches) == 1:
    return ci_matches[0]['buildingId']
# No or multiple name matches, try ID
# Exact ID match, return ID
  if which_building in GM.Globals[GM.MAP_BUILDING_ID_TO_NAME]:
    return which_building
# No exact ID match, check for case insensitive id match
  for buildingId in GM.Globals[GM.MAP_BUILDING_ID_TO_NAME]:
# Match, return ID
    if buildingId.lower() == which_building_lower:
      return buildingId
# Multiple name  matches
  if len(ci_matches) > 1:
    printErrorMessage(1, Msg.MULTIPLE_BUILDINGS_SAME_NAME.format(len(ci_matches), Ent.Plural(Ent.BUILDING)))
    Ind.Increment()
    for building in ci_matches:
      printEntity([Ent.BUILDING, building['buildingName'], Ent.BUILDING_ID, building['buildingId']])
    Ind.Decrement()
    Cmd.Backup()
    usageErrorExit(Msg.PLEASE_SPECIFY_BUILDING_EXACT_CASE_NAME_OR_ID)
# No matches
  entityDoesNotExistExit(Ent.BUILDING, which_building)

def _getBuildingNameById(cd, buildingId):
  if GM.Globals[GM.MAKE_BUILDING_ID_NAME_MAP]:
    _makeBuildingIdNameMap(cd)
  return GM.Globals[GM.MAP_BUILDING_ID_TO_NAME].get(buildingId, buildingId)

# gam update building <BuildIngID> <BuildingAttribute>*
def doUpdateBuilding():
  cd = buildGAPIObject(API.DIRECTORY)
  buildingId = _getBuildingByNameOrId(cd)
  body = _getBuildingAttributes({})
  try:
    callGAPI(cd.resources().buildings(), 'patch',
             throwReasons=[GAPI.DUPLICATE, GAPI.RESOURCE_NOT_FOUND, GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
             customer=GC.Values[GC.CUSTOMER_ID], buildingId=buildingId, body=body)
    entityActionPerformed([Ent.BUILDING_ID, buildingId])
  except GAPI.duplicate:
    entityDuplicateWarning([Ent.BUILDING, body['buildingName']])
  except GAPI.resourceNotFound:
    entityUnknownWarning(Ent.BUILDING_ID, buildingId)
  except GAPI.invalidInput as e:
    entityActionFailedWarning([Ent.BUILDING_ID, buildingId], str(e))
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden):
    accessErrorExit(cd)

# gam delete building <BuildIngID>
def doDeleteBuilding():
  cd = buildGAPIObject(API.DIRECTORY)
  buildingId = _getBuildingByNameOrId(cd)
  checkForExtraneousArguments()
  try:
    callGAPI(cd.resources().buildings(), 'delete',
             throwReasons=[GAPI.RESOURCE_NOT_FOUND, GAPI.CONDITION_NOT_MET,
                           GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
             customer=GC.Values[GC.CUSTOMER_ID], buildingId=buildingId)
    entityActionPerformed([Ent.BUILDING_ID, buildingId])
  except GAPI.resourceNotFound:
    entityUnknownWarning(Ent.BUILDING_ID, buildingId)
  except GAPI.conditionNotMet as e:
    entityActionFailedWarning([Ent.BUILDING_ID, buildingId], str(e))
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden):
    accessErrorExit(cd)

BUILDING_ADDRESS_PRINT_ORDER = ['addressLines', 'sublocality', 'locality', 'administrativeArea', 'postalCode', 'regionCode', 'languageCode']

def _showBuilding(building, delimiter=',', i=0, count=0, FJQC=None):
  if FJQC is not None and FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(building), ensure_ascii=False, sort_keys=True))
    return
  if 'buildingName' in building:
    printEntity([Ent.BUILDING, building['buildingName']], i, count)
    Ind.Increment()
    printKeyValueList(['buildingId', f'id:{building["buildingId"]}'])
  else:
    printEntity([Ent.BUILDING_ID, f'id:{building["buildingId"]}'], i, count)
    Ind.Increment()
  if 'description' in building:
    printKeyValueList(['description', building['description']])
  if 'floorNames' in building:
    printKeyValueList(['floorNames', delimiter.join(building['floorNames'])])
  if 'coordinates' in building:
    printKeyValueList(['coordinates', None])
    Ind.Increment()
    printKeyValueList(['latitude', f'{building["coordinates"].get("latitude", 0):4.7f}'])
    printKeyValueList(['longitude', f'{building["coordinates"].get("longitude", 0):4.7f}'])
    Ind.Decrement()
  if 'address' in building:
    printKeyValueList(['address', None])
    Ind.Increment()
    for field in BUILDING_ADDRESS_PRINT_ORDER:
      if field in building['address']:
        if field != 'addressLines':
          printKeyValueList([field, building['address'][field]])
        else:
          printKeyValueList([field, None])
          Ind.Increment()
          for line in building['address'][field]:
            printKeyValueList([line])
          Ind.Decrement()
    Ind.Decrement()
  Ind.Decrement()

# gam info building <BuildingID>
#	[formatjson]
def doInfoBuilding():
  cd = buildGAPIObject(API.DIRECTORY)
  buildingId = _getBuildingByNameOrId(cd)
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    FJQC.GetFormatJSON(myarg)
  try:
    building = callGAPI(cd.resources().buildings(), 'get',
                        throwReasons=[GAPI.RESOURCE_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                        customer=GC.Values[GC.CUSTOMER_ID], buildingId=buildingId)
    _showBuilding(building, FJQC=FJQC)
  except GAPI.resourceNotFound:
    entityUnknownWarning(Ent.BUILDING_ID, buildingId)
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden):
    accessErrorExit(cd)

BUILDINGS_FIELDS_CHOICE_MAP = {
  'address': 'address',
  'buildingid': 'buildingId',
  'buildingname': 'buildingName',
  'coordinates': 'coordinates',
  'description': 'description',
  'floors': 'floorNames',
  'floornames': 'floorNames',
  'id': 'buildingId',
  'name': 'buildingName',
  }
BUILDINGS_SORT_TITLES = ['buildingId', 'buildingName', 'description', 'floorNames']

# gam print buildings [todrive <ToDriveAttribute>*]
#	[allfields|<BuildingFildName>*|(fields <BuildingFieldNameList>)]
#	[delimiter <Character>] [formatjson [quotechar <Character>]]
# gam show buildings
#	[allfields|<BuildingFildName>*|(fields <BuildingFieldNameList>)]
#	[formatjson]
def doPrintShowBuildings():
  cd = buildGAPIObject(API.DIRECTORY)
  csvPF = CSVPrintFile(['buildingId'], BUILDINGS_SORT_TITLES) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER] if csvPF else ','
  fieldsList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif csvPF and myarg == 'delimiter':
      delimiter = getCharacter()
    elif myarg == 'allfields':
      fieldsList = []
    elif getFieldsList(myarg, BUILDINGS_FIELDS_CHOICE_MAP, fieldsList, initialField='buildingId'):
      pass
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  fields = getItemFieldsFromFieldsList('buildings', fieldsList)
  try:
    buildings = callGAPIpages(cd.resources().buildings(), 'list', 'buildings',
                              throwReasons=[GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                              customer=GC.Values[GC.CUSTOMER_ID], fields=fields)
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden):
    accessErrorExit(cd)
  if not csvPF:
    jcount = len(buildings)
    performActionNumItems(jcount, Ent.BUILDING)
    Ind.Increment()
    j = 0
    for building in buildings:
      j += 1
      _showBuilding(building, delimiter, j, jcount, FJQC)
    Ind.Decrement()
  else:
    for building in buildings:
      if 'buildingId' in building:
        building['buildingId'] = f'id:{building["buildingId"]}'
      if 'floorNames' in building:
        building['floorNames'] = delimiter.join(building['floorNames'])
      if 'address' in building and 'addressLines' in building['address']:
        building['address']['addressLines'] = '\n'.join(building['address']['addressLines'])
      if 'coordinates' in building:
        building['coordinates']['latitude'] = f'{building["coordinates"].get("latitude", 0):4.7f}'
        building['coordinates']['longitude'] = f'{building["coordinates"].get("longitude", 0):4.7f}'
      row = flattenJSON(building)
      if not FJQC.formatJSON:
        csvPF.WriteRowTitles(row)
      else:
        if (not csvPF.rowFilter and not csvPF.rowDropFilter) or csvPF.CheckRowTitles(row):
          csvPF.WriteRowNoFilter({'buildingId': building['buildingId'],
                                  'JSON': json.dumps(cleanJSON(building),
                                                     ensure_ascii=False, sort_keys=True)})
  if csvPF:
    csvPF.writeCSVfile('Buildings')

def _getFeatureAttributes(body):
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'name':
      body['name'] = getString(Cmd.OB_STRING)
    else:
      unknownArgumentExit()
  return body

# gam create feature name <Name>
def doCreateFeature():
  cd = buildGAPIObject(API.DIRECTORY)
  body = _getFeatureAttributes({})
  try:
    callGAPI(cd.resources().features(), 'insert',
             throwReasons=[GAPI.DUPLICATE, GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
             customer=GC.Values[GC.CUSTOMER_ID], body=body)
    entityActionPerformed([Ent.BUILDING, body['name']])
  except GAPI.duplicate:
    entityDuplicateWarning([Ent.FEATURE, body['name']])
  except GAPI.invalidInput as e:
    entityActionFailedWarning([Ent.FEATURE, body['name']], str(e))
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden):
    accessErrorExit(cd)

#gam update feature <Name> name <Name>
def doUpdateFeature():
  # update does not work for name and name is only field to be updated
  # if additional writable fields are added to feature in the future
  # we'll add support for update as well as rename
  cd = buildGAPIObject(API.DIRECTORY)
  oldName = getString(Cmd.OB_STRING)
  getChoice(['name'])
  body = {'newName': getString(Cmd.OB_STRING)}
  checkForExtraneousArguments()
  try:
    callGAPI(cd.resources().features(), 'rename',
             throwReasons=[GAPI.DUPLICATE, GAPI.RESOURCE_NOT_FOUND, GAPI.INVALID_INPUT, GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
             customer=GC.Values[GC.CUSTOMER_ID], oldName=oldName, body=body)
    entityActionPerformed([Ent.FEATURE, oldName])
  except GAPI.duplicate:
    entityDuplicateWarning([Ent.FEATURE, body['newName']])
  except GAPI.resourceNotFound:
    entityUnknownWarning(Ent.FEATURE, oldName)
  except GAPI.invalidInput as e:
    entityActionFailedWarning([Ent.FEATURE, oldName], str(e))
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden):
    accessErrorExit(cd)

# gam delete feature <Name>
def doDeleteFeature():
  cd = buildGAPIObject(API.DIRECTORY)
  featureKey = getString(Cmd.OB_NAME)
  checkForExtraneousArguments()
  try:
    callGAPI(cd.resources().features(), 'delete',
             throwReasons=[GAPI.RESOURCE_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
             customer=GC.Values[GC.CUSTOMER_ID], featureKey=featureKey)
    entityActionPerformed([Ent.FEATURE, featureKey])
  except GAPI.resourceNotFound:
    entityUnknownWarning(Ent.FEATURE, featureKey)
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden):
    accessErrorExit(cd)

FEATURE_FIELDS_CHOICE_MAP = {
  'name': 'name',
  }

# gam print features [todrive <ToDriveAttribute>*]
# gam show features
def doPrintShowFeatures():
  cd = buildGAPIObject(API.DIRECTORY)
  csvPF = CSVPrintFile('name') if Act.csvFormat() else None
  fieldsList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'allfields':
      fieldsList = []
    elif getFieldsList(myarg, FEATURE_FIELDS_CHOICE_MAP, fieldsList):
      pass
    else:
      unknownArgumentExit()
  fields = getItemFieldsFromFieldsList('features', fieldsList)
  try:
    features = callGAPIpages(cd.resources().features(), 'list', 'features',
                             throwReasons=[GAPI.BAD_REQUEST, GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                             customer=GC.Values[GC.CUSTOMER_ID], fields=fields)
  except (GAPI.badRequest, GAPI.notFound, GAPI.forbidden):
    accessErrorExit(cd)
  if not csvPF:
    jcount = len(features)
    performActionNumItems(jcount, Ent.FEATURE)
    Ind.Increment()
    j = 0
    for feature in features:
      j += 1
      printEntity([Ent.FEATURE, feature['name']], j, jcount)
  else:
    for feature in features:
      csvPF.WriteRowTitles(flattenJSON(feature))
  if csvPF:
    csvPF.writeCSVfile('Features')

RESOURCE_CATEGORY_MAP = {
  'conference': 'CONFERENCE_ROOM',
  'conferenceroom': 'CONFERENCE_ROOM',
  'room': 'CONFERENCE_ROOM',
  'other': 'OTHER',
  'categoryunknown': 'CATEGORY_UNKNOWN',
  'unknown': 'CATEGORY_UNKNOWN',
  }

def _getResourceCalendarAttributes(cd, body, updateMode):
  featureChanges = {'add': set(), 'remove': set()}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'name', 'resourcename'}:
      body['resourceName'] = getString(Cmd.OB_STRING)
    elif myarg in {'description', 'resourcedescription'}:
      body['resourceDescription'] = getStringWithCRsNLs()
    elif myarg in {'type', 'resourcetype'}:
      body['resourceType'] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg in {'building', 'buildingid'}:
      body['buildingId'] = _getBuildingByNameOrId(cd, minLen=0)
    elif myarg == 'capacity':
      body['capacity'] = getInteger(minVal=0)
    elif myarg in {'feature', 'features', 'featureinstances'}:
      body.setdefault('featureInstances', [])
      for feature in shlexSplitList(getString(Cmd.OB_STRING, minLen=0)):
        body['featureInstances'].append({'feature': {'name': feature}})
    elif myarg in {'addfeature', 'addfeatures', 'addfeatureinstances'}:
      if not updateMode:
        body.setdefault('featureInstances', [])
        for feature in shlexSplitList(getString(Cmd.OB_STRING, minLen=0)):
          body['featureInstances'].append({'feature': {'name': feature}})
      else:
        for feature in shlexSplitList(getString(Cmd.OB_STRING, minLen=0)):
          featureChanges['add'].add(feature)
    elif updateMode and myarg in {'removefeature', 'removefeatures', 'removefeatureinstances'}:
      for feature in shlexSplitList(getString(Cmd.OB_STRING, minLen=0)):
        featureChanges['remove'].add(feature)
    elif myarg in {'floor', 'floorname'}:
      body['floorName'] = getString(Cmd.OB_STRING)
    elif myarg == 'floorsection':
      body['floorSection'] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg in {'category', 'resourcecategory'}:
      body['resourceCategory'] = getChoice(RESOURCE_CATEGORY_MAP, mapChoice=True)
    elif myarg in {'userdescription', 'uservisibledescription'}:
      body['userVisibleDescription'] = getString(Cmd.OB_STRING)
    else:
      unknownArgumentExit()
  if ('featureInstances' in body and not body['featureInstances'] and
      not featureChanges['add'] and not featureChanges['remove']):
    body['featureInstances'] = [{}]
  if not updateMode:
    return body
  return body, featureChanges

# gam create resource <ResourceID> <Name> <ResourceAttribute>*
def doCreateResourceCalendar():
  cd = buildGAPIObject(API.DIRECTORY)
  body = _getResourceCalendarAttributes(cd, {'resourceId': getString(Cmd.OB_RESOURCE_ID), 'resourceName': getString(Cmd.OB_NAME)}, False)
  try:
    callGAPI(cd.resources().calendars(), 'insert',
             throwReasons=[GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.SERVICE_NOT_AVAILABLE,
                           GAPI.REQUIRED, GAPI.DUPLICATE, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
             retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
             customer=GC.Values[GC.CUSTOMER_ID], body=body, fields='')
    entityActionPerformed([Ent.RESOURCE_CALENDAR, body['resourceId']])
  except (GAPI.invalid, GAPI.invalidInput, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning([Ent.RESOURCE_CALENDAR, body['resourceId']], str(e))
  except GAPI.required as e:
    errMsg = str(e)
    if '[resourceCapacity, resourceFloorName]' in errMsg:
      entityActionFailedWarning([Ent.RESOURCE_CALENDAR, body['resourceId']], Msg.RESOURCE_CAPACITY_FLOOR_REQUIRED)
    elif'[resourceFloorName]' in errMsg:
      entityActionFailedWarning([Ent.RESOURCE_CALENDAR, body['resourceId']], Msg.RESOURCE_FLOOR_REQUIRED)
    else:
      entityActionFailedWarning([Ent.RESOURCE_CALENDAR, body['resourceId']], errMsg)
  except GAPI.duplicate:
    entityDuplicateWarning([Ent.RESOURCE_CALENDAR, body['resourceId']])
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
    accessErrorExit(cd)

def _doUpdateResourceCalendars(entityList):
  cd = buildGAPIObject(API.DIRECTORY)
  body, featureChanges = _getResourceCalendarAttributes(cd, {}, True)
  i = 0
  count = len(entityList)
  for resourceId in entityList:
    i += 1
    try:
      if featureChanges['add'] or featureChanges['remove']:
        features = callGAPI(cd.resources().calendars(), 'get',
                            throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.SERVICE_NOT_AVAILABLE, GAPI.FORBIDDEN],
                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                            customer=GC.Values[GC.CUSTOMER_ID], calendarResourceId=resourceId, fields='featureInstances(feature(name))')
        bodyFeatures = body.pop('featureInstances', [])
        body['featureInstances'] = []
        featureSet = set()
        for feature in bodyFeatures:
          featureName = feature['feature']['name']
          if featureName not in featureChanges['remove'] and featureName not in featureSet:
            body['featureInstances'].append({'feature': {'name': featureName}})
            featureSet.add(featureName)
        for feature in features.get('featureInstances', []):
          featureName = feature['feature']['name']
          if featureName not in featureChanges['remove'] and featureName not in featureSet:
            body['featureInstances'].append({'feature': {'name': featureName}})
            featureSet.add(featureName)
        for featureName in featureChanges['add']:
          if featureName not in featureChanges['remove'] and featureName not in featureSet:
            body['featureInstances'].append({'feature': {'name': featureName}})
            featureSet.add(featureName)
        if not body['featureInstances']:
          body['featureInstances'] = [{}]
      callGAPI(cd.resources().calendars(), 'patch',
               throwReasons=[GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.SERVICE_NOT_AVAILABLE, GAPI.REQUIRED,
                             GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
               customer=GC.Values[GC.CUSTOMER_ID], calendarResourceId=resourceId, body=body, fields='')
      entityActionPerformed([Ent.RESOURCE_CALENDAR, resourceId], i, count)
    except (GAPI.invalid, GAPI.invalidInput, GAPI.serviceNotAvailable, GAPI.required)  as e:
      entityActionFailedWarning([Ent.RESOURCE_CALENDAR, resourceId], str(e), i, count)
    except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
      checkEntityAFDNEorAccessErrorExit(cd, Ent.RESOURCE_CALENDAR, resourceId, i, count)

# gam update resources <ResourceEntity> <ResourceAttribute>*
def doUpdateResourceCalendars():
  _doUpdateResourceCalendars(getEntityList(Cmd.OB_RESOURCE_ENTITY))

# gam update resource <ResourceID> <ResourceAttribute>*
def doUpdateResourceCalendar():
  _doUpdateResourceCalendars(getStringReturnInList(Cmd.OB_RESOURCE_ID))

def _doDeleteResourceCalendars(entityList):
  cd = buildGAPIObject(API.DIRECTORY)
  checkForExtraneousArguments()
  i = 0
  count = len(entityList)
  for resourceId in entityList:
    i += 1
    try:
      callGAPI(cd.resources().calendars(), 'delete',
               throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
               customer=GC.Values[GC.CUSTOMER_ID], calendarResourceId=resourceId)
      entityActionPerformed([Ent.RESOURCE_CALENDAR, resourceId], i, count)
    except GAPI.serviceNotAvailable as e:
      entityActionFailedWarning([Ent.RESOURCE_CALENDAR, resourceId], str(e), i, count)
    except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
      checkEntityAFDNEorAccessErrorExit(cd, Ent.RESOURCE_CALENDAR, resourceId, i, count)

# gam delete resources <ResourceEntity>
def doDeleteResourceCalendars():
  _doDeleteResourceCalendars(getEntityList(Cmd.OB_RESOURCE_ENTITY))

# gam delete resource <ResourceID>
def doDeleteResourceCalendar():
  _doDeleteResourceCalendars(getStringReturnInList(Cmd.OB_RESOURCE_ID))

def _getResourceACLsCalSettings(cal, resource, getCalSettings, getCalPermissions, i, count):
  calId = resource['resourceEmail']
  try:
    if getCalPermissions:
      acls = callGAPIpages(cal.acl(), 'list', 'items',
                           throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.AUTH_ERROR],
                           calendarId=calId, fields='nextPageToken,items(id,role,scope)')
    else:
      acls = {}
    if getCalSettings:
      settings = callGAPI(cal.calendars(), 'get',
                          throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND],
                          calendarId=calId)
      settings.pop('etag', None)
      settings.pop('kind', None)
      resource.update({'calendar': settings})
    return (True, acls)
  except (GAPI.forbidden, GAPI.serviceNotAvailable, GAPI.authError, GAPI.notACalendarUser) as e:
    entityActionFailedWarning([Ent.RESOURCE_CALENDAR, calId], str(e), i, count)
  except GAPI.notFound:
    entityUnknownWarning(Ent.RESOURCE_CALENDAR, calId, i, count)
  return (False, None)

RESOURCE_DFLT_FIELDS = ['resourceId', 'resourceName', 'resourceEmail', 'resourceDescription', 'resourceType']
RESOURCE_ADDTL_FIELDS = [
  'buildingId',	# buildingId must be first element
  'capacity',
  'featureInstances',
  'floorName',
  'floorSection',
  'generatedResourceName',
  'resourceCategory',
  'userVisibleDescription',
  ]
RESOURCE_ALL_FIELDS = RESOURCE_DFLT_FIELDS+RESOURCE_ADDTL_FIELDS
RESOURCE_FIELDS_WITH_CRS_NLS = {'resourceDescription'}

def _showResource(cd, resource, i, count, FJQC, acls=None, noSelfOwner=False):

  def _showResourceField(title, resource, field):
    if field in resource:
      if field not in RESOURCE_FIELDS_WITH_CRS_NLS:
        printKeyValueList([title, resource[field]])
      else:
        printKeyValueWithCRsNLs(title, resource[field])

  if 'buildingId' in resource:
    resource['buildingName'] = _getBuildingNameById(cd, resource['buildingId'])
    resource['buildingId'] = f'id:{resource["buildingId"]}'
  if FJQC.formatJSON:
    if acls:
      resource['acls'] = [{'id': rule['id'], 'role': rule['role']} for rule in acls]
    printLine(json.dumps(cleanJSON(resource), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.RESOURCE_ID, resource['resourceId']], i, count)
  Ind.Increment()
  _showResourceField('Name', resource, 'resourceName')
  _showResourceField('Email', resource, 'resourceEmail')
  _showResourceField('Type', resource, 'resourceType')
  _showResourceField('Description', resource, 'resourceDescription')
  if 'featureInstances' in resource:
    resource['featureInstances'] = ', '.join([a_feature['feature']['name'] for a_feature in resource.pop('featureInstances')])
  if 'buildingId' in resource:
    _showResourceField('buildingId', resource, 'buildingId')
    _showResourceField('buildingName', resource, 'buildingName')
  for field in RESOURCE_ADDTL_FIELDS[1:]:
    _showResourceField(field, resource, field)
  calendar = resource.get('calendar')
  if calendar:
    _showCalendarSettings(calendar, 0, 0)
  if acls:
    j = 0
    jcount = len(acls)
    printEntitiesCount(Ent.CALENDAR_ACL, acls)
    Ind.Increment()
    for rule in acls:
      j += 1
      if noSelfOwner and rule['role'] == 'owner' and rule['scope']['value'] == resource['resourceEmail']:
        continue
      printKeyValueListWithCount(ACLRuleKeyValueList(rule), j, jcount)
    Ind.Decrement()
  Ind.Decrement()

def _doInfoResourceCalendars(entityList):
  cd = buildGAPIObject(API.DIRECTORY)
  getCalSettings = getCalPermissions = noSelfOwner = False
  FJQC = FormatJSONQuoteChar()
  acls = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in [Cmd.ARG_ACLS, Cmd.ARG_CALENDARACLS, Cmd.ARG_PERMISSIONS]:
      getCalPermissions = True
    elif myarg == 'noselfowner':
      noSelfOwner = True
    elif myarg == Cmd.ARG_CALENDAR:
      getCalSettings = True
    else:
      FJQC.GetFormatJSON(myarg)
  if getCalSettings or getCalPermissions:
    cal = buildGAPIObject(API.CALENDAR)
  fields = ','.join(RESOURCE_ALL_FIELDS)
  i = 0
  count = len(entityList)
  for resourceId in entityList:
    i += 1
    try:
      resource = callGAPI(cd.resources().calendars(), 'get',
                          throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                          customer=GC.Values[GC.CUSTOMER_ID], calendarResourceId=resourceId, fields=fields)
      if getCalSettings or getCalPermissions:
        status, acls = _getResourceACLsCalSettings(cal, resource, getCalSettings, getCalPermissions, i, count)
        if not status:
          continue
      _showResource(cd, resource, i, count, FJQC, acls, noSelfOwner)
    except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
      checkEntityAFDNEorAccessErrorExit(cd, Ent.RESOURCE_CALENDAR, resourceId, i, count)

# gam info resources <ResourceEntity>
#	[acls] [noselfowner] [calendar] [formatjson]
def doInfoResourceCalendars():
  _doInfoResourceCalendars(getEntityList(Cmd.OB_RESOURCE_ENTITY))

# gam info resource <ResourceID>
#	[acls] [noselfowner] [calendar] [formatjson]
def doInfoResourceCalendar():
  _doInfoResourceCalendars(getStringReturnInList(Cmd.OB_RESOURCE_ID))

RESOURCE_FIELDS_CHOICE_MAP = {
  'description': 'resourceDescription',
  'building': 'buildingId',
  'buildingid': 'buildingId',
  'capacity': 'capacity',
  'category': 'resourceCategory',
  'email': 'resourceEmail',
  'feature': 'featureInstances',
  'features': 'featureInstances',
  'featureinstances': 'featureInstances',
  'floor': 'floorName',
  'floorname': 'floorName',
  'floorsection': 'floorSection',
  'generatedresourcename': 'generatedResourceName',
  'id': 'resourceId',
  'name': 'resourceName',
  'resourcecategory': 'resourceCategory',
  'resourcedescription': 'resourceDescription',
  'resourceemail': 'resourceEmail',
  'resourceid': 'resourceId',
  'resourcename': 'resourceName',
  'resourcetype': 'resourceType',
  'type': 'resourceType',
  'userdescription': 'userVisibleDescription',
  'uservisibledescription': 'userVisibleDescription',
  }

# gam show resources
#	[allfields|<ResourceFieldName>*|(fields <ResourceFieldNameList>)]
#	[query <String>]
#	[acls] [noselfowner] [calendar] [convertcrnl] [formatjson]
# gam print resources [todrive <ToDriveAttribute>*]
#	[allfields|<ResourceFieldName>*|(fields <ResourceFieldNameList>)]
#	[query <String>]
#	[acls] [noselfowner] [calendar] [convertcrnl] [formatjson [quotechar <Character>]]
# 	[showitemcountonly]
def doPrintShowResourceCalendars():
  cd = buildGAPIObject(API.DIRECTORY)
  convertCRNL = GC.Values[GC.CSV_OUTPUT_CONVERT_CR_NL]
  getCalSettings = getCalPermissions = noSelfOwner = False
  acls = query = None
  fieldsList = []
  csvPF = CSVPrintFile() if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  showItemCountOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'query':
      query = getString(Cmd.OB_QUERY)
    elif myarg == 'allfields':
      fieldsList = RESOURCE_ALL_FIELDS[:]
    elif myarg in [Cmd.ARG_ACLS, Cmd.ARG_CALENDARACLS, Cmd.ARG_PERMISSIONS]:
      getCalPermissions = True
    elif myarg == 'noselfowner':
      noSelfOwner = True
    elif myarg == Cmd.ARG_CALENDAR:
      getCalSettings = True
    elif myarg in RESOURCE_FIELDS_CHOICE_MAP:
      if not fieldsList:
        fieldsList = ['resourceId']
      fieldsList.append(RESOURCE_FIELDS_CHOICE_MAP[myarg])
    elif myarg == 'fields':
      if not fieldsList:
        fieldsList = ['resourceId']
      for field in _getFieldsList():
        if field in [Cmd.ARG_ACLS, Cmd.ARG_CALENDARACLS, Cmd.ARG_PERMISSIONS]:
          getCalPermissions = True
        elif field == Cmd.ARG_CALENDAR:
          getCalSettings = True
        elif field in RESOURCE_FIELDS_CHOICE_MAP:
          fieldsList.append(RESOURCE_FIELDS_CHOICE_MAP[field])
        else:
          invalidChoiceExit(field, RESOURCE_FIELDS_CHOICE_MAP, True)
    elif myarg in {'convertcrnl', 'converttextnl'}:
      convertCRNL = True
    elif myarg == 'showitemcountonly':
      showItemCountOnly = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  if not fieldsList:
    fieldsList = RESOURCE_DFLT_FIELDS[:]
  if getCalSettings or getCalPermissions:
    cal = buildGAPIObject(API.CALENDAR)
    fields = getItemFieldsFromFieldsList('items', fieldsList+['resourceEmail'])
  else:
    fields = getItemFieldsFromFieldsList('items', fieldsList)
  if 'buildingId' in fieldsList:
    fieldsList.append('buildingName')
  if csvPF:
    if not FJQC.formatJSON:
      csvPF.AddTitles(fieldsList)
      csvPF.SetSortTitles(RESOURCE_DFLT_FIELDS)
    else:
      if 'resourceName' in fieldsList:
        sortTitles = ['resourceId', 'resourceName', 'JSON']
      else:
        sortTitles = ['resourceId', 'JSON']
      csvPF.AddJSONTitles(sortTitles)
  printGettingAllAccountEntities(Ent.RESOURCE_CALENDAR)
  try:
    resources = callGAPIpages(cd.resources().calendars(), 'list', 'items',
                              pageMessage=getPageMessage(showFirstLastItems=True), messageAttribute='resourceName',
                              throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID_INPUT],
                              query=query, customer=GC.Values[GC.CUSTOMER_ID], fields=fields)
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
    accessErrorExit(cd)
  except GAPI.invalidInput as e:
    entityActionFailedWarning([Ent.RESOURCE_CALENDAR, ''], str(e))
    return
  count = len(resources)
  if showItemCountOnly:
    writeStdout(f'{count}\n')
    return
  i = 0
  for resource in resources:
    i += 1
    if getCalSettings or getCalPermissions:
      status, acls = _getResourceACLsCalSettings(cal, resource, getCalSettings, getCalPermissions, i, count)
      if not status:
        continue
    if not csvPF:
      _showResource(cd, resource, i, count, FJQC, acls, noSelfOwner)
    else:
      if 'buildingId' in resource:
        resource['buildingName'] = _getBuildingNameById(cd, resource['buildingId'])
        resource['buildingId'] = f'id:{resource["buildingId"]}'
      if not FJQC.formatJSON:
        if 'featureInstances' in resource:
          resource['featureInstances'] = ', '.join([a_feature['feature']['name'] for a_feature in resource.pop('featureInstances')])
        row = {}
        for field in fieldsList:
          if convertCRNL and field in RESOURCE_FIELDS_WITH_CRS_NLS:
            row[field] = escapeCRsNLs(resource.get(field, ''))
          else:
            row[field] = resource.get(field, '')
        if getCalSettings and 'calendar' in resource:
          flattenJSON(resource['calendar'], flattened=row)
        if getCalPermissions:
          for rule in acls:
            if noSelfOwner and rule['role'] == 'owner' and rule['scope']['value'] == resource['resourceEmail']:
              continue
            csvPF.WriteRowTitles(flattenJSON(rule, flattened=row.copy()))
        else:
          csvPF.WriteRowTitles(row)
      else:
        if getCalPermissions:
          resource['acls'] = []
          for rule in acls:
            if noSelfOwner and rule['role'] == 'owner' and rule['scope']['value'] == resource['resourceEmail']:
              continue
            resource['acls'].append({'id': rule['id'], 'role': rule['role']})
        row = {'resourceId': resource['resourceId'], 'JSON': json.dumps(cleanJSON(resource), ensure_ascii=False, sort_keys=True)}
        if 'resourceName' in resource:
          row['resourceName'] = resource['resourceName']
        csvPF.WriteRow(row)
  if csvPF:
    csvPF.writeCSVfile('Resources')

# Calendar commands utilities
def normalizeCalendarId(calId, user):
  if not user or calId.lower() != 'primary':
    return convertUIDtoEmailAddress(calId, emailTypes=['user', 'resource'])
  return user

def checkCalendarExists(cal, calId, i, count, showMessage=False):
  if not cal:
    cal = buildGAPIObject(API.CALENDAR)
  try:
    return callGAPI(cal.calendars(), 'get',
                    throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND],
                    calendarId=calId, fields='id')['id']
  except GAPI.notFound as e:
    if showMessage:
      entityActionFailedWarning([Ent.CALENDAR, calId], str(e))
    return None
  except GAPI.notACalendarUser:
    if showMessage:
      userCalServiceNotEnabledWarning(calId, i, count)
    return None

def validateCalendar(calId, i=0, count=0, noClientAccess=False):
  cal = None
  if not calId.endswith('.calendar.google.com'):
    calId, cal = buildGAPIServiceObject(API.CALENDAR, calId, i, count, displayError=noClientAccess)
  if not cal:
    if noClientAccess:
      return (calId, None)
    cal = buildGAPIObject(API.CALENDAR)
  try:
    callGAPI(cal.calendars(), 'get',
             throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND],
             calendarId=calId, fields='')
    return (calId, cal)
  except GAPI.notFound as e:
    entityActionFailedWarning([Ent.CALENDAR, calId], str(e), i, count)
  except GAPI.notACalendarUser:
    userCalServiceNotEnabledWarning(calId, i, count)
  return (calId, None)

def getNormalizedCalIdCal(cal, calId, user, i=0, count=0):
  if not cal:
    return validateCalendar(calId, i, count)
  return (normalizeCalendarId(calId, user), cal)

CALENDAR_ACL_ROLES_MAP = {
  'editor': 'writer',
  'freebusy': 'freeBusyReader',
  'freebusyreader': 'freeBusyReader',
  'owner': 'owner',
  'read': 'reader',
  'reader': 'reader',
  'writer': 'writer',
  'none': 'none',
  }

ACL_SCOPE_CHOICES = ['default', 'user', 'group', 'domain'] # default must be first element

def getACLScope():
  scopeType, scopeValue = getChoiceAndValue(Cmd.OB_ACL_SCOPE, ACL_SCOPE_CHOICES[1:], ':')
  if scopeType:
    if scopeType != 'domain':
      scopeValue = normalizeEmailAddressOrUID(scopeValue, noUid=True)
    else:
      scopeValue = scopeValue.lower()
    return (scopeType, scopeValue)
  scopeType = getChoice(ACL_SCOPE_CHOICES, defaultChoice='user')
  if scopeType == 'domain':
    entity = getString(Cmd.OB_DOMAIN_NAME, optional=True)
    if entity:
      scopeValue = entity.lower()
    else:
      scopeValue = GC.Values[GC.DOMAIN]
  elif scopeType != 'default':
    scopeValue = getEmailAddress(noUid=True)
  else:
    scopeValue = None
  return (scopeType, scopeValue)

def getCalendarACLScope():
  scopeType, scopeValue = getACLScope()
  if scopeType != 'default':
    return {'list': [f'{scopeType}:{scopeValue}'], 'dict': None}
  return {'list': [scopeType], 'dict': None}

def getCalendarSiteACLScopeEntity():
  ACLScopeEntity = {'list': getEntityList(Cmd.OB_ACL_SCOPE_ENTITY), 'dict': None}
  if isinstance(ACLScopeEntity['list'], dict):
    ACLScopeEntity['dict'] = ACLScopeEntity['list']
  return ACLScopeEntity

def getCalendarACLSendNotifications():
  return getBoolean() if checkArgumentPresent('sendnotifications') else True

def getCalendarCreateUpdateACLsOptions(getScopeEntity):
  role = getChoice(CALENDAR_ACL_ROLES_MAP, mapChoice=True)
  ACLScopeEntity = getCalendarSiteACLScopeEntity() if getScopeEntity else getCalendarACLScope()
  sendNotifications = getCalendarACLSendNotifications()
  checkForExtraneousArguments()
  return (role, ACLScopeEntity, sendNotifications)

def getCalendarDeleteACLsOptions(getScopeEntity):
  rolesMap = CALENDAR_ACL_ROLES_MAP.copy()
  rolesMap['id'] = 'id'
  role = getChoice(rolesMap, defaultChoice=None, mapChoice=True)
  ACLScopeEntity = getCalendarSiteACLScopeEntity() if getScopeEntity else getCalendarACLScope()
  checkForExtraneousArguments()
  return (role, ACLScopeEntity)

def _normalizeCalIdGetRuleIds(origUser, user, origCal, calId, j, jcount, ACLScopeEntity, showAction=True):
  if ACLScopeEntity['dict']:
    if origUser:
      if not GM.Globals[GM.CSV_SUBKEY_FIELD]:
        ruleIds = ACLScopeEntity['dict'][calId]
      else:
        ruleIds = ACLScopeEntity['dict'][origUser][calId]
    else:
      ruleIds = ACLScopeEntity['dict'][calId]
  else:
    ruleIds = ACLScopeEntity['list']
  calId, cal = getNormalizedCalIdCal(origCal, calId, user, j, jcount)
  if not cal:
    return (calId, cal, None, 0)
  kcount = len(ruleIds)
  if kcount == 0:
    setSysExitRC(NO_ENTITIES_FOUND_RC)
  if showAction:
    entityPerformActionNumItems([Ent.CALENDAR, calId], kcount, Ent.CALENDAR_ACL, j, jcount)
  return (calId, cal, ruleIds, kcount)

def _processCalendarACLs(cal, function, entityType, calId, j, jcount, k, kcount, role, ruleId, sendNotifications):
  result = True
  if function == 'insert':
    kwargs = {'body': makeRoleRuleIdBody(role, ruleId), 'fields': '', 'sendNotifications': sendNotifications}
  elif function == 'patch':
    kwargs = {'ruleId': ruleId, 'body': {'role': role}, 'fields': '', 'sendNotifications': sendNotifications}
  else: # elif function == 'delete':
    kwargs = {'ruleId': ruleId}
  try:
    callGAPI(cal.acl(), function,
             throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.INVALID_PARAMETER, GAPI.INVALID_SCOPE_VALUE,
                           GAPI.ILLEGAL_ACCESS_ROLE_FOR_DEFAULT, GAPI.CANNOT_CHANGE_OWN_ACL, GAPI.CANNOT_CHANGE_OWNER_ACL,
                           GAPI.FORBIDDEN, GAPI.AUTH_ERROR, GAPI.CONDITION_NOT_MET],
             calendarId=calId, **kwargs)
    entityActionPerformed([entityType, calId, Ent.CALENDAR_ACL, formatACLScopeRole(ruleId, role)], k, kcount)
  except GAPI.notFound as e:
    if not checkCalendarExists(cal, calId, j, jcount):
      entityUnknownWarning(entityType, calId, j, jcount)
      result = False
    else:
      entityActionFailedWarning([entityType, calId, Ent.CALENDAR_ACL, formatACLScopeRole(ruleId, role)], str(e), k, kcount)
  except (GAPI.invalid, GAPI.invalidParameter, GAPI.invalidScopeValue,
          GAPI.illegalAccessRoleForDefault, GAPI.cannotChangeOwnAcl, GAPI.cannotChangeOwnerAcl,
          GAPI.forbidden, GAPI.authError, GAPI.conditionNotMet) as e:
    entityActionFailedWarning([entityType, calId, Ent.CALENDAR_ACL, formatACLScopeRole(ruleId, role)], str(e), k, kcount)
  return result

def _createCalendarACLs(cal, entityType, calId, j, jcount, role, ruleIds, kcount, sendNotifications):
  Ind.Increment()
  k = 0
  for ruleId in ruleIds:
    k += 1
    ruleId = normalizeRuleId(ruleId)
    if not _processCalendarACLs(cal, 'insert', entityType, calId, j, jcount, k, kcount, role, ruleId, sendNotifications):
      break
  Ind.Decrement()

def _doCalendarsCreateACLs(origUser, user, origCal, calIds, count, role, ACLScopeEntity, sendNotifications):
  i = 0
  for calId in calIds:
    i += 1
    calId, cal, ruleIds, jcount = _normalizeCalIdGetRuleIds(origUser, user, origCal, calId, i, count, ACLScopeEntity)
    if jcount == 0:
      continue
    _createCalendarACLs(cal, Ent.CALENDAR, calId, i, count, role, ruleIds, jcount, sendNotifications)

# gam calendar <CalendarEntity> create <CalendarACLRole> <CalendarACLScope> [sendnotifications <Boolean>]
def doCalendarsCreateACL(calIds):
  role, ACLScopeEntity, sendNotifications = getCalendarCreateUpdateACLsOptions(False)
  _doCalendarsCreateACLs(None, None, None, calIds, len(calIds), role, ACLScopeEntity, sendNotifications)

# gam calendars <CalendarEntity> create acls <CalendarACLRole> <CalendarACLScopeEntity> [sendnotifications <Boolean>]
def doCalendarsCreateACLs(calIds):
  role, ACLScopeEntity, sendNotifications = getCalendarCreateUpdateACLsOptions(True)
  _doCalendarsCreateACLs(None, None, None, calIds, len(calIds), role, ACLScopeEntity, sendNotifications)

def _updateDeleteCalendarACLs(cal, function, entityType, calId, j, jcount, role, ruleIds, kcount, sendNotifications):
  Ind.Increment()
  k = 0
  for ruleId in ruleIds:
    k += 1
    ruleId = normalizeRuleId(ruleId)
    if not _processCalendarACLs(cal, function, entityType, calId, j, jcount, k, kcount, role, ruleId, sendNotifications):
      break
  Ind.Decrement()

def _doUpdateDeleteCalendarACLs(origUser, user, origCal, function, calIds, count, ACLScopeEntity, role, sendNotifications):
  i = 0
  for calId in calIds:
    i += 1
    calId, cal, ruleIds, jcount = _normalizeCalIdGetRuleIds(origUser, user, origCal, calId, i, count, ACLScopeEntity)
    if jcount == 0:
      continue
    _updateDeleteCalendarACLs(cal, function, Ent.CALENDAR, calId, i, count, role, ruleIds, jcount, sendNotifications)

# gam calendar <CalendarEntity> update <CalendarACLRole> <CalendarACLScope> [sendnotifications <Boolean>]
def doCalendarsUpdateACL(calIds):
  role, ACLScopeEntity, sendNotifications = getCalendarCreateUpdateACLsOptions(False)
  _doUpdateDeleteCalendarACLs(None, None, None, 'patch', calIds, len(calIds), ACLScopeEntity, role, sendNotifications)

# gam calendars <CalendarEntity> update acls <CalendarACLRole> <CalendarACLScopeEntity> [sendnotifications <Boolean>]
def doCalendarsUpdateACLs(calIds):
  role, ACLScopeEntity, sendNotifications = getCalendarCreateUpdateACLsOptions(True)
  _doUpdateDeleteCalendarACLs(None, None, None, 'patch', calIds, len(calIds), ACLScopeEntity, role, sendNotifications)

# gam calendar <CalendarEntity> delete [<CalendarACLRole>] <CalendarACLScope>
def doCalendarsDeleteACL(calIds):
  role, ACLScopeEntity = getCalendarDeleteACLsOptions(False)
  _doUpdateDeleteCalendarACLs(None, None, None, 'delete', calIds, len(calIds), ACLScopeEntity, role, False)

# gam calendars <CalendarEntity> delete acls <CalendarACLScopeEntity>
def doCalendarsDeleteACLs(calIds):
  role, ACLScopeEntity = getCalendarDeleteACLsOptions(True)
  _doUpdateDeleteCalendarACLs(None, None, None, 'delete', calIds, len(calIds), ACLScopeEntity, role, False)

def _showCalendarACL(user, entityType, calId, acl, k, kcount, FJQC):
  if FJQC.formatJSON:
    if entityType == Ent.CALENDAR:
      if user:
        printLine(json.dumps(cleanJSON({'primaryEmail': user, 'calendarId': calId, 'acl': acl}),
                             ensure_ascii=False, sort_keys=True))
      else:
        printLine(json.dumps(cleanJSON({'calendarId': calId, 'acl': acl}),
                             ensure_ascii=False, sort_keys=True))
    else:
      printLine(json.dumps(cleanJSON({'resourceId': user, 'resourceEmail': calId, 'acl': acl}),
                           ensure_ascii=False, sort_keys=True))
  else:
    printKeyValueListWithCount(ACLRuleKeyValueList(acl), k, kcount)

def _infoCalendarACLs(cal, user, entityType, calId, j, jcount, ruleIds, kcount, FJQC):
  Ind.Increment()
  k = 0
  for ruleId in ruleIds:
    k += 1
    ruleId = normalizeRuleId(ruleId)
    try:
      result = callGAPI(cal.acl(), 'get',
                        throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.INVALID_SCOPE_VALUE, GAPI.FORBIDDEN, GAPI.AUTH_ERROR],
                        calendarId=calId, ruleId=ruleId, fields='id,role,scope')
      _showCalendarACL(user, entityType, calId, result, k, kcount, FJQC)
    except (GAPI.notFound, GAPI.invalid) as e:
      if not checkCalendarExists(cal, calId, j, jcount):
        entityUnknownWarning(entityType, calId, j, jcount)
        break
      entityActionFailedWarning([entityType, calId, Ent.CALENDAR_ACL, formatACLScopeRole(ruleId, None)], str(e), k, kcount)
    except (GAPI.invalidScopeValue, GAPI.forbidden, GAPI.authError) as e:
      entityActionFailedWarning([entityType, calId, Ent.CALENDAR_ACL, formatACLScopeRole(ruleId, None)], str(e), k, kcount)
  Ind.Decrement()

def _doInfoCalendarACLs(origUser, user, origCal, calIds, count, ACLScopeEntity, FJQC):
  i = 0
  for calId in calIds:
    i += 1
    calId, cal, ruleIds, jcount = _normalizeCalIdGetRuleIds(origUser, user, origCal, calId, i, count, ACLScopeEntity, showAction=not FJQC.formatJSON)
    if jcount == 0:
      continue
    _infoCalendarACLs(cal, user, Ent.CALENDAR, calId, i, count, ruleIds, jcount, FJQC)

def _getCalendarInfoACLOptions():
  return FormatJSONQuoteChar(formatJSONOnly=True)

# gam calendars <CalendarEntity> info acl|acls <CalendarACLScopeEntity>
#	[formatjson]
def doCalendarsInfoACLs(calIds):
  ACLScopeEntity = getCalendarSiteACLScopeEntity()
  FJQC = _getCalendarInfoACLOptions()
  _doInfoCalendarACLs(None, None, None, calIds, len(calIds), ACLScopeEntity, FJQC)

def _printShowCalendarACLs(cal, user, entityType, calId, i, count, csvPF, FJQC, noSelfOwner, addCSVData):
  if csvPF:
    printGettingEntityItemForWhom(Ent.CALENDAR_ACL, calId, i, count)
  try:
    acls = callGAPIpages(cal.acl(), 'list', 'items',
                         throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.AUTH_ERROR],
                         calendarId=calId, fields='nextPageToken,items(id,role,scope)')
  except (GAPI.forbidden, GAPI.authError) as e:
    entityActionFailedWarning([entityType, calId], str(e), i, count)
    return
  except GAPI.notFound:
    entityUnknownWarning(entityType, calId, i, count)
    return
  jcount = len(acls)
  if jcount == 0:
    setSysExitRC(NO_ENTITIES_FOUND_RC)
  if not csvPF:
    if not FJQC.formatJSON:
      if not noSelfOwner:
        entityPerformActionNumItems([entityType, calId], jcount, Ent.CALENDAR_ACL, i, count)
      else:
        entityPerformActionModifierNumItems([entityType, calId], Msg.MAXIMUM_OF, jcount, Ent.CALENDAR_ACL, i, count)
    Ind.Increment()
    j = 0
    for rule in acls:
      j += 1
      if noSelfOwner and rule['role'] == 'owner' and rule['scope']['value'] == calId:
        continue
      _showCalendarACL(user, entityType, calId, rule, j, jcount, FJQC)
    Ind.Decrement()
  else:
    if entityType == Ent.CALENDAR:
      if acls:
        for rule in acls:
          if noSelfOwner and rule['role'] == 'owner' and rule['scope']['value'] == calId:
            continue
          row = {'calendarId': calId}
          if user:
            row['primaryEmail'] = user
          if addCSVData:
            row.update(addCSVData)
          flattenJSON(rule, flattened=row)
          if not FJQC.formatJSON:
            csvPF.WriteRowTitles(row)
          elif csvPF.CheckRowTitles(row):
            row = {'calendarId': calId, 'JSON': json.dumps(cleanJSON(rule),
                                                           ensure_ascii=False, sort_keys=False)}
            if user:
              row['primaryEmail'] = user
            if addCSVData:
              row.update(addCSVData)
            csvPF.WriteRowNoFilter(row)
      elif GC.Values[GC.CSV_OUTPUT_USERS_AUDIT] and user:
        csvPF.WriteRowNoFilter({'calendarId': calId, 'primaryEmail': user})
    else: # Ent.RESOURCE_CALENDAR
      for rule in acls:
        if noSelfOwner and rule['role'] == 'owner' and rule['scope']['value'] == calId:
          continue
        row = {'resourceId': user, 'resourceEmail': calId}
        if addCSVData:
          row.update(addCSVData)
        flattenJSON(rule, flattened=row)
        if not FJQC.formatJSON:
          csvPF.WriteRowTitles(row)
        elif csvPF.CheckRowTitles(row):
          row = {'resourceId': user, 'resourceEmail': calId, 'JSON': json.dumps(cleanJSON(rule),
                                                                                ensure_ascii=False, sort_keys=False)}
          if addCSVData:
            row.update(addCSVData)
          csvPF.WriteRowNoFilter(row)

def _getCalendarPrintShowACLOptions(titles):
  csvPF = CSVPrintFile(titles, 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  noSelfOwner = False
  addCSVData = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'noselfowner':
      noSelfOwner = True
    elif csvPF and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if csvPF:
    if addCSVData:
      csvPF.AddTitles(sorted(addCSVData.keys()))
      if FJQC.formatJSON:
        csvPF.AddJSONTitles(sorted(addCSVData.keys()))
        csvPF.MoveJSONTitlesToEnd(['JSON'])
    csvPF.SetSortAllTitles()
  return (csvPF, FJQC, noSelfOwner, addCSVData)

# gam calendars <CalendarEntity> print acls [todrive <ToDriveAttribute>*]
#	[noselfowner] (addcsvdata <FieldName> <String>)*
#	[formatjson [quotechar <Character>]]
# gam calendars <CalendarEntity> show acls
#	[noselfowner]
#	[formatjson]
# gam calendar <CalendarEntity> printacl [todrive <ToDriveAttribute>*]
#	[noselfowner] (addcsvdata <FieldName> <String>)*
#	[formatjson]
# gam calendar <CalendarEntity> showacl
#	[noselfowner]
#	[formatjson]
def doCalendarsPrintShowACLs(calIds):
  csvPF, FJQC, noSelfOwner, addCSVData = _getCalendarPrintShowACLOptions(['calendarId'])
  count = len(calIds)
  i = 0
  for calId in calIds:
    i += 1
    calId, cal = validateCalendar(calId, i, count)
    if not cal:
      continue
    _printShowCalendarACLs(cal, None, Ent.CALENDAR, calId, i, count, csvPF, FJQC, noSelfOwner, addCSVData)
  if csvPF:
    csvPF.writeCSVfile('Calendar ACLs')

EVENT_TYPE_BIRTHDAY = 'birthday'
EVENT_TYPE_DEFAULT = 'default'
EVENT_TYPE_FOCUSTIME = 'focusTime'
EVENT_TYPE_FROMGMAIL = 'fromGmail'
EVENT_TYPE_OUTOFOFFICE = 'outOfOffice'
EVENT_TYPE_WORKINGLOCATION = 'workingLocation'

EVENT_TYPES_CHOICE_MAP = {
  'birthday': EVENT_TYPE_BIRTHDAY,
  'default': EVENT_TYPE_DEFAULT,
  'focustime': EVENT_TYPE_FOCUSTIME,
  'fromgmail': EVENT_TYPE_FROMGMAIL,
  'outofoffice': EVENT_TYPE_OUTOFOFFICE,
  'workinglocation': EVENT_TYPE_WORKINGLOCATION,
  }

EVENT_TYPE_PROPERTIES_NAME_MAP = {
  EVENT_TYPE_DEFAULT: None,
  EVENT_TYPE_FOCUSTIME: f'{EVENT_TYPE_FOCUSTIME}Properties',
  EVENT_TYPE_FROMGMAIL: None,
  EVENT_TYPE_OUTOFOFFICE: f'{EVENT_TYPE_OUTOFOFFICE}Properties',
  EVENT_TYPE_WORKINGLOCATION: f'{EVENT_TYPE_WORKINGLOCATION}Properties',
  }

EVENT_TYPE_ENTITY_MAP = {
  EVENT_TYPE_BIRTHDAY: Ent.EVENT_BIRTHDAY,
  EVENT_TYPE_DEFAULT: None,
  EVENT_TYPE_FOCUSTIME: Ent.EVENT_FOCUSTIME,
  EVENT_TYPE_FROMGMAIL: None,
  EVENT_TYPE_OUTOFOFFICE: Ent.EVENT_OUTOFOFFICE,
  EVENT_TYPE_WORKINGLOCATION: Ent.EVENT_WORKINGLOCATION,
  }

def _getEventTypes():
  typesList = []
  for field in _getFieldsList():
    if field in EVENT_TYPES_CHOICE_MAP:
      addFieldToFieldsList(field, EVENT_TYPES_CHOICE_MAP, typesList)
    else:
      invalidChoiceExit(field, EVENT_TYPES_CHOICE_MAP, True)
#  return ','.join(typesList)
  return typesList

LIST_EVENTS_DISPLAY_PROPERTIES = {
  'alwaysincludeemail': ('alwaysIncludeEmail', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}),
  'icaluid': ('iCalUID', {GC.VAR_TYPE: GC.TYPE_STRING}),
  'maxattendees': ('maxAttendees', {GC.VAR_TYPE: GC.TYPE_INTEGER, GC.VAR_LIMITS: (1, None)}),
  'orderby': ('orderBy', {GC.VAR_TYPE: GC.TYPE_CHOICE, 'choices': {'starttime': 'startTime', 'updated': 'updated'}}),
  'timezone': ('timeZone', {GC.VAR_TYPE: GC.TYPE_STRING}),
  }

LIST_EVENTS_SELECT_PROPERTIES = {
  'after': ('timeMin', {GC.VAR_TYPE: GC.TYPE_DATETIME}),
  'before': ('timeMax', {GC.VAR_TYPE: GC.TYPE_DATETIME}),
  'endtime': ('timeMax', {GC.VAR_TYPE: GC.TYPE_DATETIME}),
  'includedeleted': ('showDeleted', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}),
  'includehidden': ('showHiddenInvitations', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}),
  'privateextendedproperty': ('privateExtendedProperty', {GC.VAR_TYPE: GC.TYPE_STRING}),
  'sharedextendedproperty': ('sharedExtendedProperty', {GC.VAR_TYPE: GC.TYPE_STRING}),
  'showdeletedevents': ('showDeleted', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}),
  'showhiddeninvitations': ('showHiddenInvitations', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}),
  'singleevents': ('singleEvents', {GC.VAR_TYPE: GC.TYPE_BOOLEAN}),
  'starttime': ('timeMin', {GC.VAR_TYPE: GC.TYPE_DATETIME}),
  'timemax': ('timeMax', {GC.VAR_TYPE: GC.TYPE_DATETIME}),
  'timemin': ('timeMin', {GC.VAR_TYPE: GC.TYPE_DATETIME}),
  'updated': ('updatedMin', {GC.VAR_TYPE: GC.TYPE_DATETIME}),
  'updatedmin': ('updatedMin', {GC.VAR_TYPE: GC.TYPE_DATETIME}),
  'eventtype': ('eventTypes', {GC.VAR_TYPE: GC.TYPE_CHOICE_LIST}),
  'eventtypes': ('eventTypes', {GC.VAR_TYPE: GC.TYPE_CHOICE_LIST}),
  }

LIST_EVENTS_MATCH_FIELDS = {
  'attendees': ['attendees', 'email'],
  'attendeesonlydomainlist': ['attendees', 'onlydomainlist'],
  'attendeesdomainlist': ['attendees', 'domainlist'],
  'attendeesnotdomainlist': ['attendees', 'notdomainlist'],
  'attendeespattern': ['attendees', 'match'],
  'attendeesstatus': ['attendees', 'status'],
  'description': ['description'],
  'hangoutlink': ['hangoutLink'],
  'location': ['location'],
  'summary': ['summary'],
  'creatorname': ['creator', 'displayName'],
  'creatoremail': ['creator', 'email'],
  'organizername': ['organizer', 'displayName'],
  'organizeremail': ['organizer', 'email'],
  'organizerself': ['organizer', 'self'],
  'organisername': ['organizer', 'displayName'],
  'organiseremail': ['organizer', 'email'],
  'organiserself': ['organizer', 'self'],
  'status': ['status'],
  'transparency': ['transparency'],
  'visibility': ['visibility'],
  }

def _getCalendarListEventsProperty(myarg, attributes, kwargs):
  attrName, attribute = attributes.get(myarg, (None, None))
  if not attrName:
    return False
  attrType = attribute[GC.VAR_TYPE]
  if attrType == GC.TYPE_BOOLEAN:
    kwargs[attrName] = True
  elif attrType == GC.TYPE_STRING:
    kwargs[attrName] = getString(Cmd.OB_STRING)
  elif attrType == GC.TYPE_CHOICE:
    kwargs[attrName] = getChoice(attribute['choices'], mapChoice=True)
  elif attrType == GC.TYPE_DATETIME:
    kwargs[attrName] = getTimeOrDeltaFromNow()
  elif attrType ==  GC.TYPE_INTEGER:
    minVal, maxVal = attribute[GC.VAR_LIMITS]
    kwargs[attrName] = getInteger(minVal=minVal, maxVal=maxVal)
  else: # elif attrType == GC.TYPE_CHOICE_LIST:
    if attrName == 'eventTypes':
      kwargs[attrName] = _getEventTypes()
  return True

def _getCalendarListEventsDisplayProperty(myarg, calendarEventEntity):
  return _getCalendarListEventsProperty(myarg, LIST_EVENTS_DISPLAY_PROPERTIES, calendarEventEntity['kwargs'])

def initCalendarEventEntity():
  return {'list': [], 'queries': [], 'kwargs': {}, 'dict': None,
          'matches': [], 'maxinstances': -1, 'showDayOfWeek': False,
          'countsOnly': False, 'eventRowFilter': False, 'countsOnlyTitles': []}

def getCalendarEventEntity():
  calendarEventEntity = initCalendarEventEntity()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'event', 'events'}:
      entitySelector = getEntitySelector()
      if entitySelector:
        entityList = getEntitySelection(entitySelector, False)
        if isinstance(entityList, dict):
          calendarEventEntity['dict'] = entityList
        else:
          calendarEventEntity['list'] = entityList
      else:
        calendarEventEntity['list'].extend(convertEntityToList(getString(Cmd.OB_EVENT_ID)))
    elif myarg in {'id', 'eventid'}:
      calendarEventEntity['list'].append(getString(Cmd.OB_EVENT_ID))
    elif myarg in {'q', 'query', 'eventquery'}:
      calendarEventEntity['queries'].append(getString(Cmd.OB_QUERY))
    elif myarg == 'matchfield':
      matchField = getChoice(LIST_EVENTS_MATCH_FIELDS, mapChoice=True)
      if matchField[0] == 'organizer' and matchField[1] == 'self':
        calendarEventEntity['matches'].append((matchField, getBoolean()))
      elif matchField[0] != 'attendees' or matchField[1] == 'match':
        calendarEventEntity['matches'].append((matchField, getREPattern(re.IGNORECASE)))
      elif matchField[0] == 'attendees' and matchField[1] in {'onlydomainlist', 'domainlist', 'notdomainlist'}:
        calendarEventEntity['matches'].append((matchField, set(getString(Cmd.OB_DOMAIN_NAME_LIST).replace(',', ' ').split())))
      elif matchField[1] == 'email':
        calendarEventEntity['matches'].append((matchField, getNormalizedEmailAddressEntity()))
      else: #status
        calendarEventEntity['matches'].append((matchField,
                                               getChoice(CALENDAR_ATTENDEE_OPTIONAL_CHOICE_MAP, defaultChoice=False, mapChoice=True),
                                               getChoice(CALENDAR_ATTENDEE_STATUS_CHOICE_MAP, defaultChoice='needsAction', mapChoice=True),
                                               getNormalizedEmailAddressEntity()))
    elif myarg == 'maxinstances':
      calendarEventEntity['maxinstances'] = getInteger(minVal=-1)
    elif _getCalendarListEventsProperty(myarg, LIST_EVENTS_SELECT_PROPERTIES, calendarEventEntity['kwargs']):
      pass
    else:
      Cmd.Backup()
      break
  return calendarEventEntity

CALENDAR_EVENT_SENDUPDATES_CHOICE_MAP = {'all': 'all', 'externalonly': 'externalOnly', 'none': 'none'}

def _getCalendarSendUpdates(myarg, parameters):
  if myarg == 'sendnotifications':
    parameters['sendUpdates'] = 'all' if getBoolean() else 'none'
  elif myarg == 'notifyattendees':
    parameters['sendUpdates'] = 'all'
  elif myarg == 'sendupdates':
    parameters['sendUpdates'] = getChoice(CALENDAR_EVENT_SENDUPDATES_CHOICE_MAP, mapChoice=True)
  else:
    return False
  return True

CALENDAR_MIN_COLOR_INDEX = 1
CALENDAR_MAX_COLOR_INDEX = 24

CALENDAR_EVENT_MIN_COLOR_INDEX = 1
CALENDAR_EVENT_MAX_COLOR_INDEX = 11

CALENDAR_ATTENDEE_OPTIONAL_CHOICE_MAP = {
  'optional': True,
  'required': False,
  'true': True,
  'false': False,
  '': None,
  }
CALENDAR_ATTENDEE_STATUS_CHOICE_MAP = {
  'accepted': 'accepted',
  'declined': 'declined',
  'needsaction': 'needsAction',
  'tentative': 'tentative',
  '': None,
  }
CALENDAR_EVENT_STATUS_CHOICES = ['confirmed', 'tentative', 'cancelled']
CALENDAR_EVENT_TRANSPARENCY_CHOICES = ['opaque', 'transparent']
CALENDAR_EVENT_VISIBILITY_CHOICES = ['default', 'public', 'private', 'confedential']

EVENT_JSON_CLEAR_FIELDS = ['created', 'creator', 'endTimeUpspecified', 'hangoutLink', 'htmlLink', 'eventType',
                           'privateCopy', 'locked', 'recurringEventId', 'updated']
EVENT_JSON_INSERT_CLEAR_FIELDS = ['iCalUID', 'id', 'organizer']
EVENT_JSON_UPDATE_CLEAR_FIELDS = ['iCalUID', 'id', 'organizer']
EVENT_JSON_SUBFIELD_CLEAR_FIELDS = {
  'attendees': ['id', 'organizer', 'self'],
  'attachments': ['fileId', 'iconLink', 'mimeType', 'title'],
  'organizer': ['id', 'self'],
  }
EVENT_JSONATTENDEES_SUBFIELD_CLEAR_FIELDS = {
  'attendees': ['id', 'organizer', 'self'],
  }

def _getCalendarEventAttribute(myarg, body, parameters, function):
  def clearJSONfields(body, clearFields):
    for field in clearFields:
      body.pop(field, None)

  def clearJSONsubfields(body, clearFields):
    for field, subfields in iter(clearFields.items()):
      if field in body:
        if isinstance(body[field], list):
          for item in body[field]:
            for subfield in subfields:
              item.pop(subfield, None)
        else:
          for subfield in subfields:
            body.pop(subfield, None)

  cd = None
  if function == 'insert' and myarg in {'id', 'eventid'}:
    body['id'] = getEventID()
  elif function == 'import' and myarg == 'icaluid':
    body['iCalUID'] = getString(Cmd.OB_ICALUID)
  elif myarg == 'description':
    body['description'] = getStringWithCRsNLs()
  elif function == 'update' and myarg == 'replacedescription':
    parameters['replaceDescription'].append(getREPatternSubstitution(re.IGNORECASE))
  elif myarg == 'location':
    body['location'] = getString(Cmd.OB_STRING, minLen=0)
  elif myarg == 'source':
    body['source'] = {'title': getString(Cmd.OB_STRING), 'url': getString(Cmd.OB_URL)}
  elif myarg == 'summary':
    body['summary'] = getString(Cmd.OB_STRING, minLen=0)
  elif myarg in  {'start', 'starttime'}:
    body['start'] = getEventTime()
  elif myarg in {'originalstart', 'originalstarttime'}:
    body['originalStart'] = getEventTime()
  elif myarg in {'end', 'endtime'}:
    body['end'] = getEventTime()
  elif myarg == 'allday':
    body['start'] = body['end'] = {'date': getYYYYMMDD()}
  elif myarg == 'range':
    body['start'] = {'date': getYYYYMMDD()}
    body['end'] = {'date': getYYYYMMDD()}
  elif myarg == 'timerange':
    body['start'] = {'dateTime': getTimeOrDeltaFromNow()}
    body['end'] = {'dateTime': getTimeOrDeltaFromNow()}
  elif myarg == 'birthday':
    body['eventType'] = EVENT_TYPE_BIRTHDAY
    body['visibility'] = 'private'
    body['transparency'] = 'transparent'
    bday = getYYYYMMDD(returnDateTime=True)
    body['start'] = body['end'] = {'date': bday.strftime(YYYYMMDD_FORMAT)}
    if bday.month != 2 or bday.day != 29:
      body['recurrence'] = ['RRULE:FREQ=YEARLY']
    else:
      body['recurrence'] = ['RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1']
  elif myarg == 'attachment':
    body.setdefault('attachments', [])
    body['attachments'].append({'title': getString(Cmd.OB_STRING), 'fileUrl': getString(Cmd.OB_URL)})
  elif function == 'update' and myarg == 'clearattachments':
    body['attachments'] = []
  elif myarg in {'hangoutsmeet', 'googlemeet'}:
    body['conferenceData'] = {'createRequest': {'conferenceSolutionKey': {'type': 'hangoutsMeet'}, 'requestId': f'{str(uuid.uuid4())}'}}
  elif function == 'update' and myarg in {'clearhangoutsmeet', 'cleargooglemeet'}:
    body['conferenceData'] = None
  elif myarg == 'recurrence':
    body.setdefault('recurrence', [])
    body['recurrence'].append(getString(Cmd.OB_RECURRENCE))
  elif myarg == 'timezone':
    parameters['timeZone'] = getString(Cmd.OB_STRING)
  elif function == 'update' and myarg == 'replacemode':
    parameters['replaceMode'] = True
  elif function == 'update' and myarg == 'clearattendees':
    parameters['clearAttendees'] = True
  elif function == 'update' and myarg == 'removeattendee':
    parameters['removeAttendees'].add(getEmailAddress(noUid=True))
  elif function == 'update' and myarg == 'selectremoveattendees':
    _, attendeeList = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)
    for attendee in attendeeList:
      parameters['removeAttendees'].add(normalizeEmailAddressOrUID(attendee, noUid=True))
  elif myarg == 'attendee':
    parameters['attendees'].append({'email': getEmailAddress(noUid=True)})
  elif myarg == 'optionalattendee':
    parameters['attendees'].append({'email': getEmailAddress(noUid=True), 'optional': True})
  elif myarg in {'attendeestatus', 'selectattendees'}:
    optional = getChoice(CALENDAR_ATTENDEE_OPTIONAL_CHOICE_MAP, defaultChoice=None, mapChoice=True)
    responseStatus = getChoice(CALENDAR_ATTENDEE_STATUS_CHOICE_MAP, defaultChoice=None, mapChoice=True)
    if myarg == 'attendeestatus':
      attendeeList = [getEmailAddress(noUid=True)]
    else:
      _, attendeeList = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)
    for attendee in attendeeList:
      addAttendee = {'email': normalizeEmailAddressOrUID(attendee, noUid=True)}
      if optional is not None:
        addAttendee['optional'] = optional
      if responseStatus is not None:
        addAttendee['responseStatus'] = responseStatus
      parameters['attendees'].append(addAttendee)
  elif function == 'update' and myarg == 'clearresources':
    parameters['clearResources'] = True
  elif myarg == 'resource':
    if cd is None:
      cd = buildGAPIObject(API.DIRECTORY)
    parameters['attendees'].append({'email': _validateResourceId(cd, getString(Cmd.OB_RESOURCE_ID), 0, 0, True),
                                    'responseStatus': 'accepted', 'resource': True})
  elif myarg == 'removeresource':
    if cd is None:
      cd = buildGAPIObject(API.DIRECTORY)
    parameters['removeAttendees'].add(_validateResourceId(cd, getString(Cmd.OB_RESOURCE_ID), 0, 0, True))
  elif myarg == 'json':
    jsonData = getJSON(EVENT_JSON_CLEAR_FIELDS)
    if function == 'insert':
      body.update(jsonData)
      clearJSONfields(body, EVENT_JSON_INSERT_CLEAR_FIELDS)
    elif function == 'import':
      if 'id' in jsonData:
        jsonData['iCalUID'] = jsonData.pop('id')
      body.update(jsonData)
    elif function == 'update':
      if 'event' in jsonData and 'attendees' in jsonData['event']:
        parameters['attendees'].extend(jsonData['event'].pop('attendees'))
        clearJSONsubfields(parameters, EVENT_JSONATTENDEES_SUBFIELD_CLEAR_FIELDS)
      body.update(jsonData)
      clearJSONfields(body, EVENT_JSON_UPDATE_CLEAR_FIELDS)
    clearJSONsubfields(body, EVENT_JSON_SUBFIELD_CLEAR_FIELDS)
    if ('conferenceData' in body and body['conferenceData'] and
        'createRequest' in body['conferenceData'] and
        'status' in body['conferenceData']['createRequest']):
      body['conferenceData']['createRequest']['status'].pop('statusCode', None)
  elif myarg == 'jsonattendees':
    jsonData = getJSON([])
    if 'event' in jsonData and 'attendees' in jsonData['event']:
      parameters['attendees'].extend(jsonData['event']['attendees'])
    elif 'attendees' in jsonData:
      parameters['attendees'].extend(jsonData['attendees'])
    clearJSONsubfields(parameters, EVENT_JSONATTENDEES_SUBFIELD_CLEAR_FIELDS)
  elif function != 'import' and _getCalendarSendUpdates(myarg, parameters):
    pass
  elif myarg == 'anyonecanaddself':
    body['anyoneCanAddSelf'] = getBoolean()
  elif myarg == 'guestscaninviteothers':
    body['guestsCanInviteOthers'] = getBoolean()
  elif myarg == 'guestscantinviteothers':
    body['guestsCanInviteOthers'] = False
  elif myarg == 'guestscanmodify':
    body['guestsCanModify'] = getBoolean()
  elif myarg == 'guestscanseeotherguests':
    body['guestsCanSeeOtherGuests'] = getBoolean()
  elif myarg == 'guestscantseeotherguests':
    body['guestsCanSeeOtherGuests'] = False
  elif myarg == 'status':
    body['status'] = getChoice(CALENDAR_EVENT_STATUS_CHOICES)
  elif myarg == 'tentative':
    body['status'] = 'tentative'
  elif myarg == 'transparency':
    body['transparency'] = getChoice(CALENDAR_EVENT_TRANSPARENCY_CHOICES)
  elif myarg == 'available':
    body['transparency'] = 'transparent'
  elif myarg == 'visibility':
    body['visibility'] = getChoice(CALENDAR_EVENT_VISIBILITY_CHOICES)
  elif myarg in {'color', 'colour'}:
    body['colorId'] = getChoice(CALENDAR_EVENT_COLOR_MAP, mapChoice=True)
  elif myarg in {'colorindex', 'colorid', 'colourindex', 'colourid'}:
    body['colorId'] = getInteger(CALENDAR_EVENT_MIN_COLOR_INDEX, CALENDAR_EVENT_MAX_COLOR_INDEX)
  elif myarg == 'noreminders':
    body['reminders'] = {'overrides': [], 'useDefault': False}
  elif myarg == 'reminder':
    body.setdefault('reminders', {'overrides': [], 'useDefault': False})
    body['reminders']['overrides'].append(getCalendarReminder())
    body['reminders']['useDefault'] = False
  elif myarg == 'sequence':
    body['sequence'] = getInteger(minVal=0)
  elif myarg == 'privateproperty':
    body.setdefault('extendedProperties', {})
    body['extendedProperties'].setdefault('private', {})
    key = getString(Cmd.OB_PROPERTY_KEY)
    body['extendedProperties']['private'][key] = getString(Cmd.OB_PROPERTY_VALUE, minLen=0)
  elif myarg == 'sharedproperty':
    body.setdefault('extendedProperties', {})
    body['extendedProperties'].setdefault('shared', {})
    key = getString(Cmd.OB_PROPERTY_KEY)
    body['extendedProperties']['shared'][key] = getString(Cmd.OB_PROPERTY_VALUE, minLen=0)
  elif function == 'update' and myarg == 'clearprivateproperty':
    body.setdefault('extendedProperties', {})
    body['extendedProperties'].setdefault('private', {})
    body['extendedProperties']['private'][getString(Cmd.OB_PROPERTY_KEY)] = None
  elif function == 'update' and myarg == 'clearsharedproperty':
    body.setdefault('extendedProperties', {})
    body['extendedProperties'].setdefault('shared', {})
    body['extendedProperties']['shared'][getString(Cmd.OB_PROPERTY_KEY)] = None
  elif function == 'import' and myarg in {'organizername', 'organisername'}:
    body.setdefault('organizer', {})
    body['organizer']['displayName'] = getString(Cmd.OB_NAME)
  elif function == 'import' and myarg in {'organizeremail', 'organiseremail'}:
    body.setdefault('organizer', {})
    body['organizer']['email'] = getEmailAddress(noUid=True)
  else:
    return False
  return True

def _getEventMatchFields(calendarEventEntity, fieldsList):
  for match in calendarEventEntity['matches']:
    if match[0][0] != 'attendees':
      fieldsList.append(match[0][0])
    else:
      fieldsList.append('attendees/email')
      if match[0][1] == 'status':
        fieldsList.extend(['attendees/optional', 'attendees/responseStatus'])

def _eventMatches(event, match):
  if match[0][0] != 'attendees':
    eventAttr = event
    for attr in match[0]:
      eventAttr = eventAttr.get(attr, '')
      if not eventAttr:
        break
    if match[0][0] == 'organizer' and match[0][1] == 'self':
      return bool(eventAttr) == match[1]
    if match[0][0] != 'hangoutLink':
      return match[1].search(eventAttr) is not None
# vkj-przn-nvg or vkjprznnvg
    return match[1].search(eventAttr) is not None or match[1].search(eventAttr.replace('-', '')) is not None
  attendees = [attendee['email'] for attendee in event.get('attendees', []) if 'email' in attendee]
  if not attendees:
    return False
  if match[0][1] == 'email':
    for attendee in match[1]:
      if attendee not in attendees:
        return False
    return True
  if match[0][1] == 'match':
    for attendee in attendees:
      if match[1].search(attendee) is not None:
        return True
    return False
  if match[0][1] == 'onlydomainlist':
    for attendee in attendees:
      _, domain = attendee.lower().split('@', 1)
      if domain not in match[1]:
        return False
    return True
  if match[0][1] == 'domainlist':
    for attendee in attendees:
      _, domain = attendee.lower().split('@', 1)
      if domain in match[1]:
        return True
    return False
  if match[0][1] == 'notdomainlist':
    for attendee in attendees:
      _, domain = attendee.lower().split('@', 1)
      if domain not in match[1]:
        return True
    return False
  # status
  for matchEmail in match[3]:
    for attendee in event['attendees']:
      if 'email' in attendee and matchEmail == attendee['email']:
        if attendee.get('optional', False) != match[1] or attendee.get('responseStatus') != match[2]:
          return False
        break
    else:
      return False
  return True

def _validateCalendarGetEventIDs(origUser, user, origCal, calId, j, jcount, calendarEventEntity, doIt=True, showAction=True):
  if calendarEventEntity['dict']:
    if origUser:
      if not GM.Globals[GM.CSV_SUBKEY_FIELD]:
        calEventIds = calendarEventEntity['dict'][calId]
      else:
        calEventIds = calendarEventEntity['dict'][origUser][calId]
    else:
      calEventIds = calendarEventEntity['dict'][calId]
  else:
    calEventIds = calendarEventEntity['list']
  calId, cal = getNormalizedCalIdCal(origCal, calId, user, j, jcount)
  if not cal:
    return (calId, cal, None, 0)
  if not calEventIds:
    fieldsList = ['id']
    _getEventMatchFields(calendarEventEntity, fieldsList)
    fields = ','.join(fieldsList)
    try:
      eventIdsSet = set()
      calEventIds = []
      if len(calendarEventEntity['queries']) <= 1:
        if len(calendarEventEntity['queries']) == 1:
          calendarEventEntity['kwargs']['q'] = calendarEventEntity['queries'][0]
        events = callGAPIpages(cal.events(), 'list', 'items',
                               throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID],
                               calendarId=calId, fields=f'nextPageToken,items({fields})',
                               maxResults=GC.Values[GC.EVENT_MAX_RESULTS], **calendarEventEntity['kwargs'])
        for event in events:
          for match in calendarEventEntity['matches']:
            if not _eventMatches(event, match):
              break
          else:
            calEventIds.append(event['id'])
      else:
        for query in calendarEventEntity['queries']:
          calendarEventEntity['kwargs']['q'] = query
          events = callGAPIpages(cal.events(), 'list', 'items',
                                 throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID],
                                 calendarId=calId, fields=f'nextPageToken,items({fields})',
                                 maxResults=GC.Values[GC.EVENT_MAX_RESULTS], **calendarEventEntity['kwargs'])
          for event in events:
            for match in calendarEventEntity['matches']:
              if not _eventMatches(event, match):
                break
            else:
              eventId = event['id']
              if eventId not in eventIdsSet:
                calEventIds.append(eventId)
                eventIdsSet.add(eventId)
      kcount = len(calEventIds)
      if kcount == 0:
        entityNumEntitiesActionNotPerformedWarning([Ent.CALENDAR, calId], Ent.EVENT, kcount, Msg.NO_ENTITIES_MATCHED.format(Ent.Plural(Ent.EVENT)), j, jcount)
        setSysExitRC(NO_ENTITIES_FOUND_RC)
        return (calId, cal, None, 0)
    except GAPI.notFound:
      entityUnknownWarning(Ent.CALENDAR, calId, j, jcount)
      return (calId, cal, None, 0)
    except (GAPI.forbidden, GAPI.invalid) as e:
      entityActionFailedWarning([Ent.CALENDAR, calId], str(e), j, jcount)
      return (calId, cal, None, 0)
    except GAPI.notACalendarUser:
      userCalServiceNotEnabledWarning(calId, j, jcount)
      return (calId, cal, None, 0)
  else:
    kcount = len(calEventIds)
  if kcount == 0:
    setSysExitRC(NO_ENTITIES_FOUND_RC)
  if not doIt:
    if showAction:
      entityNumEntitiesActionNotPerformedWarning([Ent.CALENDAR, calId], Ent.EVENT, kcount, Msg.USE_DOIT_ARGUMENT_TO_PERFORM_ACTION, j, jcount)
    return (calId, cal, None, 0)
  if showAction:
    entityPerformActionNumItems([Ent.CALENDAR, calId], kcount, Ent.EVENT, j, jcount)
  return (calId, cal, calEventIds, kcount)

def _validateCalendarGetEvents(origUser, user, origCal, calId, j, jcount, calendarEventEntity,
                               fieldsList, showAction):
  if calendarEventEntity['dict']:
    if origUser:
      if not GM.Globals[GM.CSV_SUBKEY_FIELD]:
        calEventIds = calendarEventEntity['dict'][calId]
      else:
        calEventIds = calendarEventEntity['dict'][origUser][calId]
    else:
      calEventIds = calendarEventEntity['dict'][calId]
  else:
    calEventIds = calendarEventEntity['list']
  calId, cal = getNormalizedCalIdCal(origCal, calId, user, j, jcount)
  if not cal:
    return (calId, cal, [], 0)
  eventIdsSet = set()
  eventsList = []
  fields = getFieldsFromFieldsList(fieldsList)
  ifields = getItemFieldsFromFieldsList('items', fieldsList)
  try:
    if not calEventIds:
      if len(calendarEventEntity['queries']) <= 1:
        if len(calendarEventEntity['queries']) == 1:
          calendarEventEntity['kwargs']['q'] = calendarEventEntity['queries'][0]
        events = callGAPIpages(cal.events(), 'list', 'items',
                               throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID],
                               calendarId=calId, fields=ifields,
                               maxResults=GC.Values[GC.EVENT_MAX_RESULTS], **calendarEventEntity['kwargs'])
        for event in events:
          for match in calendarEventEntity['matches']:
            if not _eventMatches(event, match):
              break
          else:
            eventsList.append(event)
      else:
        for query in calendarEventEntity['queries']:
          calendarEventEntity['kwargs']['q'] = query
          events = callGAPIpages(cal.events(), 'list', 'items',
                                 throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID],
                                 calendarId=calId, fields=ifields,
                                 maxResults=GC.Values[GC.EVENT_MAX_RESULTS], **calendarEventEntity['kwargs'])
          for event in events:
            for match in calendarEventEntity['matches']:
              if not _eventMatches(event, match):
                break
            else:
              eventId = event['id']
              if eventId not in eventIdsSet:
                eventsList.append(event)
                eventIdsSet.add(eventId)
    else:
      k = 0
      for eventId in calEventIds:
        k += 1
        if eventId not in eventIdsSet:
          eventsList.append(callGAPI(cal.events(), 'get',
                                     throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.DELETED, GAPI.FORBIDDEN],
                                     calendarId=calId, eventId=eventId, fields=fields))
          eventIdsSet.add(eventId)
    kcount = len(eventsList)
    if showAction:
      entityPerformActionNumItems([Ent.CALENDAR, calId], kcount, Ent.EVENT, j, jcount)
    if kcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
    return (calId, cal, eventsList, kcount)
  except (GAPI.notFound, GAPI.deleted) as e:
    if not checkCalendarExists(cal, calId, j, jcount):
      entityUnknownWarning(Ent.CALENDAR, calId, j, jcount)
    else:
      entityActionFailedWarning([Ent.CALENDAR, calId, Ent.EVENT, eventId], str(e), j, jcount)
  except (GAPI.forbidden, GAPI.invalid) as e:
    entityActionFailedWarning([Ent.CALENDAR, calId], str(e), j, jcount)
  except GAPI.notACalendarUser:
    userCalServiceNotEnabledWarning(calId, j, jcount)
  return (calId, cal, [], 0)

def _getCalendarCreateImportUpdateEventOptions(function, entityType):
  body = {}
  parameters = {'clearAttendees': False, 'replaceMode': False, 'clearResources': False,
                'attendees': [], 'removeAttendees': set(),
                'replaceDescription': [], 'sendUpdates': 'none',
                'csvPF': None, 'FJQC': FormatJSONQuoteChar(None), 'showDayOfWeek': False}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'csv':
      parameters['csvPF'] = CSVPrintFile(['primaryEmail', 'calendarId', 'id'] if entityType == Ent.USER else ['calendarId', 'id'], 'sortall', indexedTitles=EVENT_INDEXED_TITLES)
      parameters['FJQC'].SetCsvPF(parameters['csvPF'])
    elif parameters['csvPF'] and myarg == 'todrive':
      parameters['csvPF'].GetTodriveParameters()
    elif myarg == 'showdayofweek':
      parameters['showDayOfWeek'] = True
    elif _getCalendarEventAttribute(myarg, body, parameters, function):
      pass
    else:
      parameters['FJQC'].GetFormatJSONQuoteChar(myarg, True)
  return (body, parameters)

def _setEventRecurrenceTimeZone(cal, calId, body, parameters, i, count):
  if ('recurrence' in body) and (('start' in body) or ('end' in body)):
    timeZone = parameters.get('timeZone')
    if not timeZone:
      try:
        timeZone = callGAPI(cal.calendars(), 'get',
                            throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID],
                            calendarId=calId, fields='timeZone')['timeZone']
      except (GAPI.notFound, GAPI.forbidden, GAPI.invalid) as e:
        entityActionFailedWarning([Ent.CALENDAR, calId], str(e), i, count)
        return False
      except GAPI.notACalendarUser:
        userCalServiceNotEnabledWarning(calId, i, count)
        return False
    if 'start' in body:
      body['start']['timeZone'] = timeZone
    if 'end' in body:
      body['end']['timeZone'] = timeZone
  return True

def _getEventDaysOfWeek(event):
  for attr in ['start', 'end']:
    if attr in event:
      if 'date' in event[attr]:
        try:
          dateTime = datetime.datetime.strptime(event[attr]['date'], YYYYMMDD_FORMAT)
          event[attr]['dayOfWeek'] = calendarlib.day_abbr[dateTime.weekday()]
        except ValueError:
          pass
      elif 'dateTime' in event[attr]:
        try:
          dateTime, _ = iso8601.parse_date(event[attr]['dateTime'])
          event[attr]['dayOfWeek'] = calendarlib.day_abbr[dateTime.weekday()]
        except (iso8601.ParseError, OverflowError):
          pass

def _createCalendarEvents(user, origCal, function, calIds, count, body, parameters):
  if parameters['attendees']:
    body['attendees'] = parameters.pop('attendees')
  fields = 'id' if parameters['csvPF'] is None else '*'
  i = 0
  for calId in calIds:
    i += 1
    calId, cal = getNormalizedCalIdCal(origCal, calId, user, i, count)
    if not cal:
      continue
    if not _setEventRecurrenceTimeZone(cal, calId, body, parameters, i, count):
      continue
    event = {'id': body.get('id', UNKNOWN)}
    if function == 'import' and body.get('status', '') == 'cancelled':
      entityActionNotPerformedWarning([Ent.CALENDAR, calId, Ent.EVENT, body.get('iCalUID', event['id'])], Msg.EVENT_IS_CANCELED, count)
      continue
    try:
      if function == 'insert':
        event = callGAPI(cal.events(), 'insert',
                         throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.INVALID, GAPI.REQUIRED, GAPI.TIME_RANGE_EMPTY, GAPI.EVENT_DURATION_EXCEEDS_LIMIT,
                                                                   GAPI.REQUIRED_ACCESS_LEVEL, GAPI.DUPLICATE, GAPI.FORBIDDEN,
                                                                   GAPI.MALFORMED_WORKING_LOCATION_EVENT, GAPI.BAD_REQUEST],
                         calendarId=calId, conferenceDataVersion=1, sendUpdates=parameters['sendUpdates'], supportsAttachments=True, body=body, fields=fields)
      else:
        event = callGAPI(cal.events(), 'import_',
                         throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.INVALID, GAPI.REQUIRED, GAPI.TIME_RANGE_EMPTY, GAPI.EVENT_DURATION_EXCEEDS_LIMIT,
                                                                   GAPI.REQUIRED_ACCESS_LEVEL, GAPI.DUPLICATE, GAPI.FORBIDDEN,
                                                                   GAPI.MALFORMED_WORKING_LOCATION_EVENT, GAPI.BAD_REQUEST,
                                                                   GAPI.PARTICIPANT_IS_NEITHER_ORGANIZER_NOR_ATTENDEE],
                         calendarId=calId, conferenceDataVersion=1, supportsAttachments=True, body=body, fields=fields)
      if parameters['csvPF'] is None:
        entityActionPerformed([Ent.CALENDAR, calId, Ent.EVENT, event['id']], i, count)
      else:
        if parameters['showDayOfWeek']:
          _getEventDaysOfWeek(event)
        _printCalendarEvent(user, calId, event, parameters['csvPF'], parameters['FJQC'])
    except (GAPI.invalid, GAPI.required, GAPI.timeRangeEmpty, GAPI.eventDurationExceedsLimit,
            GAPI.requiredAccessLevel, GAPI.participantIsNeitherOrganizerNorAttendee,
            GAPI.malformedWorkingLocationEvent, GAPI.badRequest) as e:
      entityActionFailedWarning([Ent.CALENDAR, calId, Ent.EVENT, event['id']], str(e), i, count)
    except GAPI.duplicate as e:
      entityActionFailedWarning([Ent.CALENDAR, calId, Ent.EVENT, event['id']], str(e), i, count)
    except GAPI.forbidden as e:
      entityActionFailedWarning([Ent.CALENDAR, calId], str(e), i, count)
      break
    except GAPI.notACalendarUser:
      userCalServiceNotEnabledWarning(calId, i, count)
      break
  if parameters['csvPF']:
    parameters['csvPF'].writeCSVfile('Calendar Created Events')

# gam calendars <CalendarEntity> create event [id <String>] <EventAddAttribute>+
#	[showdayofweek]
#	[csv [todrive <ToDriveAttribute>*] [formatjson [quotechar <Character>]]]
# gam calendar <UserItem> addevent [id <String>] <EventAddAttribute>+
#	[showdayofweek]
#	[csv [todrive <ToDriveAttribute>*] [formatjson [quotechar <Character>]]]
def doCalendarsCreateEvent(calIds):
  body, parameters = _getCalendarCreateImportUpdateEventOptions('insert', Ent.CALENDAR)
  _createCalendarEvents(None, None, 'insert', calIds, len(calIds), body, parameters)

# gam calendars <CalendarEntity> import event icaluid <iCalUID> <EventImportAttribute>+
#	[showdayofweek]
#	[csv [todrive <ToDriveAttribute>*] [formatjson [quotechar <Character>]]]
def doCalendarsImportEvent(calIds):
  body, parameters = _getCalendarCreateImportUpdateEventOptions('import', Ent.CALENDAR)
  _createCalendarEvents(None, None, 'import', calIds, len(calIds), body, parameters)

def _updateCalendarEvents(origUser, user, origCal, calIds, count, calendarEventEntity, body, parameters):
  updateFieldList = []
  if parameters['replaceDescription']:
    updateFieldList.append('description')
  if not parameters['replaceMode'] and (parameters['attendees'] or parameters['removeAttendees'] or parameters['clearResources']):
    updateFieldList.append('attendees')
  updateFields = ','.join(updateFieldList)
  if 'attendees' not in updateFieldList:
    if parameters['attendees']:
      body['attendees'] = parameters.pop('attendees')
    elif parameters['clearAttendees']:
      body['attendees'] = []
  pfields = '' if parameters['csvPF'] is None else '*'
  i = 0
  for calId in calIds:
    i += 1
    calId, cal, calEventIds, jcount = _validateCalendarGetEventIDs(origUser, user, origCal, calId, i, count, calendarEventEntity)
    if jcount == 0:
      continue
    if not _setEventRecurrenceTimeZone(cal, calId, body, parameters, i, count):
      continue
    Ind.Increment()
    j = 0
    for eventId in calEventIds:
      j += 1
      try:
        if updateFieldList:
          event = callGAPI(cal.events(), 'get',
                           throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.DELETED, GAPI.FORBIDDEN, GAPI.BACKEND_ERROR],
                           retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS+[GAPI.BACKEND_ERROR],
                           calendarId=calId, eventId=eventId, fields=updateFields)
          if 'description' in updateFieldList and 'description' in event:
            body['description'] = event['description']
            for replacement in parameters['replaceDescription']:
              body['description'] = re.sub(replacement[0], replacement[1], body['description'])
          if 'attendees' in updateFieldList:
            if not parameters['clearAttendees']:
              if 'attendees' in event:
                body['attendees'] = event['attendees']
                for addAttendee in parameters['attendees']:
                  for attendee in body['attendees']:
                    if attendee['email'].lower() == addAttendee['email']:
                      attendee.update(addAttendee)
                      break
                  else:
                    body['attendees'].append(addAttendee)
              elif parameters['attendees']:
                body['attendees'] = parameters['attendees']
              else:
                body['attendees'] = []
            elif parameters['attendees']:
              body['attendees'] = parameters['attendees']
            else:
              body['attendees'] = []
            if parameters['removeAttendees']:
              body['attendees'] = [attendee for attendee in body['attendees'] if attendee['email'].lower() not in parameters['removeAttendees']]
            if parameters['clearResources']:
              body['attendees'] = [attendee for attendee in body['attendees'] if not attendee['email'].lower().endswith('@resource.calendar.google.com')]
        event = callGAPI(cal.events(), 'patch',
                         throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.DELETED, GAPI.FORBIDDEN, GAPI.BACKEND_ERROR,
                                                                   GAPI.INVALID, GAPI.REQUIRED, GAPI.TIME_RANGE_EMPTY, GAPI.EVENT_DURATION_EXCEEDS_LIMIT,
                                                                   GAPI.REQUIRED_ACCESS_LEVEL, GAPI.CANNOT_CHANGE_ORGANIZER_OF_INSTANCE,
                                                                   GAPI.MALFORMED_WORKING_LOCATION_EVENT, GAPI.EVENT_TYPE_RESTRICTION, GAPI.BAD_REQUEST],
                         retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS+[GAPI.BACKEND_ERROR],
                         calendarId=calId, eventId=eventId, conferenceDataVersion=1, sendUpdates=parameters['sendUpdates'], supportsAttachments=True,
                         body=body, fields=pfields)
        if parameters['csvPF'] is None:
          entityActionPerformed([Ent.CALENDAR, calId, Ent.EVENT, eventId], j, jcount)
        else:
          if parameters['showDayOfWeek']:
            _getEventDaysOfWeek(event)
          _printCalendarEvent(user, calId, event, parameters['csvPF'], parameters['FJQC'])
      except (GAPI.notFound, GAPI.deleted) as e:
        if not checkCalendarExists(cal, calId, j, jcount):
          entityUnknownWarning(Ent.CALENDAR, calId, j, jcount)
          break
        entityActionFailedWarning([Ent.CALENDAR, calId, Ent.EVENT, eventId], str(e), j, jcount)
      except (GAPI.forbidden, GAPI.backendError, GAPI.invalid, GAPI.required, GAPI.timeRangeEmpty, GAPI.eventDurationExceedsLimit,
              GAPI.requiredAccessLevel, GAPI.cannotChangeOrganizerOfInstance, GAPI.malformedWorkingLocationEvent,
              GAPI.eventTypeRestriction, GAPI.badRequest) as e:
        entityActionFailedWarning([Ent.CALENDAR, calId, Ent.EVENT, eventId], str(e), j, jcount)
      except GAPI.notACalendarUser:
        userCalServiceNotEnabledWarning(calId, i, count)
        break
    Ind.Decrement()
  if parameters['csvPF']:
    parameters['csvPF'].writeCSVfile('Calendar Updated Events')

# gam calendars <CalendarEntity> update events [<EventEntity>] [replacemode] <EventUpdateAttribute>+ [<EventNotificationAttribute>]
#	[showdayofweek]
#	[csv [todrive <ToDriveAttribute>*] [formatjson [quotechar <Character>]]]
def doCalendarsUpdateEvents(calIds):
  calendarEventEntity = getCalendarEventEntity()
  body, parameters = _getCalendarCreateImportUpdateEventOptions('update', Ent.CALENDAR)
  _updateCalendarEvents(None, None, None, calIds, len(calIds), calendarEventEntity, body, parameters)

# gam calendar <CalendarEntity> updateevent <EventID> [replacemode] <EventUpdateAttribute>+ [<EventNotificationAttribute>]
#	[showdayofweek]
#	[csv [todrive <ToDriveAttribute>*] [formatjson [quotechar <Character>]]]
def doCalendarsUpdateEventsOld(calIds):
  calendarEventEntity = initCalendarEventEntity()
  calendarEventEntity['list'].append(getString(Cmd.OB_EVENT_ID))
  body, parameters = _getCalendarCreateImportUpdateEventOptions('update', Ent.CALENDAR)
  _updateCalendarEvents(None, None, None, calIds, len(calIds), calendarEventEntity, body, parameters)

def _getCalendarDeleteEventOptions(calendarEventEntity=None):
  parameters = {'sendUpdates': 'none', 'doIt': False}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if _getCalendarSendUpdates(myarg, parameters):
      pass
    elif calendarEventEntity and myarg in {'id', 'eventid'}:
      calendarEventEntity['list'].append(getString(Cmd.OB_EVENT_ID))
    elif myarg == 'doit':
      parameters['doIt'] = True
    else:
      unknownArgumentExit()
  return parameters

def _deleteCalendarEvents(origUser, user, origCal, calIds, count, calendarEventEntity, parameters):
  i = 0
  for calId in calIds:
    i += 1
    calId, cal, calEventIds, jcount = _validateCalendarGetEventIDs(origUser, user, origCal, calId, i, count, calendarEventEntity, doIt=parameters['doIt'])
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for eventId in calEventIds:
      j += 1
      try:
        callGAPI(cal.events(), 'delete',
                 throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.DELETED, GAPI.FORBIDDEN,
                                                           GAPI.INVALID, GAPI.REQUIRED, GAPI.REQUIRED_ACCESS_LEVEL],
                 calendarId=calId, eventId=eventId, sendUpdates=parameters['sendUpdates'])
        entityActionPerformed([Ent.CALENDAR, calId, Ent.EVENT, eventId], j, jcount)
      except (GAPI.notFound, GAPI.deleted) as e:
        if not checkCalendarExists(cal, calId, i, count):
          entityUnknownWarning(Ent.CALENDAR, calId, i, count)
          break
        entityActionFailedWarning([Ent.CALENDAR, calId, Ent.EVENT, eventId], str(e), j, jcount)
      except (GAPI.forbidden, GAPI.invalid, GAPI.required, GAPI.requiredAccessLevel) as e:
        entityActionFailedWarning([Ent.CALENDAR, calId, Ent.EVENT, eventId], str(e), j, jcount)
      except GAPI.notACalendarUser:
        userCalServiceNotEnabledWarning(calId, i, count)
        break
    Ind.Decrement()

# gam calendars <CalendarEntity> delete event <EventEntity> [doit] [<EventNotificationAttribute>]
def doCalendarsDeleteEvents(calIds):
  calendarEventEntity = getCalendarEventEntity()
  parameters = _getCalendarDeleteEventOptions()
  _deleteCalendarEvents(None, None, None, calIds, len(calIds), calendarEventEntity, parameters)

# gam calendar <CalendarEntity> deleteevent (id|eventid <EventID>)+ [doit] [<EventNotificationAttribute>]
def doCalendarsDeleteEventsOld(calIds):
  calendarEventEntity = initCalendarEventEntity()
  parameters = _getCalendarDeleteEventOptions(calendarEventEntity)
  _deleteCalendarEvents(None, None, None, calIds, len(calIds), calendarEventEntity, parameters)

def _getCalendarMoveEventsOptions(calendarEventEntity=None):
  parameters = {'sendUpdates': 'none'}
  newCalId = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if _getCalendarSendUpdates(myarg, parameters):
      pass
    elif calendarEventEntity and myarg in {'id', 'eventid'}:
      calendarEventEntity['list'].append(getString(Cmd.OB_EVENT_ID))
    elif calendarEventEntity and myarg == 'destination':
      newCalId = convertUIDtoEmailAddress(getString(Cmd.OB_CALENDAR_ITEM))
    else:
      unknownArgumentExit()
  return (parameters, newCalId)

def _moveCalendarEvents(origUser, user, origCal, calIds, count, calendarEventEntity, newCalId, parameters):
  i = 0
  for calId in calIds:
    i += 1
    calId, cal, calEventIds, jcount = _validateCalendarGetEventIDs(origUser, user, origCal, calId, i, count, calendarEventEntity)
    if jcount == 0:
      continue
    kvList = [Ent.USER, user] if user else []
    kvList.extend([Ent.CALENDAR, calId])
    Ind.Increment()
    j = 0
    for eventId in calEventIds:
      j += 1
      kvListEvent = kvList+[Ent.EVENT, eventId]
      kvListEventNewCal = kvListEvent+[Ent.CALENDAR, newCalId]
      try:
        callGAPI(cal.events(), 'move',
                 throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.REQUIRED_ACCESS_LEVEL,
                                                           GAPI.INVALID, GAPI.BAD_REQUEST,
                                                           GAPI.CANNOT_CHANGE_ORGANIZER, GAPI.CANNOT_CHANGE_ORGANIZER_OF_INSTANCE],
                 calendarId=calId, eventId=eventId, destination=newCalId, sendUpdates=parameters['sendUpdates'], fields='')
        entityModifierNewValueActionPerformed(kvListEvent, Act.MODIFIER_TO, f'{Ent.Singular(Ent.CALENDAR)}: {newCalId}', j, jcount)
      except GAPI.notFound as e:
        if not checkCalendarExists(cal, calId, i, count):
          entityUnknownWarning(Ent.CALENDAR, calId, i, count)
          break
        entityActionFailedWarning(kvListEventNewCal, Ent.TypeNameMessage(Ent.EVENT, eventId, str(e)), j, jcount)
      except GAPI.requiredAccessLevel:
# Correct "You need to have reader access to this calendar." to "Writer access required to both calendars."
        entityActionFailedWarning(kvListEventNewCal, Msg.WRITER_ACCESS_REQUIRED_TO_BOTH_CALENDARS, j, jcount)
      except (GAPI.forbidden, GAPI.invalid, GAPI.badRequest,
              GAPI.cannotChangeOrganizer, GAPI.cannotChangeOrganizerOfInstance) as e:
        entityActionFailedWarning(kvListEventNewCal, str(e), j, jcount)
      except GAPI.notACalendarUser:
        userCalServiceNotEnabledWarning(calId, i, count)
        break
    Ind.Decrement()

# gam calendars <CalendarEntity> move events <EventEntity> to|destination <CalendarItem> [<EventNotificationAttribute>]
def doCalendarsMoveEvents(calIds):
  calendarEventEntity = getCalendarEventEntity()
  checkArgumentPresent(['to', 'destination'])
  newCalId = convertUIDtoEmailAddress(getString(Cmd.OB_CALENDAR_ITEM))
  parameters, _ = _getCalendarMoveEventsOptions()
  if not checkCalendarExists(None, newCalId, 0, 0, True):
    return
  _moveCalendarEvents(None, None, None, calIds, len(calIds), calendarEventEntity, newCalId, parameters)

# gam calendars <CalendarEntity> moveevent (id|eventid <EventID>)+ destination <CalendarItem> [<EventNotificationAttribute>]
def doCalendarsMoveEventsOld(calIds):
  calendarEventEntity = initCalendarEventEntity()
  parameters, newCalId = _getCalendarMoveEventsOptions(calendarEventEntity)
  if not checkCalendarExists(None, newCalId, 0, 0, True):
    return
  _moveCalendarEvents(None, None, None, calIds, len(calIds), calendarEventEntity, newCalId, parameters)

def _purgeCalendarEvents(origUser, user, origCal, calIds, count, calendarEventEntity, parameters, emptyTrash):
  body = {'summary': f'GamPurgeCalendar-{random.randint(1, 99999):05}'}
  if user:
    entityValueList = [Ent.USER, user, Ent.CALENDAR, body['summary']]
  else:
    entityValueList = [Ent.CALENDAR, body['summary']]
  i = 0
  for calId in calIds:
    i += 1
    calId, cal = getNormalizedCalIdCal(origCal, calId, user, i, count)
    if not cal:
      continue
    try:
      purgeCalId = callGAPI(cal.calendars(), 'insert',
                            throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.FORBIDDEN],
                            body=body, fields='id')['id']
      Act.Set(Act.CREATE)
      entityActionPerformed(entityValueList)
      Ind.Increment()
      if not emptyTrash:
        Act.Set(Act.DELETE)
        _deleteCalendarEvents(origUser, user, cal, [calId], count, calendarEventEntity, parameters)
      Act.Set(Act.MOVE)
      calendarEventEntity['kwargs']['showDeleted'] = True
      _moveCalendarEvents(origUser, user, cal, [calId], count, calendarEventEntity, purgeCalId, parameters)
      calendarEventEntity['kwargs'].pop('showDeleted')
      Ind.Decrement()
      callGAPI(cal.calendars(), 'delete',
               throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN],
               calendarId=purgeCalId)
      Act.Set(Act.REMOVE)
      entityActionPerformed(entityValueList)
    except (GAPI.notFound, GAPI.forbidden) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.CALENDAR, body['summary']], str(e))
    except GAPI.notACalendarUser:
      userCalServiceNotEnabledWarning(calId, i, count)

# gam calendars <CalendarEntity> purge event <EventEntity> [doit] [<EventNotificationAttribute>]
def doCalendarsPurgeEvents(calIds):
  calendarEventEntity = getCalendarEventEntity()
  parameters = _getCalendarDeleteEventOptions()
  _purgeCalendarEvents(None, None, None, calIds, len(calIds), calendarEventEntity, parameters, False)

def _wipeCalendarEvents(user, origCal, calIds, count):
  i = 0
  for calId in calIds:
    i += 1
    calId, cal = getNormalizedCalIdCal(origCal, calId, user, i, count)
    if not cal:
      continue
    try:
      callGAPI(cal.calendars(), 'clear',
               throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID,
                                                         GAPI.REQUIRED_ACCESS_LEVEL, GAPI.SERVICE_NOT_AVAILABLE],
               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
               calendarId=calId)
      entityActionPerformed([Ent.CALENDAR, calId], i, count)
    except (GAPI.notFound, GAPI.forbidden, GAPI.invalid, GAPI.requiredAccessLevel, GAPI.serviceNotAvailable) as e:
      entityActionFailedWarning([Ent.CALENDAR, calId], str(e), i, count)
    except GAPI.notACalendarUser:
      userCalServiceNotEnabledWarning(calId, i, count)

# gam calendars <CalendarEntity> wipe events
# gam calendar <CalendarEntity> wipe
def doCalendarsWipeEvents(calIds):
  checkArgumentPresent([Cmd.ARG_EVENT, Cmd.ARG_EVENTS])
  checkForExtraneousArguments()
  _wipeCalendarEvents(None, None, calIds, len(calIds))

def _emptyCalendarTrash(user, origCal, calIds, count):
  i = 0
  for calId in calIds:
    i += 1
    calId, cal = getNormalizedCalIdCal(origCal, calId, user, i, count)
    if not cal:
      continue
    Act.Set(Act.PURGE)
    calendarEventEntity = initCalendarEventEntity()
    try:
      events = callGAPIpages(cal.events(), 'list', 'items',
                             throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                             calendarId=calId, showDeleted=True, fields='nextPageToken,items(id,status,organizer(self),recurringEventId)',
                             maxResults=GC.Values[GC.EVENT_MAX_RESULTS])
    except (GAPI.notFound, GAPI.forbidden) as e:
      entityActionFailedWarning([Ent.CALENDAR, calId], str(e), i, count)
      continue
    except GAPI.notACalendarUser:
      userCalServiceNotEnabledWarning(calId, i, count)
      continue
    for event in events:
      if event['status'] == 'cancelled' and event.get('organizer', {}).get('self', user is None) and not event.get('recurringEventId', ''):
        calendarEventEntity['list'].append(event['id'])
    jcount = len(calendarEventEntity['list'])
    if not user:
      entityPerformActionNumItems([Ent.CALENDAR, calId], jcount, Ent.TRASHED_EVENT, i, count)
      Ind.Increment()
    if jcount > 0:
      _purgeCalendarEvents(user, user, cal, [calId], 1, calendarEventEntity, {'sendUpdates': 'none', 'doIt': True}, True)
    if not user:
      Ind.Decrement()

# gam calendars <CalendarEntity> empty calendartrash
def doCalendarsEmptyTrash(calIds):
  checkForExtraneousArguments()
  Act.Set(Act.PURGE)
  _emptyCalendarTrash(None, None, calIds, len(calIds))

EVENT_SHOW_ORDER = ['id', 'summary', 'status', 'description', 'location',
                    'start', 'end', 'endTimeUnspecified',
                    'creator', 'organizer', 'created', 'updated', 'iCalUID']
EVENT_PRINT_ORDER = ['id', 'summary', 'status', 'description', 'location',
                     'created', 'updated', 'iCalUID']

EVENT_TIME_OBJECTS = {'created', 'updated'}

def _showCalendarEvent(primaryEmail, calId, eventEntityType, event, k, kcount, FJQC):
  if FJQC.formatJSON:
    if primaryEmail:
      printLine(json.dumps(cleanJSON({'primaryEmail': primaryEmail, 'calendarId': calId, 'event': event},
                                     timeObjects=EVENT_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    else:
      printLine(json.dumps(cleanJSON({'calendarId': calId, 'event': event},
                                     timeObjects=EVENT_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  printEntity([eventEntityType, event['id']], k, kcount)
  skipObjects = {'id'}
  Ind.Increment()
  for field in EVENT_SHOW_ORDER:
    if field in event:
      if field != 'description':
        showJSON(field, event[field], skipObjects, EVENT_TIME_OBJECTS)
      else:
        printKeyValueWithCRsNLs(field, event[field])
      skipObjects.add(field)
  showJSON(None, event, skipObjects)
  Ind.Decrement()

def _printCalendarEvent(user, calId, event, csvPF, FJQC):
  row = {'calendarId': calId, 'id': event['id']}
  if user:
    row['primaryEmail'] = user
  flattenJSON(event, flattened=row, timeObjects=EVENT_TIME_OBJECTS)
  if not FJQC.formatJSON:
    csvPF.WriteRowTitles(row)
  elif csvPF.CheckRowTitles(row):
    row = {'calendarId': calId, 'id': event['id'],
           'JSON': json.dumps(cleanJSON(event, timeObjects=EVENT_TIME_OBJECTS),
                              ensure_ascii=False, sort_keys=False)}
    if user:
      row['primaryEmail'] = user
    csvPF.WriteRowNoFilter(row)

def _printShowCalendarEvents(origUser, user, origCal, calIds, count, calendarEventEntity,
                             csvPF, FJQC, fieldsList):
  i = 0
  for calId in calIds:
    i += 1
    if csvPF:
      printGettingEntityItemForWhom(Ent.EVENT, calId, i, count)
    calId, _, events, jcount = _validateCalendarGetEvents(origUser, user, origCal, calId, i, count, calendarEventEntity,
                                                          fieldsList, not csvPF and not FJQC.formatJSON and not calendarEventEntity['countsOnly'])
    if not csvPF:
      if not calendarEventEntity['countsOnly']:
        Ind.Increment()
        j = 0
        for event in events:
          j += 1
          if calendarEventEntity['showDayOfWeek']:
            _getEventDaysOfWeek(event)
          _showCalendarEvent(user, calId, Ent.EVENT, event, j, jcount, FJQC)
        Ind.Decrement()
      else:
        printKeyValueList([Ent.Singular(Ent.CALENDAR), calId, Ent.Choose(Ent.EVENT, jcount), jcount])
    else:
      if not calendarEventEntity['countsOnly']:
        if events:
          for event in events:
            if calendarEventEntity['showDayOfWeek']:
              _getEventDaysOfWeek(event)
            _printCalendarEvent(user, calId, event, csvPF, FJQC)
        elif GC.Values[GC.CSV_OUTPUT_USERS_AUDIT] and user:
          csvPF.WriteRowNoFilter({'calendarId': calId, 'primaryEmail': user, 'id': ''})
      else:
        if calendarEventEntity['eventRowFilter']:
          jcount = 0
          for event in events:
            if calendarEventEntity['showDayOfWeek']:
              _getEventDaysOfWeek(event)
            row = {'calendarId': calId, 'id': event['id']}
            if user:
              row['primaryEmail'] = user
            flattenJSON(event, flattened=row, timeObjects=EVENT_TIME_OBJECTS)
            if csvPF.CheckRowTitles(row):
              jcount += 1
        row = {'calendarId': calId}
        if user:
          row['primaryEmail'] = user
        row['events'] = jcount
        if not calendarEventEntity['eventRowFilter']:
          csvPF.WriteRow(row)
        else:
          csvPF.WriteRowNoFilter(row)

EVENT_FIELDS_CHOICE_MAP = {
  'anyonecanaddself': 'anyoneCanAddSelf',
  'attachments': 'attachments',
  'attendees': 'attendees',
  'attendeesomitted': 'attendeesOmitted',
  'colorid': 'colorId',
  'conferencedata': 'conferenceData',
  'created': 'created',
  'creator': 'creator',
  'description': 'description',
  'end': 'end',
  'endtime': 'end',
  'endtimeunspecified': 'endTimeUnspecified',
  'extendedproperties': 'extendedProperties',
  'focustimeproperties': 'focusTimeProperties',
  'gadget': 'gadget',
  'guestscaninviteothers': 'guestsCanInviteOthers',
  'guestscanmodify': 'guestsCanModify',
  'guestscanseeotherguests': 'guestsCanSeeOtherGuests',
  'hangoutlink': 'hangoutLink',
  'htmllink': 'htmlLink',
  'eventtype': 'eventType',
  'icaluid': 'iCalUID',
  'id': 'id',
  'location': 'location',
  'locked': 'locked',
  'organizer': 'organizer',
  'organiser': 'organizer',
  'originalstart': 'originalStartTime',
  'originalstarttime': 'originalStartTime',
  'outofofficeproperties': 'outOfOfficeProperties',
  'privatecopy': 'privateCopy',
  'recurrence': 'recurrence',
  'recurringeventid': 'recurringEventId',
  'reminders': 'reminders',
  'sequence': 'sequence',
  'source': 'source',
  'start': 'start',
  'starttime': 'start',
  'status': 'status',
  'summary': 'summary',
  'transparency': 'transparency',
  'updated': 'updated',
  'visibility': 'visibility',
  'workinglocationproperties': 'workingLocationProperties',
  }

EVENT_ATTACHMENTS_SUBFIELDS_CHOICE_MAP = {
  'fileid': 'fileId',
  'fileurl': 'fileUrl',
  'iconlink': 'iconLink',
  'mimetype': 'mimeType',
  'title': 'title',
  }

EVENT_ATTENDEES_SUBFIELDS_CHOICE_MAP = {
  'additionalguests': 'additionalGuests',
  'comment': 'comment',
  'displayname': 'displayName',
  'email': 'email',
  'id': 'id',
  'optional': 'optional',
  'organizer': 'organizer',
  'organiser': 'organizer',
  'resource': 'resource',
  'responsestatus': 'responseStatus',
  'self': 'self',
  }

EVENT_CONFERENCEDATA_SUBFIELDS_CHOICE_MAP = {
  'conferenceid': 'conferenceId',
  'conferencesolution': 'conferenceSolution',
  'createrequest': 'createRequest',
  'entrypoints': 'entryPoints',
  'notes': 'notes',
  'signature': 'signature',
  }

EVENT_CREATOR_SUBFIELDS_CHOICE_MAP = {
  'displayname': 'displayName',
  'email': 'email',
  'id': 'id',
  'self': 'self',
  }

EVENT_FOCUSTIME_SUBFIELDS_CHOICE_MAP = {
  'autodeclinemode': 'autoDeclineMode',
  'chatstatus': 'chatStatus',
  'declinemessage': 'declineMessage',
  }

EVENT_ORGANIZER_SUBFIELDS_CHOICE_MAP = {
  'displayname': 'displayName',
  'email': 'email',
  'id': 'id',
  'self': 'self',
  }

EVENT_OUTOFOFFICE_SUBFIELDS_CHOICE_MAP = {
  'autodeclinemode': 'autoDeclineMode',
  'declinemessage': 'declineMessage',
  }

EVENT_WORKINGLOCATION_SUBFIELDS_CHOICE_MAP = {
  'homeoffice': 'homeOffice',
  'customlocation': 'customLocation',
  'officelocation': 'officeLocation',
  }

EVENT_SUBFIELDS_CHOICE_MAP = {
  'attachments': EVENT_ATTACHMENTS_SUBFIELDS_CHOICE_MAP,
  'attendees': EVENT_ATTENDEES_SUBFIELDS_CHOICE_MAP,
  'conferencedata': EVENT_CONFERENCEDATA_SUBFIELDS_CHOICE_MAP,
  'creator': EVENT_CREATOR_SUBFIELDS_CHOICE_MAP,
  'focustimeproperties': EVENT_FOCUSTIME_SUBFIELDS_CHOICE_MAP,
  'organizer': EVENT_ORGANIZER_SUBFIELDS_CHOICE_MAP,
  'organiser': EVENT_ORGANIZER_SUBFIELDS_CHOICE_MAP,
  'outofofficeproperties': EVENT_OUTOFOFFICE_SUBFIELDS_CHOICE_MAP,
  'workinglocationproperties': EVENT_WORKINGLOCATION_SUBFIELDS_CHOICE_MAP,
}

def _getEventFields(fieldsList):
  if not fieldsList:
    fieldsList.append('id')
  for field in _getFieldsList():
    if field.find('.') == -1:
      if field in EVENT_FIELDS_CHOICE_MAP:
        addFieldToFieldsList(field, EVENT_FIELDS_CHOICE_MAP, fieldsList)
      else:
        invalidChoiceExit(field, EVENT_FIELDS_CHOICE_MAP, True)
    else:
      field, subField = field.split('.', 1)
      if field in EVENT_SUBFIELDS_CHOICE_MAP:
        if subField in EVENT_SUBFIELDS_CHOICE_MAP[field]:
          fieldsList.append(f'{EVENT_FIELDS_CHOICE_MAP[field]}.{EVENT_SUBFIELDS_CHOICE_MAP[field][subField]}')
        else:
          invalidChoiceExit(subField, list(EVENT_SUBFIELDS_CHOICE_MAP[field]), True)
      else:
        invalidChoiceExit(field, list(EVENT_SUBFIELDS_CHOICE_MAP), True)

def _addEventEntitySelectFields(calendarEventEntity, fieldsList):
  if fieldsList:
    _getEventMatchFields(calendarEventEntity, fieldsList)
    if calendarEventEntity['maxinstances'] != -1:
      fieldsList.append('recurrence')

def _getCalendarInfoEventOptions(calendarEventEntity):
  FJQC = FormatJSONQuoteChar()
  fieldsList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'fields':
      _getEventFields(fieldsList)
    elif myarg == 'showdayofweek':
      calendarEventEntity['showDayOfWeek'] = True
    else:
      FJQC.GetFormatJSON(myarg)
  _addEventEntitySelectFields(calendarEventEntity, fieldsList)
  return (FJQC, fieldsList)

def _infoCalendarEvents(origUser, user, origCal, calIds, count, calendarEventEntity, FJQC, fieldsList):
  fields = getFieldsFromFieldsList(fieldsList)
  ifields = getItemFieldsFromFieldsList('items', fieldsList)
  i = 0
  for calId in calIds:
    i += 1
    calId, cal, calEventIds, jcount = _validateCalendarGetEventIDs(origUser, user, origCal, calId, i, count, calendarEventEntity, showAction=not FJQC.formatJSON)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for eventId in calEventIds:
      j += 1
      try:
        event = callGAPI(cal.events(), 'get',
                         throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.DELETED, GAPI.FORBIDDEN],
                         calendarId=calId, eventId=eventId, fields=fields)
        if calendarEventEntity['maxinstances'] == -1 or 'recurrence' not in event:
          if calendarEventEntity['showDayOfWeek']:
            _getEventDaysOfWeek(event)
          _showCalendarEvent(user, calId, Ent.EVENT, event, j, jcount, FJQC)
        else:
          instances = callGAPIpages(cal.events(), 'instances', 'items',
                                    throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.DELETED, GAPI.FORBIDDEN],
                                    calendarId=calId, eventId=eventId, fields=ifields,
                                    maxItems=calendarEventEntity['maxinstances'], maxResults=GC.Values[GC.EVENT_MAX_RESULTS])
          lcount = len(instances)
          if not FJQC.formatJSON:
            entityPerformActionNumItems([Ent.EVENT, event['id']], lcount, Ent.INSTANCE, j, jcount)
          Ind.Increment()
          l = 0
          for instance in instances:
            l += 1
            if calendarEventEntity['showDayOfWeek']:
              _getEventDaysOfWeek(instance)
            _showCalendarEvent(user, calId, Ent.INSTANCE, instance, l, lcount, FJQC)
          Ind.Decrement()
      except (GAPI.notFound, GAPI.deleted) as e:
        if not checkCalendarExists(cal, calId, i, count):
          entityUnknownWarning(Ent.CALENDAR, calId, i, count)
          break
        entityActionFailedWarning([Ent.CALENDAR, calId, Ent.EVENT, eventId], str(e), j, jcount)
      except (GAPI.forbidden) as e:
        entityActionFailedWarning([Ent.CALENDAR, calId], str(e), i, count)
        break
      except GAPI.notACalendarUser:
        userCalServiceNotEnabledWarning(calId, i, count)
        break
    Ind.Decrement()

# gam calendars <CalendarEntity> info events <EventEntity> [maxinstances <Number>]
#	[fields <EventFieldNameList>] [showdayofweek]
#	[formatjson]
def doCalendarsInfoEvents(calIds):
  calendarEventEntity = getCalendarEventEntity()
  FJQC, fieldsList = _getCalendarInfoEventOptions(calendarEventEntity)
  _infoCalendarEvents(None, None, None, calIds, len(calIds), calendarEventEntity, FJQC, fieldsList)

EVENT_INDEXED_TITLES = ['attendees', 'attachments', 'recurrence']

def _getCalendarPrintShowEventOptions(calendarEventEntity, entityType):
  csvPF = CSVPrintFile(['primaryEmail', 'calendarId', 'id'] if entityType == Ent.USER else ['calendarId', 'id'], 'sortall', indexedTitles=EVENT_INDEXED_TITLES) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  fieldsList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif _getCalendarListEventsDisplayProperty(myarg, calendarEventEntity):
      pass
    elif myarg == 'fields':
      _getEventFields(fieldsList)
    elif myarg == 'countsonly':
      calendarEventEntity['countsOnly'] = True
    elif myarg == 'showdayofweek':
      calendarEventEntity['showDayOfWeek'] = True
    elif myarg == 'eventrowfilter':
      calendarEventEntity['eventRowFilter'] = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if calendarEventEntity['countsOnly'] and not calendarEventEntity['eventRowFilter']:
    fieldsList = ['id']
  if csvPF:
    if calendarEventEntity['countsOnly']:
      csvPF.RemoveTitles(['id'])
      csvPF.AddTitles(['events'])
      calendarEventEntity['countsOnlyTitles'] = csvPF.titlesList[:]
    elif not FJQC.formatJSON and not fieldsList:
      csvPF.AddSortTitles(EVENT_PRINT_ORDER)
  _addEventEntitySelectFields(calendarEventEntity, fieldsList)
  return (csvPF, FJQC, fieldsList)

# gam calendars <CalendarEntity> print events <EventEntity> <EventDisplayProperties>*
#	[fields <EventFieldNameList>] [showdayofweek]
#	[countsonly [eventrowfilter]]
#	[formatjson [quotechar <Character>]] [todrive <ToDriveAttribute>*]
# gam calendars <CalendarEntity> show events <EventEntity> <EventDisplayProperties>*
#	[fields <EventFieldNameList>] [showdayofweek]
#	[countsonly]
#	[formatjson]
def doCalendarsPrintShowEvents(calIds):
  calendarEventEntity = getCalendarEventEntity()
  csvPF, FJQC, fieldsList = _getCalendarPrintShowEventOptions(calendarEventEntity, Ent.CALENDAR)
  _printShowCalendarEvents(None, None, None, calIds, len(calIds), calendarEventEntity,
                           csvPF, FJQC, fieldsList)
  if csvPF:
    if calendarEventEntity['countsOnly'] and calendarEventEntity['eventRowFilter']:
      csvPF.SetTitles(calendarEventEntity['countsOnlyTitles'])
      csvPF.writeCSVfile('Calendar Events', True)
    else:
      csvPF.writeCSVfile('Calendar Events')

# <CalendarSettings> ::==
#	[description <String>] [location <String>] [summary <String>] [timezone <TimeZone>]
def _getCalendarSetting(myarg, body):
  if myarg == 'description':
    body['description'] = getStringWithCRsNLs()
  elif myarg == 'location':
    body['location'] = getString(Cmd.OB_STRING, minLen=0)
  elif myarg == 'summary':
    body['summary'] = getString(Cmd.OB_STRING)
  elif myarg == 'timezone':
    body['timeZone'] = getString(Cmd.OB_STRING)
  else:
    return False
  return True

def getCalendarSettings(summaryRequired=False):
  body = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if _getCalendarSetting(myarg, body):
      pass
    else:
      unknownArgumentExit()
  if summaryRequired and not body.get('summary', None):
    missingArgumentExit('summary <String>')
  return body

# gam calendars <CalendarEntity> modify <CalendarSettings>
def doCalendarsModifySettings(calIds):
  body = getCalendarSettings(summaryRequired=False)
  count = len(calIds)
  i = 0
  for calId in calIds:
    i += 1
    calId, cal = validateCalendar(calId, i, count)
    if not cal:
      continue
    try:
      callGAPI(cal.calendars(), 'patch',
               throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID],
               calendarId=calId, body=body)
      entityActionPerformed([Ent.CALENDAR, calId], i, count)
    except (GAPI.notFound, GAPI.forbidden, GAPI.invalid) as e:
      entityActionFailedWarning([Ent.CALENDAR, calId], str(e), i, count)
    except GAPI.notACalendarUser:
      userCalServiceNotEnabledWarning(calId, i, count)

def _showCalendarSettings(calendar, j, jcount):
  printEntity([Ent.CALENDAR, calendar['id']], j, jcount)
  Ind.Increment()
  if 'summaryOverride' in calendar or 'summary' in calendar:
    printKeyValueList(['Summary', calendar.get('summaryOverride', calendar.get('summary', ''))])
  if 'description' in calendar:
    printKeyValueWithCRsNLs('Description', calendar.get('description', ''))
  if 'location' in calendar:
    printKeyValueList(['Location', calendar.get('location', '')])
  if 'timeZone' in calendar:
    printKeyValueList(['Timezone', calendar.get('timeZone', '')])
  if 'conferenceProperties' in calendar:
    printKeyValueList(['ConferenceProperties', None])
    Ind.Increment()
    printKeyValueList(['AllowedConferenceSolutionTypes', ','.join(calendar.get('conferenceProperties', {}).get('allowedConferenceSolutionTypes', []))])
    Ind.Decrement()
  Ind.Decrement()

CALENDAR_SETTINGS_FIELDS_CHOICE_MAP = {
  'conferenceproperties': 'conferenceProperties',
  'description': 'description',
  'id': 'id',
  'location': 'location',
  'summary': 'summary',
  'timezone': 'timeZone',
  }

# gam calendars <CalendarEntity> print settings [todrive <ToDriveAttribute>*]
#	[fields <CalendarSettingsFieldList>]
#	[formatjson] [quotechar <Character>}
# gam calendars <CalendarEntity> show settings
#	[fields <CalendarSettingsFieldList>]
#	[formatjson]
def doCalendarsPrintShowSettings(calIds):
  csvPF = CSVPrintFile(['calendarId'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  fieldsList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif getFieldsList(myarg, CALENDAR_SETTINGS_FIELDS_CHOICE_MAP, fieldsList, initialField='id'):
      pass
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  fields = getFieldsFromFieldsList(fieldsList)
  count = len(calIds)
  i = 0
  for calId in calIds:
    i += 1
    calId, cal = validateCalendar(calId, i, count)
    if not cal:
      continue
    try:
      calendar = callGAPI(cal.calendars(), 'get',
                          throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                          calendarId=calId, fields=fields)
      if not csvPF:
        if not FJQC.formatJSON:
          _showCalendarSettings(calendar, i, count)
        else:
          printLine(json.dumps(cleanJSON(calendar), ensure_ascii=False, sort_keys=True))
      else:
        row = flattenJSON(calendar)
        if not FJQC.formatJSON:
          row['calendarId'] = row.pop('id')
          csvPF.WriteRowTitles(row)
        elif csvPF.CheckRowTitles(row):
          csvPF.WriteRowNoFilter({'calendarId': calId, 'JSON': json.dumps(cleanJSON(calendar), ensure_ascii=False, sort_keys=True)})
    except (GAPI.notFound, GAPI.forbidden) as e:
      entityActionFailedWarning([Ent.CALENDAR, calId], str(e), i, count)
    except GAPI.notACalendarUser:
      userCalServiceNotEnabledWarning(calId, i, count)
  if csvPF:
    csvPF.writeCSVfile('Calendar Settings')

def _validateResourceId(cd, resourceId, i, count, exitOnNotFound):
  try:
    return callGAPI(cd.resources().calendars(), 'get',
                    throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                    customer=GC.Values[GC.CUSTOMER_ID], calendarResourceId=resourceId, fields='resourceEmail')['resourceEmail']
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
    if exitOnNotFound:
      entityDoesNotExistExit(Ent.RESOURCE_CALENDAR, resourceId, i, count)
    checkEntityAFDNEorAccessErrorExit(cd, Ent.RESOURCE_CALENDAR, resourceId, i, count)
    return None

def _normalizeResourceIdGetRuleIds(cd, resourceId, i, count, ACLScopeEntity, showAction=True):
  calId = _validateResourceId(cd, resourceId, i, count, False)
  if not calId:
    return (None, None, 0)
  if ACLScopeEntity['dict']:
    ruleIds = ACLScopeEntity['dict'][resourceId]
  else:
    ruleIds = ACLScopeEntity['list']
  jcount = len(ruleIds)
  if showAction:
    entityPerformActionNumItems([Ent.RESOURCE_CALENDAR, resourceId], jcount, Ent.CALENDAR_ACL, i, count)
  if jcount == 0:
    setSysExitRC(NO_ENTITIES_FOUND_RC)
  return (calId, ruleIds, jcount)

# gam resource <ResourceID> create calendaracls <CalendarACLRole> <CalendarACLScopeEntity> [sendnotifications <Boolean>]
# gam resources <ResourceEntity> create calendaracls <CalendarACLRole> <CalendarACLScopeEntity> [sendnotifications <Boolean>]
def doResourceCreateCalendarACLs(entityList):
  cal = buildGAPIObject(API.CALENDAR)
  cd = buildGAPIObject(API.DIRECTORY)
  role, ACLScopeEntity, sendNotifications = getCalendarCreateUpdateACLsOptions(True)
  i = 0
  count = len(entityList)
  for resourceId in entityList:
    i += 1
    calId, ruleIds, jcount = _normalizeResourceIdGetRuleIds(cd, resourceId, i, count, ACLScopeEntity)
    if jcount == 0:
      continue
    _createCalendarACLs(cal, Ent.RESOURCE_CALENDAR, calId, i, count, role, ruleIds, jcount, sendNotifications)

def _resourceUpdateDeleteCalendarACLs(entityList, function, ACLScopeEntity, role, sendNotifications):
  cal = buildGAPIObject(API.CALENDAR)
  cd = buildGAPIObject(API.DIRECTORY)
  i = 0
  count = len(entityList)
  for resourceId in entityList:
    i += 1
    calId, ruleIds, jcount = _normalizeResourceIdGetRuleIds(cd, resourceId, i, count, ACLScopeEntity)
    if jcount == 0:
      continue
    _updateDeleteCalendarACLs(cal, function, Ent.RESOURCE_CALENDAR, calId, i, count, role, ruleIds, jcount, sendNotifications)

# gam resource <ResourceID> update calendaracls <CalendarACLRole> <CalendarACLScopeEntity> [sendnotifications <Boolean>]
# gam resources <ResourceEntity> update calendaracls <CalendarACLRole> <CalendarACLScopeEntity> [sendnotifications <Boolean>]
def doResourceUpdateCalendarACLs(entityList):
  role, ACLScopeEntity, sendNotifications = getCalendarCreateUpdateACLsOptions(True)
  _resourceUpdateDeleteCalendarACLs(entityList, 'patch', ACLScopeEntity, role, sendNotifications)

# gam resource <ResourceID> delete calendaracls [<CalendarACLRole>] <CalendarACLScopeEntity>
# gam resources <ResourceEntity> delete calendaracls [<CalendarACLRole>] <CalendarACLScopeEntity>
def doResourceDeleteCalendarACLs(entityList):
  role, ACLScopeEntity = getCalendarDeleteACLsOptions(True)
  _resourceUpdateDeleteCalendarACLs(entityList, 'delete', ACLScopeEntity, role, False)

# gam resource <ResourceID> info calendaracls <CalendarACLScopeEntity>
#	[formatjson]
# gam resources <ResourceEntity> info calendaracls <CalendarACLScopeEntity>
#	[formatjson]
def doResourceInfoCalendarACLs(entityList):
  cal = buildGAPIObject(API.CALENDAR)
  cd = buildGAPIObject(API.DIRECTORY)
  ACLScopeEntity = getCalendarSiteACLScopeEntity()
  FJQC = _getCalendarInfoACLOptions()
  i = 0
  count = len(entityList)
  for resourceId in entityList:
    i += 1
    calId, ruleIds, jcount = _normalizeResourceIdGetRuleIds(cd, resourceId, i, count, ACLScopeEntity, showAction=not FJQC.formatJSON)
    if jcount == 0:
      continue
    _infoCalendarACLs(cal, resourceId, Ent.RESOURCE_CALENDAR, calId, i, count, ruleIds, jcount, FJQC)

# gam resource <ResourceID> print calendaracls [todrive <ToDriveAttribute>*]
#	[noselfowner] (addcsvdata <FieldName> <String>)*
#	[formatjson [quotechar <Character>]]
# gam resources <ResourceEntity> print calendaracls [todrive <ToDriveAttribute>*]
#	[noselfowner] (addcsvdata <FieldName> <String>)*
#	[formatjson [quotechar <Character>]]
# gam resource <ResourceID> show calendaracls
#	[noselfowner]
#	[formatjson]
# gam resources <ResourceEntity> show calendaracls
#	[noselfowner]
#	[formatjson]
def doResourcePrintShowCalendarACLs(entityList):
  cal = buildGAPIObject(API.CALENDAR)
  cd = buildGAPIObject(API.DIRECTORY)
  csvPF, FJQC, noSelfOwner, addCSVData = _getCalendarPrintShowACLOptions(['resourceId', 'resourceEmail'])
  i = 0
  count = len(entityList)
  for resourceId in entityList:
    i += 1
    calId = _validateResourceId(cd, resourceId, i, count, False)
    if not calId:
      continue
    _printShowCalendarACLs(cal, resourceId, Ent.RESOURCE_CALENDAR, calId, i, count, csvPF, FJQC, noSelfOwner, addCSVData)
  if csvPF:
    csvPF.writeCSVfile('Resource Calendar ACLs')

def _showSchema(schema, i=0, count=0):
  printEntity([Ent.USER_SCHEMA, schema['schemaName']], i, count)
  Ind.Increment()
  for a_key in schema:
    if a_key not in {'kind', 'etag', 'schemaName', 'fields'}:
      printKeyValueList([a_key, schema[a_key]])
  for field in schema['fields']:
    printKeyValueList(['Field', field['fieldName']])
    Ind.Increment()
    for a_key in field:
      if a_key not in {'kind', 'etag', 'fieldName'}:
        if a_key != 'numericIndexingSpec':
          printKeyValueList([a_key, field[a_key]])
        else:
          printKeyValueList([a_key, ''])
          Ind.Increment()
          for s_key in field[a_key]:
            printKeyValueList([s_key, field[a_key][s_key]])
          Ind.Decrement()
    Ind.Decrement()
  Ind.Decrement()

SCHEMA_FIELDTYPE_CHOICE_MAP = {
  'bool': 'BOOL',
  'boolean': 'BOOL',
  'date': 'DATE',
  'double': 'DOUBLE',
  'email': 'EMAIL',
  'int64': 'INT64',
  'phone': 'PHONE',
  'string': 'STRING',
  }

# gam create schema|schemas <SchemaName> [displayname <String>] <SchemaFieldDefinition>+
# gam update schema|schemas <SchemaEntity> [displayname <String>] <SchemaFieldDefinition>+
def doCreateUpdateUserSchemas():
  cd = buildGAPIObject(API.DIRECTORY)
  updateCmd = Act.Get() == Act.UPDATE
  if not updateCmd:
    entityList = getStringReturnInList(Cmd.OB_SCHEMA_NAME)
  else:
    entityList = getEntityList(Cmd.OB_SCHEMA_ENTITY)
  addBody = {'schemaName': '', 'fields': []}
  deleteFields = []
  schemaDisplayName = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'field':
      fieldName = getString(Cmd.OB_FIELD_NAME)
      a_field = {'fieldName': fieldName.replace(' ', '_'), 'displayName': fieldName, 'fieldType': 'STRING'}
      while Cmd.ArgumentsRemaining():
        argument = getArgument()
        if argument == 'displayname':
          a_field['displayName'] = getString(Cmd.OB_FIELD_NAME)
        elif argument == 'type':
          a_field['fieldType'] = getChoice(SCHEMA_FIELDTYPE_CHOICE_MAP, mapChoice=True)
        elif argument in {'multivalued', 'multivalue'}:
          a_field['multiValued'] = True
        elif argument == 'indexed':
          a_field['indexed'] = True
        elif argument == 'restricted':
          a_field['readAccessType'] = 'ADMINS_AND_SELF'
        elif argument == 'range':
          a_field['numericIndexingSpec'] = {'minValue': getInteger(), 'maxValue': getInteger()}
        elif argument == 'endfield':
          break
        elif argument == 'field':
          Cmd.Backup()
          break
        else:
          unknownArgumentExit()
      addBody['fields'].append(a_field)
    elif updateCmd and myarg == 'deletefield':
      deleteFields.append(getString(Cmd.OB_FIELD_NAME).replace(' ', '_'))
    elif myarg == 'displayname':
      schemaDisplayName = getString(Cmd.OB_SCHEMA_NAME)
    else:
      unknownArgumentExit()
  i = 0
  count = len(entityList)
  if not updateCmd:
    if not addBody['fields']:
      missingArgumentExit('SchemaFieldDefinition')
  elif schemaDisplayName and count > 1:
    usageErrorExit(Msg.DISPLAYNAME_NOT_ALLOWED_WHEN_UPDATING_MULTIPLE_SCHEMAS)
  for schemaName in entityList:
    i += 1
    try:
      if updateCmd:
        oldBody = callGAPI(cd.schemas(), 'get',
                           throwReasons=[GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                           customerId=GC.Values[GC.CUSTOMER_ID], schemaKey=schemaName, fields='schemaName,displayName,fields')
        for field in oldBody['fields']:
          field.pop('etag', None)
          field.pop('kind', None)
          field.pop('fieldId', None)
        badDelete = False
        for delField in deleteFields:
          fieldNameLower = delField.lower()
          for n, field in enumerate(oldBody['fields']):
            if field['fieldName'].lower() == fieldNameLower:
              del oldBody['fields'][n]
              break
          else:
            entityActionNotPerformedWarning([Ent.USER_SCHEMA, schemaName, Ent.FIELD, delField], Msg.DOES_NOT_EXIST)
            badDelete = True
        if badDelete:
          continue
        for addField in addBody['fields']:
          fieldNameLower = addField['fieldName'].lower()
          for n, field in enumerate(oldBody['fields']):
            if field['fieldName'].lower() == fieldNameLower:
              del oldBody['fields'][n]
              break
        oldBody['fields'].extend(addBody['fields'])
        if not oldBody['fields']:
          entityActionNotPerformedWarning([Ent.USER_SCHEMA, schemaName],
                                          Msg.SCHEMA_WOULD_HAVE_NO_FIELDS.format(Ent.Singular(Ent.USER_SCHEMA), Ent.Plural(Ent.FIELD)))
          continue
        if schemaDisplayName:
          oldBody['displayName'] = schemaDisplayName
        result = callGAPI(cd.schemas(), 'update',
                          throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN, GAPI.FIELD_IN_USE],
                          customerId=GC.Values[GC.CUSTOMER_ID], body=oldBody, schemaKey=schemaName)
        entityActionPerformed([Ent.USER_SCHEMA, result['schemaName']], i, count)
      else:
        addBody['schemaName'] = schemaName.replace(' ', '_')
        addBody['displayName'] = schemaDisplayName if schemaDisplayName else schemaName
        result = callGAPI(cd.schemas(), 'insert',
                          throwReasons=[GAPI.DUPLICATE, GAPI.CONDITION_NOT_MET, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                          customerId=GC.Values[GC.CUSTOMER_ID], body=addBody, fields='schemaName')
        entityActionPerformed([Ent.USER_SCHEMA, result['schemaName']], i, count)
    except GAPI.duplicate:
      entityDuplicateWarning([Ent.USER_SCHEMA, schemaName], i, count)
    except (GAPI.conditionNotMet, GAPI.fieldInUse, GAPI.forbidden) as e:
      entityActionFailedWarning([Ent.USER_SCHEMA, schemaName], str(e), i, count)
    except (GAPI.badRequest, GAPI.resourceNotFound):
      checkEntityAFDNEorAccessErrorExit(cd, Ent.USER_SCHEMA, schemaName, i, count)

# gam delete schema|schemas <SchemaEntity>
def doDeleteUserSchemas():
  cd = buildGAPIObject(API.DIRECTORY)
  entityList = getEntityList(Cmd.OB_SCHEMA_ENTITY)
  checkForExtraneousArguments()
  i = 0
  count = len(entityList)
  for schemaKey in entityList:
    i += 1
    try:
      callGAPI(cd.schemas(), 'delete',
               throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN, GAPI.FIELD_IN_USE],
               customerId=GC.Values[GC.CUSTOMER_ID], schemaKey=schemaKey)
      entityActionPerformed([Ent.USER_SCHEMA, schemaKey], i, count)
    except GAPI.fieldInUse as e:
      entityActionFailedWarning([Ent.USER_SCHEMA, schemaKey], str(e), i, count)
    except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
      checkEntityAFDNEorAccessErrorExit(cd, Ent.USER_SCHEMA, schemaKey, i, count)

# gam info schema|schemas <SchemaEntity>
def doInfoUserSchemas():
  cd = buildGAPIObject(API.DIRECTORY)
  entityList = getEntityList(Cmd.OB_SCHEMA_ENTITY)
  checkForExtraneousArguments()
  i = 0
  count = len(entityList)
  for schemaKey in entityList:
    i += 1
    try:
      schema = callGAPI(cd.schemas(), 'get',
                        throwReasons=[GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                        customerId=GC.Values[GC.CUSTOMER_ID], schemaKey=schemaKey)
      _showSchema(schema, i, count)
    except (GAPI.invalid, GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
      checkEntityAFDNEorAccessErrorExit(cd, Ent.USER_SCHEMA, schemaKey, i, count)

SCHEMAS_SORT_TITLES = ['schemaId', 'schemaName', 'displayName']
SCHEMAS_INDEXED_TITLES = ['fields']

# gam print schema|schemas [todrive <ToDriveAttribute>*]
# gam show schema|schemas
def doPrintShowUserSchemas():
  csvPF = CSVPrintFile(SCHEMAS_SORT_TITLES, 'sortall', SCHEMAS_INDEXED_TITLES) if Act.csvFormat() else None
  cd = buildGAPIObject(API.DIRECTORY)
  getTodriveOnly(csvPF)
  try:
    result = callGAPI(cd.schemas(), 'list',
                      throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                      customerId=GC.Values[GC.CUSTOMER_ID])
    jcount = len(result.get('schemas', [])) if (result) else 0
    if not csvPF:
      performActionNumItems(jcount, Ent.USER_SCHEMA)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
    else:
      if not csvPF:
        Ind.Increment()
        j = 0
        for schema in result['schemas']:
          j += 1
          _showSchema(schema, j, jcount)
        Ind.Decrement()
      else:
        for schema in result['schemas']:
          csvPF.WriteRowTitles(flattenJSON(schema))
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
    accessErrorExit(cd)
  if csvPF:
    csvPF.writeCSVfile('User Schemas')

def _copyStorageObjects(objects, target_bucket, target_prefix):
  """Copies objects to target_bucket.

  Args:
    objects: list of object dicts
      [
        {
          bucket: source bucket,
          name: source object name,
          (optional) md5Hash: source file hash value
        },
        ...
      ]
    target_bucket: target bucket id
    target_prefix: prefix name to prepend to target object

  """

  def process_rewrite(request_id, response, exception):
    fileIndex = int(request_id)
    if exception:
      http_status, reason, message = checkGAPIError(exception)
      # Poor man's backoff/retry
      if http_status == 429 or http_status > 499:
        writeStderr(f'Temporary error: {http_status} - {message}, Backing off: 10 seconds\n')
        flushStderr()
        time.sleep(10)
        next_batch.add(s.objects().rewrite(**files_to_copy[fileIndex]['method']), request_id=request_id)
        return
      if reason in GAPI.REASON_EXCEPTION_MAP:
        raise GAPI.REASON_EXCEPTION_MAP[reason](message)
      raise exception(message)
    source_displayname = files_to_copy[fileIndex]['source_displayname']
    target_displayname = files_to_copy[fileIndex]['target_displayname']
    if response.get('done'):
      source_md5 = files_to_copy[fileIndex]['md5Hash']
      target_md5 = response['resource']['md5Hash']
      if source_md5 != target_md5:
        systemErrorExit(GOOGLE_API_ERROR_RC, f'Target file {target_displayname} checksum {target_md5} does not match source {source_md5}. This should not happen')
      entityActionPerformedMessage([Ent.CLOUD_STORAGE_FILE, None], f'{1:>7.2%} Complete', fileIndex+1, totalFiles)
      Ind.Increment()
      writeStdout(formatKeyValueList(Ind.Spaces(), ['Source', source_displayname], '\n'))
      writeStdout(formatKeyValueList(Ind.Spaces(), ['Target', target_displayname], '\n'))
      Ind.Decrement()
    else:
      total_bytes = float(response.get('objectSize'))
      done_bytes = float(response.get('totalBytesRewritten'))
      entityActionPerformedMessage([Ent.CLOUD_STORAGE_FILE, None], f'{(done_bytes / total_bytes):>7.2%} Complete', fileIndex+1, totalFiles)
      Ind.Increment()
      writeStdout(formatKeyValueList(Ind.Spaces(), ['Source', source_displayname], '\n'))
      writeStdout(formatKeyValueList(Ind.Spaces(), ['Target', target_displayname], '\n'))
      Ind.Decrement()
      files_to_copy[fileIndex]['method']['rewriteToken'] = response.get('rewriteToken')
      next_batch.add(s.objects().rewrite(**files_to_copy[fileIndex]['method']), request_id=request_id)

  action = Act.Get()
  Act.Set(Act.COPY)
  s = buildGAPIObject(API.STORAGEWRITE)
  sbatch = s.new_batch_http_request(callback=process_rewrite)
  files_to_copy = []
  for object_ in objects:
    files_to_copy.append(
      {
        'md5Hash': object_['md5Hash'],
        'source_displayname': f'{object_["bucket"]}:{object_["name"]}',
        'target_displayname': f'{target_bucket}:{target_prefix}{object_["name"]}',
        'method': {
          'destinationBucket': target_bucket,
          'destinationObject': f'{target_prefix}{object_["name"]}',
          'sourceBucket': object_['bucket'],
          'sourceObject': object_['name'],
          'maxBytesRewrittenPerCall': 1048576, # uncomment to easily test multiple rewrite API calls per object
        },
      })
  totalFiles = len(files_to_copy)
  i = 0
  try:
    for file in files_to_copy:
      while len(sbatch._order) == 100:
        next_batch = s.new_batch_http_request(callback=process_rewrite)
        sbatch.execute()
        sbatch = next_batch
      sbatch.add(s.objects().rewrite(**file['method']), request_id=str(i))
      i += 1
    while len(sbatch._order) > 0:
      next_batch = s.new_batch_http_request(callback=process_rewrite)
      sbatch.execute()
      sbatch = next_batch
  except GAPI.notFound:
    Act.Set(action)
    entityDoesNotExistExit(Ent.CLOUD_STORAGE_BUCKET, target_bucket)
  except Exception:
    ClientAPIAccessDeniedExit()
  Act.Set(action)

def _getCloudStorageObject(s, bucket, s_object, localFilename, expectedMd5=None, zipToStdout=False, j=0, jcount=0):
  if not zipToStdout:
    localFilename = cleanFilepath(localFilename)
    entityValueList = [Ent.DRIVE_FILE, localFilename]
    if os.path.exists(localFilename):
      printEntityMessage(entityValueList, Msg.EXISTS)
      if not expectedMd5:
        return # nothing to verify, just assume we're good.
      if md5MatchesFile(localFilename, expectedMd5):
        return
      printEntityMessage(entityValueList, Msg.DOWNLOADING_AGAIN_AND_OVER_WRITING)
    entityPerformAction(entityValueList)
    file_path = os.path.dirname(localFilename)
    if not os.path.exists(file_path):
      os.makedirs(file_path)
    f = openFile(localFilename, 'wb')
  else:
    f = openFile('-', 'wb')
  try:
    request = s.objects().get_media(bucket=bucket, object=s_object)
    downloader = googleapiclient.http.MediaIoBaseDownload(f, request)
    done = False
    while not done:
      status, done = downloader.next_chunk()
      if not zipToStdout and status.progress() < 1.0:
        entityActionPerformedMessage([Ent.CLOUD_STORAGE_FILE, s_object], f'{status.progress():>7.2%}', j, jcount)
    if not zipToStdout:
      entityModifierNewValueActionPerformed([Ent.CLOUD_STORAGE_FILE, s_object], Act.MODIFIER_TO, localFilename, j, jcount)
      closeFile(f, True)
    if expectedMd5 and not md5MatchesFile(localFilename, expectedMd5):
      systemErrorExit(FILE_ERROR_RC, fileErrorMessage(localFilename, Msg.CORRUPT_FILE))
  except googleapiclient.http.HttpError as e:
    mg = HTTP_ERROR_PATTERN.match(str(e))
    entityModifierNewValueActionFailedWarning([Ent.CLOUD_STORAGE_FILE, s_object], Act.MODIFIER_TO, localFilename, mg.group(1) if mg else str(e), j, jcount)

TAKEOUT_EXPORT_PATTERN = re.compile(r'(takeout-export-[a-f,0-9,-]*)')

# gam copy storagebucket sourcebucket <StorageBucketName> targetbucket <StorageBucketName>
#	[sourceprefix <String>] [targetprefix <String>]
def doCopyCloudStorageBucket():
  s = buildGAPIObject(API.STORAGEREAD)
  source_bucket = None
  target_bucket = None
  source_prefix = None
  target_prefix = ''
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'sourcebucket':
      source_bucket = getString(Cmd.OB_URL)
    elif myarg == 'targetbucket':
      target_bucket = getString(Cmd.OB_URL)
    elif myarg == 'sourceprefix':
      source_prefix = getString(Cmd.OB_STRING, minLen=0)
    elif myarg == 'targetprefix':
      target_prefix = getString(Cmd.OB_STRING, minLen=0)
    else:
      unknownArgumentExit()
  if not target_bucket:
    missingArgumentExit('targetbucket')
  if not source_bucket:
    missingArgumentExit('sourcebucket')
  printGettingAllAccountEntities(Ent.FILE)
  pageMessage = getPageMessage()
  try:
    objects = callGAPIpages(s.objects(), 'list', 'items',
                            pageMessage=pageMessage,
                            throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                            bucket=source_bucket, prefix=source_prefix,
                            fields='nextPageToken,items(name,bucket,md5Hash)')
  except GAPI.notFound:
    entityDoesNotExistExit(Ent.CLOUD_STORAGE_BUCKET, source_bucket)
  except GAPI.forbidden as e:
    entityActionFailedExit([Ent.CLOUD_STORAGE_BUCKET, source_bucket], str(e))
  _copyStorageObjects(objects, target_bucket, target_prefix)

# gam download storagebucket <TakeoutBucketName>
#	[targetfolder <FilePath>]
def doDownloadCloudStorageBucket():
  bucket_url = getString(Cmd.OB_STRING)
  targetFolder = GC.Values[GC.DRIVE_DIR]
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'targetfolder':
      targetFolder = os.path.expanduser(getString(Cmd.OB_FILE_PATH))
      if not os.path.isdir(targetFolder):
        os.makedirs(targetFolder)
    else:
      unknownArgumentExit()
  bucket_match = re.search(TAKEOUT_EXPORT_PATTERN, bucket_url)
  if not bucket_match:
    systemErrorExit(ACTION_NOT_PERFORMED_RC, f'Could not find a takeout-export-* bucket in {bucket_url}')
  bucket = bucket_match.group(1)
  s = buildGAPIObject(API.STORAGEREAD)
  printGettingAllAccountEntities(Ent.FILE)
  pageMessage = getPageMessage()
  try:
    objects = callGAPIpages(s.objects(), 'list', 'items',
                            pageMessage=pageMessage,
                            throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                            bucket=bucket, projection='noAcl', fields='nextPageToken,items(name,md5Hash)')
  except GAPI.notFound:
    entityDoesNotExistExit(Ent.CLOUD_STORAGE_BUCKET, bucket)
  except GAPI.forbidden as e:
    entityActionFailedExit([Ent.CLOUD_STORAGE_BUCKET, bucket], str(e))
  count = len(objects)
  i = 0
  for s_object in objects:
    i += 1
    printGettingEntityItem(Ent.FILE, s_object['name'], i, count)
    expectedMd5 = base64.b64decode(s_object['md5Hash']).hex()
    _getCloudStorageObject(s, bucket, s_object['name'], os.path.join(targetFolder, s_object['name']),
                           expectedMd5=expectedMd5)

# gam download storagefile <StorageBucketObjectName>
#	[targetfolder <FilePath>] [overwrite [<Boolean>]] [nogcspath [Boolean>]]
def doDownloadCloudStorageFile():
  bucket, s_object, bucketObject = getBucketObjectName()
  targetFolder = GC.Values[GC.DRIVE_DIR]
  overwrite = nogcspath = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'targetfolder':
      targetFolder = os.path.expanduser(getString(Cmd.OB_FILE_PATH))
      if not os.path.isdir(targetFolder):
        os.makedirs(targetFolder)
    elif myarg == 'overwrite':
      overwrite = getBoolean()
    elif myarg == 'nogcspath':
      nogcspath = getBoolean()
    else:
      unknownArgumentExit()
  s_obpaths = s_object.rsplit('/', 1)
  s_obfile = s_obpaths[-1]
  if len(s_obpaths) > 1 and not nogcspath:
    targetFolder = os.path.join(targetFolder, s_obpaths[0])
  filename, _ = uniqueFilename(targetFolder, s_obfile, overwrite)
  filepath = os.path.dirname(filename)
  if not os.path.exists(filepath):
    os.makedirs(filepath)
  s = buildGAPIObject(API.STORAGEREAD)
  printGettingEntityItem(Ent.FILE, s_object)
  f = openFile(filename, 'wb')
  try:
    request = s.objects().get_media(bucket=bucket, object=s_object)
    downloader = googleapiclient.http.MediaIoBaseDownload(f, request)
    done = False
    while not done:
      _, done = downloader.next_chunk()
    entityModifierNewValueActionPerformed([Ent.CLOUD_STORAGE_FILE, s_object], Act.MODIFIER_TO, filename)
    closeFile(f, True)
  except googleapiclient.http.HttpError as e:
    mg = HTTP_ERROR_PATTERN.match(str(e))
    entityActionFailedWarning([Ent.CLOUD_STORAGE_FILE, bucketObject], mg.group(1) if mg else str(e))

def formatVaultNameId(vaultName, vaultId):
  return f'{vaultName}({vaultId})'

def convertExportNameToID(v, nameOrId, matterId, matterNameId):
  cg = UID_PATTERN.match(nameOrId)
  if cg:
    try:
      export = callGAPI(v.matters().exports(), 'get',
                        throwReasons=[GAPI.NOT_FOUND, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                        matterId=matterId, exportId=cg.group(1))
      return (export['id'], export['name'], formatVaultNameId(export['id'], export['name']))
    except (GAPI.notFound, GAPI.badRequest):
      entityDoesNotHaveItemExit([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_EXPORT, nameOrId])
    except (GAPI.forbidden, GAPI.invalidArgument) as e:
      ClientAPIAccessDeniedExit(str(e))
  nameOrIdlower = nameOrId.lower()
  try:
    exports = callGAPIpages(v.matters().exports(), 'list', 'exports',
                            throwReasons=[GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                            matterId=matterId, fields='exports(id,name),nextPageToken')
  except (GAPI.forbidden, GAPI.invalidArgument) as e:
    ClientAPIAccessDeniedExit(str(e))
  for export in exports:
    if export['name'].lower() == nameOrIdlower:
      return (export['id'], export['name'], formatVaultNameId(export['id'], export['name']))
  entityDoesNotHaveItemExit([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_EXPORT, nameOrId])

def convertHoldNameToID(v, nameOrId, matterId, matterNameId):
  cg = UID_PATTERN.match(nameOrId)
  if cg:
    try:
      hold = callGAPI(v.matters().holds(), 'get',
                      throwReasons=[GAPI.NOT_FOUND, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                      matterId=matterId, holdId=cg.group(1))
      return (hold['holdId'], hold['name'], formatVaultNameId(hold['holdId'], hold['name']))
    except (GAPI.notFound, GAPI.badRequest):
      entityDoesNotHaveItemExit([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, nameOrId])
    except (GAPI.forbidden, GAPI.invalidArgument) as e:
      ClientAPIAccessDeniedExit(str(e))
  nameOrIdlower = nameOrId.lower()
  try:
    holds = callGAPIpages(v.matters().holds(), 'list', 'holds',
                          throwReasons=[GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                          matterId=matterId, fields='holds(holdId,name),nextPageToken')
  except (GAPI.forbidden, GAPI.invalidArgument) as e:
    ClientAPIAccessDeniedExit(str(e))
  for hold in holds:
    if hold['name'].lower() == nameOrIdlower:
      return (hold['holdId'], hold['name'], formatVaultNameId(hold['holdId'], hold['name']))
  entityDoesNotHaveItemExit([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, nameOrId])

def convertMatterNameToID(v, nameOrId, state=None):
  cg = UID_PATTERN.match(nameOrId)
  if cg:
    try:
      matter = callGAPI(v.matters(), 'get',
                        throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                        matterId=cg.group(1), view='BASIC', fields='matterId,name,state')
      return (matter['matterId'], matter['name'], formatVaultNameId(matter['name'], matter['matterId']), matter['state'])
    except (GAPI.notFound, GAPI.forbidden):
      entityDoesNotExistExit(Ent.VAULT_MATTER, nameOrId)
  try:
    matters = callGAPIpages(v.matters(), 'list', 'matters',
                            throwReasons=[GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                            view='BASIC', state=state, fields='matters(matterId,name,state),nextPageToken')
  except (GAPI.forbidden, GAPI.invalidArgument) as e:
    ClientAPIAccessDeniedExit(str(e))
  nameOrIdlower = nameOrId.lower()
  ids = []
  states = []
  for matter in matters:
    if matter['name'].lower() == nameOrIdlower:
      nameOrId = matter['name']
      ids.append(matter['matterId'])
      states.append(matter['state'])
  if len(ids) == 1:
    return (ids[0], nameOrId, formatVaultNameId(nameOrId, ids[0]), states[0])
  if not ids:
    entityDoesNotExistExit(Ent.VAULT_MATTER, nameOrId)
  else:
    entityIsNotUniqueExit(Ent.VAULT_MATTER, nameOrId, Ent.VAULT_MATTER_ID, ids)

def convertQueryNameToID(v, nameOrId, matterId, matterNameId):
  cg = UID_PATTERN.match(nameOrId)
  if cg:
    try:
      query = callGAPI(v.matters().savedQueries(), 'get',
                       throwReasons=[GAPI.NOT_FOUND, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                       matterId=matterId, savedQueryId=cg.group(1))
      return (query['savedQueryId'], query['displayName'], formatVaultNameId(query['savedQueryId'], query['displayName']))
    except (GAPI.notFound, GAPI.badRequest):
      entityDoesNotHaveItemExit([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_QUERY, nameOrId])
    except (GAPI.forbidden, GAPI.invalidArgument) as e:
      ClientAPIAccessDeniedExit(str(e))
  nameOrIdlower = nameOrId.lower()
  try:
    queries = callGAPIpages(v.matters().savedQueries(), 'list', 'savedQueries',
                            throwReasons=[GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                            matterId=matterId, fields='savedQueries(savedQueryId,displayName),nextPageToken')
  except (GAPI.forbidden, GAPI.invalidArgument) as e:
    ClientAPIAccessDeniedExit(str(e))
  for query in queries:
    if query['displayName'].lower() == nameOrIdlower:
      return (query['savedQueryId'], query['displayName'], formatVaultNameId(query['savedQueryId'], query['displayName']))
  entityDoesNotHaveItemExit([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_QUERY, nameOrId])

def getMatterItem(v, state=None):
  matterId, _, matterNameId, _ = convertMatterNameToID(v, getString(Cmd.OB_MATTER_ITEM), state=state)
  return (matterId, matterNameId)

def warnMatterNotOpen(v, matter, matterNameId, j, jcount):
  if v is not None:
    try:
      matter['state'] = callGAPI(v.matters(), 'get',
                                 throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                                 matterId=matter['matterId'], view='BASIC', fields='state')['state']
    except (GAPI.notFound, GAPI.forbidden, GAPI.invalidArgument):
      matter['state'] = 'Unknown'
  else:
    setSysExitRC(DATA_NOT_AVALIABLE_RC)
  message = formatKeyValueList('',
                               Ent.FormatEntityValueList([Ent.VAULT_MATTER, matterNameId])+
                               [Msg.MATTER_NOT_OPEN.format(matter['state'])],
                               currentCount(j, jcount))
  writeStderr(f'\n{Ind.Spaces()}{WARNING_PREFIX}{message}\n')

def _cleanVaultExport(export, cd):
  query = export.get('query')
  if query:
    if cd is not None:
      if 'orgUnitInfo' in query:
        query['orgUnitInfo']['orgUnitPath'] = convertOrgUnitIDtoPath(cd, query['orgUnitInfo']['orgUnitId'])

VAULT_EXPORT_TIME_OBJECTS = {'versionDate', 'createTime', 'startTime', 'endTime'}

def _showVaultExport(matterNameId, export, cd, FJQC, k=0, kcount=0):
  _cleanVaultExport(export, cd)
  if FJQC is not None and FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(export, timeObjects=VAULT_EXPORT_TIME_OBJECTS), ensure_ascii=False, sort_keys=False))
    return
  if matterNameId is not None:
    printEntity([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_EXPORT, formatVaultNameId(export['name'], export['id'])], k, kcount)
  Ind.Increment()
  showJSON(None, export, timeObjects=VAULT_EXPORT_TIME_OBJECTS)
  Ind.Decrement()

VAULT_SEARCH_METHODS_MAP = {
  'account': 'ACCOUNT',
  'accounts': 'ACCOUNT',
  'chatspace': 'ROOM',
  'chatspaces': 'ROOM',
  'entireorg': 'ENTIRE_ORG',
  'everyone': 'ENTIRE_ORG',
  'org': 'ORG_UNIT',
  'orgunit': 'ORG_UNIT',
  'ou': 'ORG_UNIT',
  'room': 'ROOM',
  'rooms': 'ROOM',
  'shareddrive': 'SHARED_DRIVE',
  'shareddrives': 'SHARED_DRIVE',
  'sitesurl': 'SITES_URL',
  'teamdrive': 'SHARED_DRIVE',
  'teamdrives': 'SHARED_DRIVE',
  }
VAULT_CORPUS_ARGUMENT_MAP = {
  'calendar': 'CALENDAR',
  'drive': 'DRIVE',
  'gemini': 'GEMINI',
  'groups': 'GROUPS',
  'hangoutschat': 'HANGOUTS_CHAT',
  'mail': 'MAIL',
  'voice': 'VOICE',
  }
VAULT_COUNTS_CORPUS_ARGUMENT_MAP = {
  'mail': 'MAIL',
  'groups': 'GROUPS',
  }
VAULT_RESPONSE_STATUS_MAP = {
  'accepted': 'ATTENDEE_RESPONSE_ACCEPTED',
  'declined': 'ATTENDEE_RESPONSE_DECLINED',
  'needsaction': 'ATTENDEE_RESPONSE_NEEDS_ACTION',
  'tentative': 'ATTENDEE_RESPONSE_TENTATIVE',
  }
VAULT_SHARED_DRIVES_OPTION_MAP = {
  'included': 'INCLUDED',
  'includedifaccountisnotamember': 'INCLUDED_IF_ACCOUNT_IS_NOT_A_MEMBER',
  'notincluded': 'NOT_INCLUDED',
  }
VAULT_VOICE_COVERED_DATA_MAP = {
  'calllogs': 'CALL_LOGS',
  'textmessages': 'TEXT_MESSAGES',
  'voicemails': 'VOICEMAILS',
  }
VAULT_EXPORT_DATASCOPE_MAP = {
  'alldata': 'ALL_DATA',
  'helddata': 'HELD_DATA',
  'unprocesseddata': 'UNPROCESSED_DATA',
  }
VAULT_EXPORT_FORMAT_MAP = {
  'ics': 'ICS',
  'mbox': 'MBOX',
  'pst': 'PST',
  'xml': 'XML',
  }
VAULT_CORPUS_EXPORT_FORMATS = {
  'CALENDAR': ['ICS', 'PST'],
  'DRIVE': [],
  'GEMINI': ['XML'],
  'GROUPS': ['MBOX', 'PST'],
  'HANGOUTS_CHAT': ['MBOX', 'PST'],
  'MAIL': ['MBOX', 'PST'],
  'VOICE' : ['MBOX', 'PST'],
  }
VAULT_CSE_OPTION_MAP = {
  'any': 'CLIENT_SIDE_ENCRYPTED_OPTION_ANY',
  'encrypted': 'CLIENT_SIDE_ENCRYPTED_OPTION_ENCRYPTED',
  'unencrypted': 'CLIENT_SIDE_ENCRYPTED_OPTION_UNENCRYPTED',
  }
VAULT_EXPORT_REGION_MAP = {
  'any': 'ANY',
  'europe': 'EUROPE',
  'us': 'US',
  }
VAULT_CORPUS_OPTIONS_MAP = {
  'CALENDAR': 'calendarOptions',
  'DRIVE': 'driveOptions',
  'GEMINI': 'geminiOptions',
  'GROUPS': 'groupsOptions',
  'HANGOUTS_CHAT': 'hangoutsChatOptions',
  'MAIL': 'mailOptions',
  'VOICE': 'voiceOptions',
  }
VAULT_CORPUS_QUERY_MAP = {
  'CALENDAR': None,
  'DRIVE': 'driveQuery',
  'GEMINI': None,
  'GROUPS': 'groupsQuery',
  'MAIL': 'mailQuery',
  'HANGOUTS_CHAT': 'hangoutsChatQuery',
  'VOICE': 'voiceQuery',
  }
VAULT_QUERY_ARGS = [
  'corpus', 'scope', 'terms', 'start', 'starttime', 'end', 'endtime', 'timezone',
# calendar
  'locationquery', 'peoplequery', 'minuswords', 'responsestatuses', 'caldendarversiondate',
# drive
  'driveclientsideencryption', 'driveversiondate', 'includeshareddrives', 'includeteamdrives', 'shareddrivesoption',
# hangoutsChat
  'includerooms',
# mail
  'mailclientsideencryption', 'excludedrafts',
# voice
  'covereddata',
  ] + list(VAULT_SEARCH_METHODS_MAP.keys())

def _buildVaultQuery(myarg, query, corpusArgumentMap):
  def _getQueryList(obNameList):
    itemList = getString(obNameList)
    if itemList != 'select':
      return itemList.replace(',', ' ').split()
    return getEntityList(obNameList)

  if not query:
    query['dataScope'] = 'ALL_DATA'
  if myarg == 'corpus':
    query['corpus'] = getChoice(corpusArgumentMap, mapChoice=True)
  elif myarg in VAULT_SEARCH_METHODS_MAP:
    if query.get('searchMethod'):
      Cmd.Backup()
      usageErrorExit(Msg.MULTIPLE_SEARCH_METHODS_SPECIFIED.format(formatChoiceList(VAULT_SEARCH_METHODS_MAP)))
    searchMethod = VAULT_SEARCH_METHODS_MAP[myarg]
    query['searchMethod'] = searchMethod
    if searchMethod == 'ACCOUNT':
      query['accountInfo'] = {'emails': getNormalizedEmailAddressEntity()}
    elif searchMethod == 'ORG_UNIT':
      query['orgUnitInfo'] = {'orgUnitId': getOrgUnitId()[1]}
    elif searchMethod == 'SHARED_DRIVE':
      query['sharedDriveInfo'] = {'sharedDriveIds': _getQueryList(Cmd.OB_SHAREDDRIVE_ID_LIST)}
    elif searchMethod == 'ROOM':
      roomIds = _getQueryList(Cmd.OB_CHAT_SPACE_LIST)
      for i, roomId in enumerate(roomIds):
        if roomId.startswith('spaces/') or roomId.startswith('space/'):
          _, roomIds[i] = roomId.split('/', 1)
      query['hangoutsChatInfo'] = {'roomId': roomIds}
    elif searchMethod == 'SITES_URL':
      query['sitesUrlInfo'] = {'urls': _getQueryList(Cmd.OB_URL_LIST)}
  elif myarg == 'scope':
    query['dataScope'] = getChoice(VAULT_EXPORT_DATASCOPE_MAP, mapChoice=True)
  elif myarg == 'terms':
    query['terms'] = getString(Cmd.OB_STRING)
  elif myarg in {'start', 'starttime'}:
    query['startTime'] = getTimeOrDeltaFromNow()
  elif myarg in {'end', 'endtime'}:
    query['endTime'] = getTimeOrDeltaFromNow()
  elif myarg == 'timezone':
    query['timeZone'] = getString(Cmd.OB_STRING)
# calendar
  elif myarg == 'locationquery':
    query.setdefault('calendarOptions', {})['locationQuery'] = shlexSplitList(getString(Cmd.OB_STRING_LIST))
  elif myarg == 'peoplequery':
    query.setdefault('calendarOptions', {})['peopleQuery'] = shlexSplitList(getString(Cmd.OB_STRING_LIST))
  elif myarg == 'minuswords':
    query.setdefault('calendarOptions', {})['minusWords'] = shlexSplitList(getString(Cmd.OB_STRING_LIST))
  elif myarg == 'responsestatuses':
    query.setdefault('calendarOptions', {})['responseStatuses'] = []
    for response in getString(Cmd.OB_FIELD_NAME_LIST).lower().replace('_', '').replace(',', ' ').split():
      if response in VAULT_RESPONSE_STATUS_MAP:
        query['calendarOptions']['responseStatuses'].append(VAULT_RESPONSE_STATUS_MAP[response])
      else:
        invalidChoiceExit(response, VAULT_RESPONSE_STATUS_MAP, True)
  elif myarg == 'calendarversiondate':
    query.setdefault('calendarOptions', {})['versionDate'] = getTimeOrDeltaFromNow()
# drive
  elif myarg == 'driveversiondate':
    query.setdefault('driveOptions', {})['versionDate'] = getTimeOrDeltaFromNow()
  elif myarg in {'includeshareddrives', 'includeteamdrives'}:
    query.setdefault('driveOptions', {})['sharedDrivesOption'] = 'INCLUDED' if getBoolean() else 'INCLUDED_IF_ACCOUNT_IS_NOT_A_MEMBER'
  elif myarg == 'shareddrivesoption':
    query.setdefault('driveOptions', {})['sharedDrivesOption'] = getChoice(VAULT_SHARED_DRIVES_OPTION_MAP, mapChoice=True)
  elif myarg == 'driveclientsideencryption':
    query.setdefault('driveOptions', {})['clientSideEncryptedOption'] = getChoice(VAULT_CSE_OPTION_MAP, mapChoice=True)
# hangoutsChat
  elif myarg == 'includerooms':
    query['hangoutsChatOptions'] = {'includeRooms': getBoolean()}
# mail
  elif myarg == 'excludedrafts':
    query['mailOptions'] = {'excludeDrafts': getBoolean()}
  elif myarg == 'mailclientsideencryption':
    query.setdefault('mailOptions', {})['clientSideEncryptedOption'] = getChoice(VAULT_CSE_OPTION_MAP, mapChoice=True)
# voice
  elif myarg == 'covereddata':
    query['voiceOptions'] = {'coveredData': getChoice(VAULT_VOICE_COVERED_DATA_MAP, mapChoice=True)}

def _validateVaultQuery(body, corpusArgumentMap):
  if 'corpus' not in body['query']:
    missingArgumentExit(f'corpus {formatChoiceList(corpusArgumentMap)}')
  if 'searchMethod' not in body['query']:
    missingArgumentExit(formatChoiceList(VAULT_SEARCH_METHODS_MAP))
  if 'exportOptions' in body:
    for corpus, options in iter(VAULT_CORPUS_OPTIONS_MAP.items()):
      if body['query']['corpus'] != corpus:
        body['exportOptions'].pop(options, None)

# gam create vaultexport|export matter <MatterItem> [name <String>] corpus calendar|drive|gemini|groups|hangouts_chat|mail|voice
#	(accounts <EmailAddressEntity>) | (orgunit|org|ou <OrgUnitPath>) | everyone
#	(shareddrives|teamdrives (<SharedDriveIDList>|(select <FileSelector>|<CSVFileSelector>))) |
#	    (rooms (<ChatSpaceList>|(select <FileSelector>|<CSVFileSelector>))) |
#	    (sitesurl (<URLList>||(select <FileSelector>|<CSVFileSelector>)))
#	[scope <all_data|held_data|unprocessed_data>]
#	[terms <String>] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>] [timezone <TimeZone>]
#	[locationquery <StringList>] [peoplequery <StringList>] [minuswords <StringList>]
#	[responsestatuses <AttendeeStatus>(,<AttendeeStatus>)*] [calendarversiondate <Date>|<Time>]
#	[(includeshareddrives <Boolean>)|(shareddrivesoption included|included_if_account_is_not_a_member|not_included)]
#	[driveversiondate <Date>|<Time>] [includeaccessinfo <Boolean>]
#	[driveclientsideencryption any|encrypted|unencrypted]
#	[includerooms <Boolean>]
#	[excludedrafts <Boolean>] [mailclientsideencryption any|encrypted|unencrypted]
#	[showconfidentialmodecontent <Boolean>] [usenewexport <Boolean>] [exportlinkeddrivefiles <Boolean>]
#	[covereddata calllogs|textmessages|voicemails]
#	[format ics|mbox|pst|xml]
#	[region any|europe|us] [showdetails|returnidonly]
def doCreateVaultExport():
  v = buildGAPIObject(API.VAULT)
  matterId = None
  body = {'query': {'dataScope': 'ALL_DATA'}, 'exportOptions': {}}
  exportFormat = None
  showConfidentialModeContent = None
  exportLinkedDriveFiles = None
  returnIdOnly = showDetails = False
  useNewExport = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'matter':
      matterId, matterNameId = getMatterItem(v, state='OPEN')
      body['matterId'] = matterId
    elif myarg == 'name':
      body['name'] = getString(Cmd.OB_STRING)
    elif myarg in VAULT_QUERY_ARGS:
      _buildVaultQuery(myarg, body['query'], VAULT_CORPUS_ARGUMENT_MAP)
    elif myarg == 'usenewexport':
      useNewExport = getBoolean()
    elif myarg == 'format':
      exportFormat = getChoice(VAULT_EXPORT_FORMAT_MAP, mapChoice=True)
    elif myarg == 'showconfidentialmodecontent':
      showConfidentialModeContent = getBoolean()
    elif myarg == 'exportlinkeddrivefiles':
      exportLinkedDriveFiles = getBoolean()
    elif myarg == 'region':
      body['exportOptions']['region'] = getChoice(VAULT_EXPORT_REGION_MAP, mapChoice=True)
    elif myarg == 'includeaccessinfo':
      body['exportOptions'].setdefault('driveOptions', {})['includeAccessInfo'] = getBoolean()
    elif myarg == 'covereddata':
      body['exportOptions'].setdefault('voiceOptions', {})['coveredData'] = getChoice(VAULT_VOICE_COVERED_DATA_MAP, mapChoice=True)
    elif myarg == 'showdetails':
      showDetails = True
      returnIdOnly = False
    elif myarg == 'returnidonly':
      returnIdOnly = True
      showDetails = False
    else:
      unknownArgumentExit()
  if not matterId:
    missingArgumentExit('matter')
  _validateVaultQuery(body, VAULT_CORPUS_ARGUMENT_MAP)
  if exportFormat is not None:
    if not exportFormat in VAULT_CORPUS_EXPORT_FORMATS[body['query']['corpus']]:
      invalidChoiceExit(exportFormat, VAULT_CORPUS_EXPORT_FORMATS[body['query']['corpus']], False)
  elif body['query']['corpus'] != 'DRIVE':
    exportFormat = VAULT_CORPUS_EXPORT_FORMATS[body['query']['corpus']][0]
  if 'name' not in body:
    body['name'] = f'GAM {body["query"]["corpus"]} Export - {ISOformatTimeStamp(todaysTime())}'
  optionsField = VAULT_CORPUS_OPTIONS_MAP[body['query']['corpus']]
  if body['query']['corpus'] != 'DRIVE':
    body['exportOptions'][optionsField] = {'exportFormat': exportFormat}
    if body['query']['corpus'] == 'MAIL':
      if showConfidentialModeContent is not None:
        body['exportOptions'][optionsField]['showConfidentialModeContent'] = showConfidentialModeContent
      if useNewExport is not None:
        body['exportOptions'][optionsField]['useNewExport'] = useNewExport
      if exportLinkedDriveFiles is not None:
        body['exportOptions'][optionsField]['exportLinkedDriveFiles'] = exportLinkedDriveFiles
  try:
    export = callGAPI(v.matters().exports(), 'create',
                      throwReasons=[GAPI.ALREADY_EXISTS, GAPI.BAD_REQUEST, GAPI.BACKEND_ERROR, GAPI.INVALID_ARGUMENT,
                                    GAPI.INVALID, GAPI.FAILED_PRECONDITION, GAPI.FORBIDDEN, GAPI.QUOTA_EXCEEDED, GAPI.NOT_FOUND],
                      matterId=matterId, body=body)
    if not returnIdOnly:
      entityActionPerformed([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_EXPORT, formatVaultNameId(export['name'], export['id'])])
      if showDetails:
        _showVaultExport(None, export, None, None)
    else:
      writeStdout(f'{export["id"]}\n')
  except (GAPI.alreadyExists, GAPI.badRequest, GAPI.backendError, GAPI.invalidArgument,
          GAPI.invalid, GAPI.failedPrecondition, GAPI.forbidden, GAPI.quotaExceeded, GAPI.notFound) as e:
    entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_EXPORT, body.get('name')], str(e))

# gam delete vaultexport|export <ExportItem> matter <MatterItem>
# gam delete vaultexport|export <MatterItem> <ExportItem>
def doDeleteVaultExport():
  v = buildGAPIObject(API.VAULT)
  if not Cmd.ArgumentIsAhead('matter'):
    matterId, matterNameId = getMatterItem(v)
    exportId, exportName, exportNameId = convertExportNameToID(v, getString(Cmd.OB_EXPORT_ITEM), matterId, matterNameId)
  else:
    exportName = getString(Cmd.OB_EXPORT_ITEM)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'matter':
      matterId, matterNameId = getMatterItem(v)
      exportId, exportName, exportNameId = convertExportNameToID(v, exportName, matterId, matterNameId)
    else:
      unknownArgumentExit()
  try:
    callGAPI(v.matters().exports(), 'delete',
             throwReasons=[GAPI.NOT_FOUND, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
             matterId=matterId, exportId=exportId)
    entityActionPerformed([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_EXPORT, exportNameId])
  except (GAPI.notFound, GAPI.badRequest, GAPI.forbidden, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_EXPORT, exportNameId], str(e))

VAULT_EXPORT_FIELDS_CHOICE_MAP = {
  'cloudstoragesink': 'cloudStorageSink',
  'createtime': 'createTime',
  'exportoptions': 'exportOptions',
  'id': 'id',
  'matterid': 'matterId',
  'name': 'name',
  'query': 'query',
  'requester': 'requester',
  'requester.displayname': 'requester.displayName',
  'requester.email': 'requester.email',
  'stats': 'stats',
  'stats.exportedartifactcount': 'stats.exportedArtifactCount',
  'stats.sizeinbytes': 'stats.sizeInBytes',
  'stats.totalartifactcount': 'stats.totalArtifactCount',
  'status': 'status',
  }

# gam info vaultexport|export <ExportItem> matter <MatterItem>
#	[fields <VaultExportFieldNameList>] [shownames]
#	[formatjson]
# gam info vaultexport|export <MatterItem> <ExportItem>
#	[fields <VaultExportFieldNameList>] [shownames]
#	[formatjson]
def doInfoVaultExport():
  v = buildGAPIObject(API.VAULT)
  if not Cmd.ArgumentIsAhead('matter'):
    matterId, matterNameId = getMatterItem(v)
    exportId, exportName, exportNameId = convertExportNameToID(v, getString(Cmd.OB_EXPORT_ITEM), matterId, matterNameId)
  else:
    exportName = getString(Cmd.OB_EXPORT_ITEM)
  cd = None
  fieldsList = []
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'matter':
      matterId, matterNameId = getMatterItem(v)
      exportId, exportName, exportNameId = convertExportNameToID(v, exportName, matterId, matterNameId)
    elif myarg == 'shownames':
      cd = buildGAPIObject(API.DIRECTORY)
    elif getFieldsList(myarg, VAULT_EXPORT_FIELDS_CHOICE_MAP, fieldsList, initialField=['id', 'name']):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  fields = getFieldsFromFieldsList(fieldsList)
  try:
    export = callGAPI(v.matters().exports(), 'get',
                      throwReasons=[GAPI.NOT_FOUND, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                      matterId=matterId, exportId=exportId, fields=fields)
    _showVaultExport(matterNameId, export, cd, FJQC)
  except (GAPI.notFound, GAPI.badRequest, GAPI.forbidden, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_EXPORT, exportNameId], str(e))

VAULT_EXPORT_STATUS_MAP = {'completed': 'COMPLETED', 'failed': 'FAILED', 'inprogress': 'IN_PROGRESS'}
PRINT_VAULT_EXPORTS_TITLES = ['matterId', 'matterName', 'id', 'name']

# gam print vaultexports|exports [todrive <ToDriveAttribute>*]
#	[matters <MatterItemList>] [exportstatus <ExportStatusList>]
#	[fields <VaultExportFieldNameList>] [shownames]
#	[formatjson [quotechar <Character>]]
#	[oneitemperrow]
# gam show vaultexports|exports
#	[matters <MatterItemList>] [exportstatus <ExportStatusList>]
#	[fields <VaultExportFieldNameList>] [shownames]
#	[formatjson]
def doPrintShowVaultExports():
  def _printVaultExport(export):
    row = flattenJSON(export, flattened={'matterId': matterId, 'matterName': matterName}, timeObjects=VAULT_EXPORT_TIME_OBJECTS)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'matterId': matterId, 'matterName': matterName,
                              'id': export['id'], 'name': export['name'],
                              'JSON': json.dumps(cleanJSON(export, timeObjects=VAULT_EXPORT_TIME_OBJECTS), ensure_ascii=False, sort_keys=True)})

  v = buildGAPIObject(API.VAULT)
  csvPF = CSVPrintFile(PRINT_VAULT_EXPORTS_TITLES, 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar()
  matters = []
  exportStatusList = []
  cd = None
  fieldsList = []
  oneItemPerRow = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'matter', 'matters'}:
      matters = shlexSplitList(getString(Cmd.OB_MATTER_ITEM_LIST))
    elif myarg == 'exportstatus':
      for state in getString(Cmd.OB_STATE_NAME_LIST).lower().replace('_', '').replace(',', ' ').split():
        if state in VAULT_EXPORT_STATUS_MAP:
          exportStatusList.append(VAULT_EXPORT_STATUS_MAP[state])
        else:
          invalidChoiceExit(state, list(VAULT_EXPORT_STATUS_MAP), True)
    elif myarg == 'shownames':
      cd = buildGAPIObject(API.DIRECTORY)
    elif getFieldsList(myarg, VAULT_EXPORT_FIELDS_CHOICE_MAP, fieldsList, initialField=['id', 'name']):
      pass
    elif csvPF and myarg == 'oneitemperrow':
      oneItemPerRow = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  fields = getItemFieldsFromFieldsList('exports', fieldsList)
  if csvPF and FJQC.formatJSON:
    csvPF.SetJSONTitles(PRINT_VAULT_EXPORTS_TITLES+['JSON'])
  exportStatuses = set(exportStatusList)
  exportQualifier = f' ({",".join(exportStatusList)})' if exportStatusList else ''
  if not matters:
    printGettingAllAccountEntities(Ent.VAULT_MATTER, qualifier=' (OPEN)')
    try:
      results = callGAPIpages(v.matters(), 'list', 'matters',
                              pageMessage=getPageMessage(),
                              throwReasons=[GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                              view='BASIC', state='OPEN', fields='matters(matterId,name,state),nextPageToken')
    except (GAPI.forbidden, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.VAULT_EXPORT, None], str(e))
      return
  else:
    results = []
    for matter in matters:
      matterId, matterName, _, state = convertMatterNameToID(v, matter)
      results.append({'matterId': matterId, 'name': matterName, 'state': state})
  jcount = len(results)
  if not csvPF:
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
  j = 0
  for matter in results:
    j += 1
    matterId = matter['matterId']
    matterName = matter['name']
    matterNameId = formatVaultNameId(matterName, matterId)
    if csvPF:
      printGettingAllEntityItemsForWhom(Ent.VAULT_EXPORT, f'{Ent.Singular(Ent.VAULT_MATTER)}: {matterNameId}',
                                        j, jcount, qualifier=exportQualifier)
      pageMessage = getPageMessageForWhom()
    else:
      pageMessage = None
    if matter['state'] == 'OPEN':
      try:
        exports = callGAPIpages(v.matters().exports(), 'list', 'exports',
                                pageMessage=pageMessage,
                                throwReasons=[GAPI.FAILED_PRECONDITION, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                                matterId=matterId, fields=fields)
      except GAPI.failedPrecondition:
        warnMatterNotOpen(v, matter, matterNameId, j, jcount)
        continue
      except (GAPI.forbidden, GAPI.invalidArgument) as e:
        entityActionFailedWarning([Ent.VAULT_EXPORT, None], str(e))
        break
    else:
      warnMatterNotOpen(None, matter, matterNameId, j, jcount)
      continue
    kcount = len(exports)
    if not csvPF:
      if not FJQC.formatJSON:
        entityPerformActionNumItems([Ent.VAULT_MATTER, matterNameId], kcount, Ent.VAULT_EXPORT, j, jcount)
      Ind.Increment()
      k = 0
      for export in exports:
        k += 1
        if not exportStatuses or export['status'] in exportStatuses:
          _showVaultExport(matterNameId, export, cd, FJQC, k, kcount)
      Ind.Decrement()
    else:
      for export in exports:
        if not exportStatuses or export['status'] in exportStatuses:
          _cleanVaultExport(export, cd)
          if not oneItemPerRow or not export.get('cloudStorageSink', {}).get('files'):
            _printVaultExport(export)
          else:
            for file in export['cloudStorageSink'].pop('files'):
              export['cloudStorageSink']['files'] = file
              _printVaultExport(export)
  if csvPF:
    csvPF.writeCSVfile('Vault Exports')

def md5MatchesFile(filename, expected_md5, j=0, jcount=0):
  action = Act.Get()
  Act.Set(Act.VERIFY)
  try:
    f = openFile(filename, 'rb')
    hash_md5 = hashlib.md5()
    for chunk in iter(lambda: f.read(4096), b""):
      hash_md5.update(chunk)
    closeFile(f)
    actual_hash = hash_md5.hexdigest()
    if actual_hash == expected_md5:
      entityActionPerformed([Ent.FILE, filename, Ent.MD5HASH, expected_md5], j, jcount)
      Act.Set(action)
      return True
    entityActionFailedWarning([Ent.FILE, filename, Ent.MD5HASH, expected_md5], Msg.DOES_NOT_MATCH.format(actual_hash), j, jcount)
    Act.Set(action)
    return False
  except IOError as e:
    systemErrorExit(FILE_ERROR_RC, fileErrorMessage(filename, e))

# gam copy vaultexport|export <ExportItem> matter <MatterItem>
#	[targetbucket <String>] [targetprefix <String>]
#	[bucketmatchpattern <REMatchPattern>] [objectmatchpattern <REMatchPattern>]
#	[copyattempts <Integer>] [retryinterval <Integer>]
# gam copy vaultexport|export <MatterItem> <ExportItem>
#	[targetbucket <String>] [targetprefix <String>]
#	[bucketmatchpattern <REMatchPattern>] [objectmatchpattern <REMatchPattern>]
#	[copyattempts <Integer>] [retryinterval <Integer>]
def doCopyVaultExport():
  v = buildGAPIObject(API.VAULT)
  if not Cmd.ArgumentIsAhead('matter'):
    matterId, matterNameId = getMatterItem(v)
    exportId, exportName, exportNameId = convertExportNameToID(v, getString(Cmd.OB_EXPORT_ITEM), matterId, matterNameId)
  else:
    exportName = getString(Cmd.OB_EXPORT_ITEM)
  target_bucket = None
  target_prefix = ''
  bucketMatchPattern = objectMatchPattern = None
  copyAttempts = 1
  retryInterval = 30
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'matter':
      matterId, matterNameId = getMatterItem(v)
      exportId, exportName, exportNameId = convertExportNameToID(v, exportName, matterId, matterNameId)
    elif myarg == 'targetbucket':
      target_bucket = getString(Cmd.OB_URL)
    elif myarg == 'targetprefix':
      target_prefix = getString(Cmd.OB_STRING, minLen=0)
    elif myarg == 'bucketmatchpattern':
      bucketMatchPattern = getREPattern(re.IGNORECASE)
    elif myarg == 'objectmatchpattern':
      objectMatchPattern = getREPattern(re.IGNORECASE)
    elif myarg == 'copyattempts':
      copyAttempts = getInteger(minVal=1)
    elif myarg == 'retryinterval':
      retryInterval = getInteger(minVal=10)
    else:
      unknownArgumentExit()
  if not target_bucket:
    missingArgumentExit('targetbucket')
  attempts = 0
  while True:
    try:
      export = callGAPI(v.matters().exports(), 'get',
                        throwReasons=[GAPI.NOT_FOUND, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                        matterId=matterId, exportId=exportId)
    except (GAPI.notFound, GAPI.badRequest, GAPI.forbidden, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_EXPORT, exportNameId], str(e))
      return
    if export['status'] == 'COMPLETED':
      break
    attempts += 1
    entityActionNotPerformedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_EXPORT, exportNameId], Msg.EXPORT_NOT_COMPLETE.format(export['status']), attempts, copyAttempts)
    if attempts >= copyAttempts:
      return
    time.sleep(retryInterval)
  objects = []
  for s_file in export['cloudStorageSink']['files']:
    if ((bucketMatchPattern and not bucketMatchPattern.match(s_file['bucketName'])) or
        (objectMatchPattern and not objectMatchPattern.match(s_file['objectName']))):
      continue
    # Convert to md5Hash format Storage API uses because OF COURSE they differ
    md5Hash = base64.b64encode(bytes.fromhex(s_file['md5Hash'])).decode()
    objects.append({'bucket': s_file['bucketName'], 'name': s_file['objectName'], 'md5Hash': md5Hash})
  _copyStorageObjects(objects, target_bucket, target_prefix)

ZIP_EXTENSION_PATTERN = re.compile(r'^.*\.zip$', re.IGNORECASE)

# gam download vaultexport|export <ExportItem> matter <MatterItem>
#	[targetfolder <FilePath>] [targetname <FileName>] [noverify] [noextract] [ziptostdout]
#	[bucketmatchpattern <REMatchPattern>] [objectmatchpattern <REMatchPattern>]
#	[downloadattempts <Integer>] [retryinterval <Integer>]
# gam download vaultexport|export <MatterItem> <ExportItem>
#	[targetfolder <FilePath>] [targetname <FileName>] [noverify] [noextract] [ziptostdout]
#	[bucketmatchpattern <REMatchPattern>] [objectmatchpattern <REMatchPattern>]
#	[downloadattempts <Integer>] [retryinterval <Integer>]
def doDownloadVaultExport():
  def extract_nested_zip(zippedFile):
    """ Extract a zip file including any nested zip files
        Delete the zip file(s) after extraction
    """
    Act.Set(Act.UNZIP)
    performAction(Ent.FILE, zippedFile)
    Ind.Increment()
    with zipfile.ZipFile(zippedFile, 'r') as zfile:
      inner_files = zfile.infolist()
      for inner_file in inner_files:
        Act.Set(Act.EXTRACT)
        performAction(Ent.FILE, inner_file.filename)
        innerFilePath = zfile.extract(inner_file, targetFolder)
        if ZIP_EXTENSION_PATTERN.match(inner_file.filename):
          extract_nested_zip(innerFilePath)
    Ind.Decrement()
    try:
      os.remove(zippedFile)
    except OSError as e:
      stderrWarningMsg(e)

  v = buildGAPIObject(API.VAULT)
  s = buildGAPIObject(API.STORAGEREAD)
  verifyFiles = extractFiles = True
  targetFolder = GC.Values[GC.DRIVE_DIR]
  targetName = None
  if not Cmd.ArgumentIsAhead('matter'):
    matterId, matterNameId = getMatterItem(v)
    exportId, exportName, exportNameId = convertExportNameToID(v, getString(Cmd.OB_EXPORT_ITEM), matterId, matterNameId)
  else:
    exportName = getString(Cmd.OB_EXPORT_ITEM)
  zipToStdout = False
  bucketMatchPattern = objectMatchPattern = None
  downloadAttempts = 1
  retryInterval = 30
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'matter':
      matterId, matterNameId = getMatterItem(v)
      exportId, exportName, exportNameId = convertExportNameToID(v, exportName, matterId, matterNameId)
    elif myarg == 'targetname':
      targetName = getString(Cmd.OB_FILE_NAME)
    elif myarg == 'targetfolder':
      targetFolder = os.path.expanduser(getString(Cmd.OB_FILE_PATH))
      if not os.path.isdir(targetFolder):
        os.makedirs(targetFolder)
    elif myarg == 'noverify':
      verifyFiles = False
    elif myarg == 'noextract':
      extractFiles = False
    elif myarg == 'ziptostdout':
      zipToStdout = True
      verifyFiles = extractFiles = False
    elif myarg == 'bucketmatchpattern':
      bucketMatchPattern = getREPattern(re.IGNORECASE)
    elif myarg == 'objectmatchpattern':
      objectMatchPattern = getREPattern(re.IGNORECASE)
    elif myarg == 'downloadattempts':
      downloadAttempts = getInteger(minVal=1)
    elif myarg == 'retryinterval':
      retryInterval = getInteger(minVal=10)
    else:
      unknownArgumentExit()
  attempts = 0
  while True:
    try:
      export = callGAPI(v.matters().exports(), 'get',
                        throwReasons=[GAPI.NOT_FOUND, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                        matterId=matterId, exportId=exportId)
    except (GAPI.notFound, GAPI.badRequest, GAPI.forbidden, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_EXPORT, exportNameId], str(e))
      return
    if export['status'] == 'COMPLETED':
      break
    attempts += 1
    entityActionNotPerformedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_EXPORT, exportNameId], Msg.EXPORT_NOT_COMPLETE.format(export['status']), attempts, downloadAttempts)
    if attempts >= downloadAttempts:
      return
    time.sleep(retryInterval)
  jcount = len(export['cloudStorageSink']['files'])
  if not zipToStdout:
    if not bucketMatchPattern and not objectMatchPattern:
      entityPerformActionNumItems([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_EXPORT, exportNameId], jcount, Ent.CLOUD_STORAGE_FILE)
    else:
      entityPerformActionModifierNumItems([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_EXPORT, exportNameId], Msg.MAXIMUM_OF, jcount, Ent.CLOUD_STORAGE_FILE)
  Ind.Increment()
  j = 0
  extCounts = {}
  for s_file in export['cloudStorageSink']['files']:
    j += 1
    bucket = s_file['bucketName']
    s_object = s_file['objectName']
    if ((bucketMatchPattern and not bucketMatchPattern.match(bucket)) or
        (objectMatchPattern and not objectMatchPattern.match(s_object))):
      continue
    filename = os.path.join(targetFolder, cleanFilename(s_object))
    if zipToStdout and not ZIP_EXTENSION_PATTERN.match(filename):
      continue
    if targetName:
      _, s_objectFilename = s_object.rsplit('/', 1)
      if s_objectFilename.find('.') != -1:
        s_objectFilename, s_objectExtension = s_objectFilename.rsplit('.', 1)
      else:
        s_objectExtension = ''
      if targetName.find('#') == -1:
        extCounts.setdefault(s_objectExtension, 0)
        extCounts[s_objectExtension] += 1
        if s_objectExtension:
          filename = f"{targetName}-{extCounts[s_objectExtension]}.{s_objectExtension}"
        else:
          filename = f"{targetName}-{extCounts[s_objectExtension]}"
      else:
        filename = targetName.replace('#objectname#', s_object).replace('#filename#', s_objectFilename).replace('#extension#', s_objectExtension)
      filename = os.path.join(targetFolder, cleanFilename(filename))
    Act.Set(Act.DOWNLOAD)
    if not zipToStdout:
      performAction(Ent.CLOUD_STORAGE_FILE, s_object, j, jcount)
    Ind.Increment()
    _getCloudStorageObject(s, bucket, s_object, filename,
                           expectedMd5=s_file['md5Hash'] if verifyFiles else None,
                           zipToStdout=zipToStdout, j=j, jcount=jcount)
    if extractFiles and ZIP_EXTENSION_PATTERN.match(filename):
      Act.Set(Act.EXTRACT)
      extract_nested_zip(filename)
      Act.Set(Act.DOWNLOAD)
    Ind.Decrement()
  Ind.Decrement()

def _cleanVaultHold(hold, cd):
  if cd is not None:
    if 'accounts' in hold:
      accountType = 'group' if hold['corpus'] == 'GROUPS' else 'user'
      for i in range(0, len(hold['accounts'])):
        hold['accounts'][i]['email'] = convertUIDtoEmailAddress(f'uid:{hold["accounts"][i]["accountId"]}', cd, accountType)
    if 'orgUnit' in hold:
      hold['orgUnit']['orgUnitPath'] = convertOrgUnitIDtoPath(cd, hold['orgUnit']['orgUnitId'])
  query = hold.get('query')
  if query:
    if 'driveQuery' in hold['query']:
      hold['query']['driveQuery'].pop('includeTeamDriveFiles', None)

VAULT_HOLD_TIME_OBJECTS = {'holdTime', 'updateTime', 'startTime', 'endTime'}

def _showVaultHold(matterNameId, hold, cd, FJQC, k=0, kcount=0):
  _cleanVaultHold(hold, cd)
  if FJQC is not None and FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(hold, timeObjects=VAULT_HOLD_TIME_OBJECTS), ensure_ascii=False, sort_keys=False))
    return
  if matterNameId is not None:
    printEntity([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, formatVaultNameId(hold['name'], hold['holdId'])], k, kcount)
  Ind.Increment()
  showJSON(None, hold, timeObjects=VAULT_HOLD_TIME_OBJECTS)
  Ind.Decrement()

def _getHoldQueryParameters(myarg, queryParameters):
  if myarg == 'query':
    queryParameters['queryLocation'] = Cmd.Location()
    queryParameters['query'] = getString(Cmd.OB_QUERY)
  elif myarg == 'terms':
    queryParameters['terms'] = getString(Cmd.OB_STRING)
  elif myarg in {'start', 'starttime'}:
    queryParameters['startTime'] = getTimeOrDeltaFromNow()
  elif myarg in {'end', 'endtime'}:
    queryParameters['endTime'] = getTimeOrDeltaFromNow()
  elif myarg == 'includerooms':
    queryParameters['includeRooms'] = getBoolean()
  elif myarg in {'includeshareddrives', 'includeteamdrives'}:
    queryParameters['includeSharedDriveFiles'] = getBoolean()
  elif myarg == 'covereddata':
    queryParameters['coveredData'] = getChoice(VAULT_VOICE_COVERED_DATA_MAP, mapChoice=True)
  else:
    return False
  return True

def _setHoldQuery(body, queryParameters):
  queryType = VAULT_CORPUS_QUERY_MAP[body['corpus']]
  if queryType is None:
    return
  body['query'] = {queryType: {}}
  if body['corpus'] == 'DRIVE':
    if queryParameters.get('query'):
      try:
        body['query'][queryType] = json.loads(queryParameters['query'])
      except (IndexError, KeyError, SyntaxError, TypeError, ValueError) as e:
        Cmd.SetLocation(queryParameters['queryLocation'])
        usageErrorExit(f'{str(e)}: {queryParameters["query"]}')
    elif queryParameters.get('includeSharedDriveFiles'):
      body['query'][queryType]['includeSharedDriveFiles'] = queryParameters['includeSharedDriveFiles']
  elif body['corpus'] in {'GROUPS', 'MAIL'}:
    if queryParameters.get('query'):
      body['query'][queryType]['terms'] = queryParameters['query']
    elif queryParameters.get('terms'):
      body['query'][queryType]['terms'] = queryParameters['terms']
    if queryParameters.get('startTime'):
      body['query'][queryType]['startTime'] = queryParameters['startTime']
    if queryParameters.get('endTime'):
      body['query'][queryType]['endTime'] = queryParameters['endTime']
  elif body['corpus'] == 'HANGOUTS_CHAT':
    if queryParameters.get('includeRooms'):
      body['query'][queryType]['includeRooms'] = queryParameters['includeRooms']
  elif body['corpus'] == 'VOICE':
    if queryParameters.get('coveredData'):
      body['query'][queryType]['coveredData'] = queryParameters['coveredData']

# gam create vaulthold|hold matter <MatterItem> [name <String>] corpus calendar|drive|mail|groups|hangouts_chat|voice
#	[(accounts|groups|users <EmailItemList>) | (orgunit|org|ou <OrgUnit>)]
#	[query <QueryVaultCorpus>]
#	[terms <String>] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
#	[includerooms <Boolean>]
#	[covereddata calllogs|textmessages|voicemails]
#	[includeshareddrives <Boolean>]
#	[showdetails|returnidonly]
def doCreateVaultHold():
  v = buildGAPIObject(API.VAULT)
  body = {}
  matterId = None
  accounts = []
  queryParameters = {}
  returnIdOnly = showDetails = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'matter':
      matterId, matterNameId = getMatterItem(v)
    elif myarg == 'name':
      body['name'] = getString(Cmd.OB_STRING)
    elif myarg == 'corpus':
      body['corpus'] = getChoice(VAULT_CORPUS_ARGUMENT_MAP, mapChoice=True)
    elif myarg in {'accounts', 'users', 'groups'}:
      accountsLocation = Cmd.Location()
      accounts = getEntityList(Cmd.OB_EMAIL_ADDRESS_ENTITY)
    elif myarg in {'ou', 'org', 'orgunit'}:
      body['orgUnit'] = {'orgUnitId': getOrgUnitId()[1]}
    elif _getHoldQueryParameters(myarg, queryParameters):
      pass
    elif myarg == 'showdetails':
      showDetails = True
      returnIdOnly = False
    elif myarg == 'returnidonly':
      returnIdOnly = True
      showDetails = False
    else:
      unknownArgumentExit()
  if matterId is None:
    missingArgumentExit('matter')
  if not body.get('corpus'):
    missingArgumentExit(f'corpus {"|".join(VAULT_CORPUS_ARGUMENT_MAP)}')
  if 'name' not in body:
    body['name'] = f'GAM {body["corpus"]} Hold - {ISOformatTimeStamp(todaysTime())}'
  _setHoldQuery(body, queryParameters)
  if accounts:
    body['accounts'] = []
    cd = buildGAPIObject(API.DIRECTORY)
    accountType = 'group' if body['corpus'] == 'GROUPS' else 'user'
    for account in accounts:
      body['accounts'].append({'accountId': convertEmailAddressToUID(account, cd, accountType, accountsLocation)})
  try:
    hold = callGAPI(v.matters().holds(), 'create',
                    throwReasons=[GAPI.ALREADY_EXISTS, GAPI.BAD_REQUEST, GAPI.BACKEND_ERROR, GAPI.FAILED_PRECONDITION,
                                  GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                    matterId=matterId, body=body)
    if not returnIdOnly:
      entityActionPerformed([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, formatVaultNameId(hold['name'], hold['holdId'])])
      if showDetails:
        _showVaultHold(None, hold, None, None)
    else:
      writeStdout(f'{hold["holdId"]}\n')
  except (GAPI.alreadyExists, GAPI.badRequest, GAPI.backendError, GAPI.failedPrecondition,
          GAPI.forbidden, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, body.get('name')], str(e))

# gam update vaulthold|hold <HoldItem> matter <MatterItem>
#	[([addaccounts|addgroups|addusers <EmailItemList>] [removeaccounts|removegroups|removeusers <EmailItemList>]) | (orgunit|org|ou <OrgUnit>)]
#	[query <QueryVaultCorpus>]
#	[terms <String>] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
#	[includerooms <Boolean>]
#	[covereddata calllogs|textmessages|voicemails]
#	[includeshareddrives <Boolean>]
#	[showdetails]
def doUpdateVaultHold():
  v = buildGAPIObject(API.VAULT)
  holdName = getString(Cmd.OB_HOLD_ITEM)
  body = {}
  cd = matterId = None
  addAccounts = []
  addAccountIds = []
  removeAccounts = []
  removeAccountIds = []
  queryParameters = {}
  showDetails = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'matter':
      matterId, matterNameId = getMatterItem(v)
      holdId, holdName, holdNameId = convertHoldNameToID(v, holdName, matterId, matterNameId)
    elif myarg in {'addusers', 'addaccounts', 'addgroups'}:
      addAccountsLocation = Cmd.Location()
      addAccounts = getEntityList(Cmd.OB_EMAIL_ADDRESS_ENTITY)
    elif myarg in {'removeusers', 'removeaccounts', 'removegroups'}:
      removeAccountsLocation = Cmd.Location()
      removeAccounts = getEntityList(Cmd.OB_EMAIL_ADDRESS_ENTITY)
    elif myarg in {'ou', 'org', 'orgunit'}:
      body['orgUnit'] = {'orgUnitId': getOrgUnitId()[1]}
    elif _getHoldQueryParameters(myarg, queryParameters):
      pass
    elif myarg == 'showdetails':
      showDetails = True
    else:
      unknownArgumentExit()
  if matterId is None:
    missingArgumentExit('matter')
  try:
    old_body = callGAPI(v.matters().holds(), 'get',
                        throwReasons=[GAPI.NOT_FOUND, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                        matterId=matterId, holdId=holdId, fields='name,corpus,query,orgUnit')
  except (GAPI.notFound, GAPI.badRequest, GAPI.forbidden, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, holdNameId], str(e))
    return
  accountType = 'group' if old_body['corpus'] == 'GROUPS' else 'user'
  if addAccounts:
    if cd is None:
      cd = buildGAPIObject(API.DIRECTORY)
    for account in addAccounts:
      addAccountIds.append({'email': account, 'id': convertEmailAddressToUID(account, cd, accountType, addAccountsLocation)})
  if removeAccounts:
    if cd is None:
      cd = buildGAPIObject(API.DIRECTORY)
    for account in removeAccounts:
      removeAccountIds.append({'email': account, 'id': convertEmailAddressToUID(account, cd, accountType, removeAccountsLocation)})
  if queryParameters or body.get('orgUnit'):
    body['corpus'] = old_body['corpus']
    if 'orgUnit' in old_body and 'orgUnit' not in body:
      # bah, API requires this to be sent on update even when it's not changing
      body['orgUnit'] = old_body['orgUnit']
    if queryParameters:
      _setHoldQuery(body, queryParameters)
    else:
      body['query'] = old_body['query']
  if body:
    try:
      hold = callGAPI(v.matters().holds(), 'update',
                      throwReas=[GAPI.NOT_FOUND, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                      matterId=matterId, holdId=holdId, body=body)
      entityActionPerformed([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, holdNameId])
    except (GAPI.notFound, GAPI.badRequest, GAPI.forbidden, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, holdNameId], str(e))
      return
  jcount = len(addAccountIds)
  if jcount > 0:
    Act.Set(Act.ADD)
    entityPerformActionNumItems([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, holdNameId], jcount, Ent.ACCOUNT)
    Ind.Increment()
    j = 0
    for account in addAccountIds:
      j += 1
      try:
        callGAPI(v.matters().holds().accounts(), 'create',
                 throwReasons=[GAPI.ALREADY_EXISTS, GAPI.BACKEND_ERROR, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                 matterId=matterId, holdId=holdId, body={'accountId': account['id']})
        entityActionPerformed([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, holdNameId, Ent.ACCOUNT, account['email']], j, jcount)
      except (GAPI.alreadyExists, GAPI.backendError) as e:
        entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, holdNameId, Ent.ACCOUNT, account['email']], str(e), j, jcount)
      except (GAPI.forbidden, GAPI.invalidArgument) as e:
        entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, None], str(e))
        return
    Ind.Decrement()
  jcount = len(removeAccountIds)
  if jcount > 0:
    Act.Set(Act.REMOVE)
    if cd is None:
      cd = buildGAPIObject(API.DIRECTORY)
    entityPerformActionNumItems([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, holdNameId], jcount, Ent.ACCOUNT)
    Ind.Increment()
    j = 0
    for account in removeAccountIds:
      j += 1
      try:
        callGAPI(v.matters().holds().accounts(), 'delete',
                 throwReasons=[GAPI.NOT_FOUND, GAPI.BACKEND_ERROR, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                 matterId=matterId, holdId=holdId, accountId=account['id'])
        entityActionPerformed([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, holdNameId, Ent.ACCOUNT, account['email']], j, jcount)
      except (GAPI.alreadyExists, GAPI.backendError) as e:
        entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, holdNameId, Ent.ACCOUNT, account['email']], str(e), j, jcount)
      except (GAPI.forbidden, GAPI.invalidArgument) as e:
        entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, None], str(e))
        return
    Ind.Decrement()
  if showDetails:
    _showVaultHold(None, hold, cd, None)

# gam delete vaulthold|hold <HoldItem> matter <MatterItem>
# gam delete vaulthold|hold <MatterItem> <HoldItem>
def doDeleteVaultHold():
  v = buildGAPIObject(API.VAULT)
  if not Cmd.ArgumentIsAhead('matter'):
    matterId, matterNameId = getMatterItem(v)
    holdId, holdName, holdNameId = convertHoldNameToID(v, getString(Cmd.OB_HOLD_ITEM), matterId, matterNameId)
  else:
    holdName = getString(Cmd.OB_HOLD_ITEM)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'matter':
      matterId, matterNameId = getMatterItem(v)
      holdId, holdName, holdNameId = convertHoldNameToID(v, holdName, matterId, matterNameId)
    else:
      unknownArgumentExit()
  try:
    callGAPI(v.matters().holds(), 'delete',
             throwReasons=[GAPI.NOT_FOUND, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
             matterId=matterId, holdId=holdId)
    entityActionPerformed([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, holdNameId])
  except (GAPI.notFound, GAPI.badRequest, GAPI.forbidden, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, holdNameId], str(e))

VAULT_HOLD_FIELDS_CHOICE_MAP = {
  'accounts': 'accounts',
  'accounts.acountid': 'accounts.accountId',
  'accounts.email': 'accounts.email',
  'accounts.firstname': 'accounts.firstName',
  'accounts.holdtime': 'accounts.holdTime',
  'accounts.lastname': 'accounts.lastName',
  'corpus': 'corpus',
  'holdid': 'holdId',
  'name': 'name',
  'orgunit': 'orgUnit',
  'orgunit.holdtime': 'orgUnit.holdTime',
  'orgunit.ordunitid': 'orgUnit.orgUnitId',
  'query': 'query',
  'updatetime': 'updateTime',
  }

# gam info vaulthold|hold <HoldItem> matter <MatterItem>
#	[fields <VaultHoldFieldNameList>] [shownames]
#	[formatjson]
# gam info vaulthold|hold <MatterItem> <HoldItem>
#	[fields <VaultHoldFieldNameList>] [shownames]
#	[formatjson]
def doInfoVaultHold():
  v = buildGAPIObject(API.VAULT)
  if not Cmd.ArgumentIsAhead('matter'):
    matterId, matterNameId = getMatterItem(v)
    holdId, holdName, holdNameId = convertHoldNameToID(v, getString(Cmd.OB_HOLD_ITEM), matterId, matterNameId)
  else:
    holdName = getString(Cmd.OB_HOLD_ITEM)
  cd = None
  fieldsList = []
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'matter':
      matterId, matterNameId = getMatterItem(v)
      holdId, holdName, holdNameId = convertHoldNameToID(v, holdName, matterId, matterNameId)
    elif myarg == 'shownames':
      cd = buildGAPIObject(API.DIRECTORY)
    elif getFieldsList(myarg, VAULT_HOLD_FIELDS_CHOICE_MAP, fieldsList, initialField=['holdId', 'name']):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  fields = getFieldsFromFieldsList(fieldsList)
  try:
    hold = callGAPI(v.matters().holds(), 'get',
                    throwReasons=[GAPI.NOT_FOUND, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                    matterId=matterId, holdId=holdId, fields=fields)
    _showVaultHold(matterNameId, hold, cd, FJQC)
  except (GAPI.notFound, GAPI.badRequest, GAPI.forbidden, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_HOLD, holdNameId], str(e))

PRINT_VAULT_HOLDS_TITLES = ['matterId', 'matterName', 'holdId', 'name', 'updateTime']

# gam print vaultholds|holds [todrive <ToDriveAttribute>*] [matters <MatterItemList>]
#	[fields <VaultHoldFieldNameList>] [shownames]
#	[formatjson [quotechar <Character>]]
#	[oneitemperrow]
# gam show vaultholds|holds [matters <MatterItemList>]
#	[fields <VaultHoldFieldNameList>] [shownames]
#	[formatjson]
def doPrintShowVaultHolds():
  def _printVaultHold(hold):
    row = flattenJSON(hold, flattened={'matterId': matterId, 'matterName': matterName}, timeObjects=VAULT_HOLD_TIME_OBJECTS)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'matterId': matterId, 'matterName': matterName,
                              'holdId': hold['holdId'], 'name': hold['name'],
                              'JSON': json.dumps(cleanJSON(hold, timeObjects=VAULT_HOLD_TIME_OBJECTS), ensure_ascii=False, sort_keys=True)})

  v = buildGAPIObject(API.VAULT)
  csvPF = CSVPrintFile(PRINT_VAULT_HOLDS_TITLES, 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar()
  matters = []
  cd = None
  fieldsList = []
  oneItemPerRow = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'matter', 'matters'}:
      matters = shlexSplitList(getString(Cmd.OB_MATTER_ITEM_LIST))
    elif myarg == 'shownames':
      cd = buildGAPIObject(API.DIRECTORY)
    elif getFieldsList(myarg, VAULT_HOLD_FIELDS_CHOICE_MAP, fieldsList, initialField=['holdId', 'name']):
      pass
    elif csvPF and myarg == 'oneitemperrow':
      oneItemPerRow = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  fields = getItemFieldsFromFieldsList('holds', fieldsList)
  if csvPF and FJQC.formatJSON:
    csvPF.SetJSONTitles(PRINT_VAULT_HOLDS_TITLES+['JSON'])
  if not matters:
    printGettingAllAccountEntities(Ent.VAULT_MATTER, qualifier=' (OPEN)')
    try:
      results = callGAPIpages(v.matters(), 'list', 'matters',
                              pageMessage=getPageMessage(),
                              throwReasons=[GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                              view='BASIC', state='OPEN', fields='matters(matterId,name,state),nextPageToken')
    except (GAPI.forbidden, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.VAULT_HOLD, None], str(e))
      return
  else:
    results = []
    for matter in matters:
      matterId, matterName, _, state = convertMatterNameToID(v, matter)
      results.append({'matterId': matterId, 'name': matterName, 'state': state})
  jcount = len(results)
  if not csvPF:
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
  j = 0
  for matter in results:
    j += 1
    matterId = matter['matterId']
    matterName = matter['name']
    matterNameId = formatVaultNameId(matterName, matterId)
    if csvPF:
      printGettingAllEntityItemsForWhom(Ent.VAULT_HOLD, f'{Ent.Singular(Ent.VAULT_MATTER)}: {matterNameId}', j, jcount)
      pageMessage = getPageMessageForWhom()
    else:
      pageMessage = None
    if matter['state'] == 'OPEN':
      try:
        holds = callGAPIpages(v.matters().holds(), 'list', 'holds',
                              pageMessage=pageMessage,
                              throwReasons=[GAPI.FAILED_PRECONDITION, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                              matterId=matterId, fields=fields)
      except GAPI.failedPrecondition:
        warnMatterNotOpen(v, matter, matterNameId, j, jcount)
        continue
      except (GAPI.forbidden, GAPI.invalidArgument) as e:
        entityActionFailedWarning([Ent.VAULT_HOLD, None], str(e))
        break
    else:
      warnMatterNotOpen(None, matter, matterNameId, j, jcount)
      continue
    kcount = len(holds)
    if not csvPF:
      if not FJQC.formatJSON:
        entityPerformActionNumItems([Ent.VAULT_MATTER, matterNameId], kcount, Ent.VAULT_HOLD, j, jcount)
      Ind.Increment()
      k = 0
      for hold in holds:
        k += 1
        _showVaultHold(matterNameId, hold, cd, FJQC, k, kcount)
      Ind.Decrement()
    else:
      for hold in holds:
        _cleanVaultHold(hold, cd)
        if not oneItemPerRow or not hold.get('accounts', []):
          _printVaultHold(hold)
        else:
          for account in hold.pop('accounts'):
            hold['account'] = account
            _printVaultHold(hold)
  if csvPF:
    csvPF.writeCSVfile('Vault Holds')

PRINT_USER_VAULT_HOLDS_TITLES = ['User', 'matterId', 'matterName', 'holdId', 'name', 'orgUnitId', 'orgUnitPath']

# gam <UserTypeEntity> print vaultholds|holds [todrive <ToDriveAttribute>*]
# gam <UserTypeEntity> show vaultholds|holds
def printShowUserVaultHolds(entityList):
  cd = buildGAPIObject(API.DIRECTORY)
  v = buildGAPIObject(API.VAULT)
  csvPF = CSVPrintFile(PRINT_USER_VAULT_HOLDS_TITLES, 'sortall') if Act.csvFormat() else None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    else:
      unknownArgumentExit()
  printGettingAllAccountEntities(Ent.VAULT_MATTER, qualifier=' (OPEN)')
  try:
    matters = callGAPIpages(v.matters(), 'list', 'matters',
                            pageMessage=getPageMessage(),
                            throwReasons=[GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                            view='BASIC', state='OPEN', fields='matters(matterId,name,state),nextPageToken')
  except (GAPI.forbidden, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.VAULT_HOLD, None], str(e))
    return
  jcount = len(matters)
  j = 0
  for matter in matters:
    j += 1
    matterId = matter['matterId']
    matterName = matter['name']
    matterNameId = formatVaultNameId(matterName, matterId)
    printGettingAllEntityItemsForWhom(Ent.VAULT_HOLD, f'{Ent.Singular(Ent.VAULT_MATTER)}: {matterNameId}', j, jcount)
    try:
      matter['holds'] = callGAPIpages(v.matters().holds(), 'list', 'holds',
                                      pageMessage=getPageMessageForWhom(),
                                      throwReasons=[GAPI.FAILED_PRECONDITION, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                                      matterId=matterId, fields='holds(holdId,name,accounts(accountId,email),orgUnit(orgUnitId)),nextPageToken')
    except GAPI.failedPrecondition:
      warnMatterNotOpen(v, matter, matterNameId, j, jcount)
    except (GAPI.forbidden, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.VAULT_HOLD, None], str(e), j, jcount)
  totalHolds = 0
  _, _, entityList = getEntityArgument(entityList)
  for user in entityList:
    user = normalizeEmailAddressOrUID(user)
    orgUnits = getAllParentOrgUnitsForUser(cd, user)
    for matter in matters:
      matterId = matter['matterId']
      matterName = matter['name']
      matterNameId = formatVaultNameId(matterName, matterId)
      for hold in matter.get('holds', []):
        if 'orgUnit' in hold:
          orgUnitId = hold['orgUnit'].get('orgUnitId')
          if orgUnitId in orgUnits:
            if not csvPF:
              printEntityMessage([Ent.USER, user,
                                  Ent.ORGANIZATIONAL_UNIT, orgUnits[orgUnitId],
                                  Ent.VAULT_MATTER, formatVaultNameId(matter['name'], matter['matterId']),
                                  Ent.VAULT_HOLD, formatVaultNameId(hold['name'], hold['holdId'])],
                                 Msg.ON_VAULT_HOLD)
            else:
              csvPF.WriteRow({'User': user, 'matterId': matterId, 'matterName': matterName,
                              'holdId': hold['holdId'], 'name': hold['name'],
                              'orgUnitId': orgUnitId, 'orgUnitPath': orgUnits[orgUnitId]})
            totalHolds += 1
        else:
          for account in hold.get('accounts', []):
            if user == account.get('email', '').lower() or user == account.get('accountId', ''):
              if not csvPF:
                printEntityMessage([Ent.USER, user,
                                    Ent.VAULT_MATTER, formatVaultNameId(matter['name'], matter['matterId']),
                                    Ent.VAULT_HOLD, formatVaultNameId(hold['name'], hold['holdId'])],
                                   Msg.ON_VAULT_HOLD)
              else:
                csvPF.WriteRow({'User': user, 'matterId': matterId, 'matterName': matterName,
                                'holdId': hold['holdId'], 'name': hold['name']})
              totalHolds += 1
              break
  if csvPF:
    csvPF.writeCSVfile('User Vault Holds')
  else:
    printKeyValueList(['Total Holds', totalHolds])

def _cleanVaultQuery(query, cd, drive):
  if 'query' in query:
    if cd is not None and 'orgUnitInfo' in query['query']:
      query['query']['orgUnitInfo']['orgUnitPath'] = convertOrgUnitIDtoPath(cd, query['query']['orgUnitInfo']['orgUnitId'])
    if drive is not None and 'sharedDriveInfo' in query['query']:
      query['query']['sharedDriveInfo']['sharedDriveNames'] = []
      for sharedDriveId in query['query']['sharedDriveInfo']['sharedDriveIds']:
        query['query']['sharedDriveInfo']['sharedDriveNames'].append(_getSharedDriveNameFromId(drive, sharedDriveId, False))
    query['query'].pop('searchMethod', None)
    query['query'].pop('teamDriveInfo', None)

VAULT_QUERY_TIME_OBJECTS = {'createTime', 'endTime', 'startTime', 'versionDate'}

def _showVaultQuery(matterNameId, query, cd, drive, FJQC, k=0, kcount=0):
  _cleanVaultQuery(query, cd, drive)
  if FJQC is not None and FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(query, timeObjects=VAULT_QUERY_TIME_OBJECTS), ensure_ascii=False, sort_keys=False))
    return
  printEntity([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_QUERY, formatVaultNameId(query['displayName'], query['savedQueryId'])], k, kcount)
  Ind.Increment()
  showJSON(None, query, timeObjects=VAULT_QUERY_TIME_OBJECTS)
  Ind.Decrement()

VAULT_QUERY_FIELDS_CHOICE_MAP = {
  'createtime': 'createTime',
  'displayname': 'displayName',
  'matterid': 'matterId',
  'name': 'displayName',
  'query': 'query',
  'queryid': 'savedQueryId',
  'savedqueryid': 'savedQueryId',
  }

# gam info vaultquery <QueryItem> matter <MatterItem>
#	[fields <VaultQueryFieldNameList>] [shownames]
#	[formatjson]
# gam info vaultquery <MatterItem> <QueryItem>
#	[fields <VaultQueryFieldNameList>] [shownames]
#	[formatjson]
def doInfoVaultQuery():
  v = buildGAPIObject(API.VAULT)
  if not Cmd.ArgumentIsAhead('matter'):
    matterId, matterNameId = getMatterItem(v)
    queryId, queryName, queryNameId = convertQueryNameToID(v, getString(Cmd.OB_QUERY_ITEM), matterId, matterNameId)
  else:
    queryName = getString(Cmd.OB_QUERY_ITEM)
  cd = drive = None
  fieldsList = []
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'matter':
      matterId, matterNameId = getMatterItem(v)
      queryId, queryName, queryNameId = convertQueryNameToID(v, queryName, matterId, matterNameId)
    elif myarg == 'shownames':
      cd = buildGAPIObject(API.DIRECTORY)
      _, drive = buildGAPIServiceObject(API.DRIVE3, _getAdminEmail())
      if drive is None:
        return
    elif getFieldsList(myarg, VAULT_QUERY_FIELDS_CHOICE_MAP, fieldsList, initialField=['savedQueryId', 'displayName']):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  fields = getFieldsFromFieldsList(fieldsList)
  try:
    query = callGAPI(v.matters().savedQueries(), 'get',
                    throwReasons=[GAPI.NOT_FOUND, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                    matterId=matterId, savedQueryId=queryId, fields=fields)
    _showVaultQuery(matterNameId, query, cd, drive, FJQC)
  except (GAPI.notFound, GAPI.badRequest, GAPI.forbidden, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId, Ent.VAULT_QUERY, queryNameId], str(e))

PRINT_VAULT_QUERIES_TITLES = ['matterId', 'matterName', 'savedQueryId', 'displayName']

# gam print vaultqueries [todrive <ToDriveAttribute>*] [matters <MatterItemList>]
#	[fields <VaultQueryFieldNameList>] [shownames]
#	[formatjson [quotechar <Character>]]
# gam show vaultqueries [matters <MatterItemList>]
#	[fields <VaultQueryFieldNameList>] [shownames]
#	[formatjson]
def doPrintShowVaultQueries():
  v = buildGAPIObject(API.VAULT)
  csvPF = CSVPrintFile(PRINT_VAULT_QUERIES_TITLES, 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  matters = []
  cd = drive = None
  fieldsList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'matter', 'matters'}:
      matters = shlexSplitList(getString(Cmd.OB_MATTER_ITEM_LIST))
    elif myarg == 'shownames':
      cd = buildGAPIObject(API.DIRECTORY)
      _, drive = buildGAPIServiceObject(API.DRIVE3, _getAdminEmail())
      if drive is None:
        return
    elif getFieldsList(myarg, VAULT_QUERY_FIELDS_CHOICE_MAP, fieldsList, initialField=['savedQueryId', 'displayName']):
      pass
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  fields = getItemFieldsFromFieldsList('savedQueries', fieldsList)
  if csvPF and FJQC.formatJSON:
    csvPF.SetJSONTitles(PRINT_VAULT_QUERIES_TITLES+['JSON'])
  if not matters:
    printGettingAllAccountEntities(Ent.VAULT_MATTER, qualifier=' (OPEN)')
    try:
      results = callGAPIpages(v.matters(), 'list', 'matters',
                              pageMessage=getPageMessage(),
                              throwReasons=[GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                              view='BASIC', state='OPEN', fields='matters(matterId,name,state),nextPageToken')
    except (GAPI.forbidden, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.VAULT_QUERY, None], str(e))
      return
  else:
    results = []
    for matter in matters:
      matterId, matterName, _, state = convertMatterNameToID(v, matter)
      results.append({'matterId': matterId, 'name': matterName, 'state': state})
  jcount = len(results)
  if not csvPF:
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
  j = 0
  for matter in results:
    j += 1
    matterId = matter['matterId']
    matterName = matter['name']
    matterNameId = formatVaultNameId(matterName, matterId)
    if csvPF:
      printGettingAllEntityItemsForWhom(Ent.VAULT_QUERY, f'{Ent.Singular(Ent.VAULT_MATTER)}: {matterNameId}', j, jcount)
      pageMessage = getPageMessageForWhom()
    else:
      pageMessage = None
    if matter['state'] == 'OPEN':
      try:
        queries = callGAPIpages(v.matters().savedQueries(), 'list', 'savedQueries',
                                pageMessage=pageMessage,
                                throwReasons=[GAPI.FAILED_PRECONDITION, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                                matterId=matterId, fields=fields)
      except GAPI.failedPrecondition:
        warnMatterNotOpen(v, matter, matterNameId, j, jcount)
        continue
      except (GAPI.forbidden, GAPI.invalidArgument) as e:
        entityActionFailedWarning([Ent.VAULT_QUERY, None], str(e))
        break
    else:
      warnMatterNotOpen(None, matter, matterNameId, j, jcount)
      continue
    kcount = len(queries)
    if not csvPF:
      if not FJQC.formatJSON:
        entityPerformActionNumItems([Ent.VAULT_MATTER, matterNameId], kcount, Ent.VAULT_QUERY, j, jcount)
      Ind.Increment()
      k = 0
      for query in queries:
        k += 1
        _showVaultQuery(matterNameId, query, cd, drive, FJQC, k, kcount)
      Ind.Decrement()
    else:
      for query in queries:
        _cleanVaultQuery(query, cd, drive)
        row = flattenJSON(query, flattened={'matterId': matterId, 'matterName': matterName}, timeObjects=VAULT_QUERY_TIME_OBJECTS)
        if not FJQC.formatJSON:
          csvPF.WriteRowTitles(row)
        elif csvPF.CheckRowTitles(row):
          csvPF.WriteRowNoFilter({'matterId': matterId, 'matterName': matterName,
                                  'savedQueryId': query['savedQueryId'], 'displayName': query['displayName'],
                                  'JSON': json.dumps(cleanJSON(query, timeObjects=VAULT_QUERY_TIME_OBJECTS), ensure_ascii=False, sort_keys=True)})
  if csvPF:
    csvPF.writeCSVfile('Vault Saved Queries')

def validateCollaborators(cd):
  collaborators = []
  for collaborator in getEntityList(Cmd.OB_COLLABORATOR_ENTITY):
    collaborators.append({'email': collaborator, 'id': convertEmailAddressToUID(collaborator, cd)})
  return collaborators

def _cleanVaultMatter(matter, cd):
  if cd is not None:
    if 'matterPermissions' in matter:
      for i in range(0, len(matter['matterPermissions'])):
        matter['matterPermissions'][i]['email'] = convertUserIDtoEmail(matter["matterPermissions"][i]["accountId"], cd)

def _showVaultMatter(matter, cd, FJQC, j=0, jcount=0):
  _cleanVaultMatter(matter, cd)
  if FJQC is not None and FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(matter), ensure_ascii=False, sort_keys=False))
    return
  printEntity([Ent.VAULT_MATTER, formatVaultNameId(matter['name'], matter['matterId'])], j, jcount)
  Ind.Increment()
  showJSON(None, matter)
  Ind.Decrement()

# gam create vaultmatter|matter [name <String>] [description <string>]
#	[collaborator|collaborators <CollaboratorItemList>] [sendemails <Boolean>] [ccme <Boolean>]
#	[showdetails|returnidonly]
def doCreateVaultMatter():
  v = buildGAPIObject(API.VAULT)
  body = {}
  cbody = {'matterPermission': {'role': 'COLLABORATOR', 'accountId': ''}, 'sendEmails': False, 'ccMe': False}
  collaborators = []
  cd = None
  returnIdOnly = showDetails = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'name':
      body['name'] = getString(Cmd.OB_STRING)
    elif myarg == 'description':
      body['description'] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg in {'collaborator', 'collaborators'}:
      if not cd:
        cd = buildGAPIObject(API.DIRECTORY)
      collaborators.extend(validateCollaborators(cd))
    elif myarg == 'sendemails':
      cbody['sendEmails'] = getBoolean()
    elif myarg == 'ccme':
      cbody['ccMe'] = getBoolean()
    elif myarg == 'showdetails':
      showDetails = True
      returnIdOnly = False
    elif myarg == 'returnidonly':
      returnIdOnly = True
      showDetails = False
    else:
      unknownArgumentExit()
  if 'name' not in body:
    body['name'] = f'GAM Matter - {ISOformatTimeStamp(todaysTime())}'
  try:
    matter = callGAPI(v.matters(), 'create',
                      throwReasons=[GAPI.ALREADY_EXISTS, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                      body=body)
    matterId = matter['matterId']
    matterNameId = formatVaultNameId(matter['name'], matterId)
    if not returnIdOnly:
      entityActionPerformed([Ent.VAULT_MATTER, matterNameId])
    else:
      writeStdout(f'{matterId}\n')
  except (GAPI.alreadyExists, GAPI.forbidden, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.VAULT_MATTER, body['name']], str(e))
    return
  jcount = len(collaborators)
  if jcount > 0:
    Act.Set(Act.ADD)
    if not returnIdOnly:
      entityPerformActionNumItems([Ent.VAULT_MATTER, matterNameId], jcount, Ent.COLLABORATOR)
    Ind.Increment()
    j = 0
    for collaborator in collaborators:
      j += 1
      cbody['matterPermission']['accountId'] = collaborator['id']
      try:
        callGAPI(v.matters(), 'addPermissions',
                 throwReasons=[GAPI.FAILED_PRECONDITION, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                 matterId=matterId, body=cbody)
        if not returnIdOnly:
          entityActionPerformed([Ent.VAULT_MATTER, matterNameId, Ent.COLLABORATOR, collaborator['email']], j, jcount)
      except (GAPI.failedPrecondition, GAPI.forbidden, GAPI.invalidArgument) as e:
        entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId], str(e))
        break
    Ind.Decrement()
  if showDetails:
    _showVaultMatter(matter, cd, None)

VAULT_MATTER_ACTIONS = {
  'close': Act.CLOSE,
  'reopen': Act.REOPEN,
  'delete': Act.DELETE,
  'undelete': Act.UNDELETE,
  }

def doActionVaultMatter(action, matterId=None, matterNameId=None, v=None):
  if v is None:
    v = buildGAPIObject(API.VAULT)
    matterId, matterNameId = getMatterItem(v)
  else:
    Act.Set(VAULT_MATTER_ACTIONS[action])
  checkForExtraneousArguments()
  action_kwargs = {} if action == 'delete' else {'body': {}}
  try:
    callGAPI(v.matters(), action,
             throwReasons=[GAPI.NOT_FOUND, GAPI.FAILED_PRECONDITION, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
             matterId=matterId, **action_kwargs)
    entityActionPerformed([Ent.VAULT_MATTER, matterNameId])
  except (GAPI.notFound, GAPI.failedPrecondition, GAPI.forbidden, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId], str(e))

# gam close vaultmatter|matter <MatterItem>
def doCloseVaultMatter():
  doActionVaultMatter('close')

# gam reopen vaultmatter|matter <MatterItem>
def doReopenVaultMatter():
  doActionVaultMatter('reopen')

# gam delete vaultmatter|matter <MatterItem>
def doDeleteVaultMatter():
  doActionVaultMatter('delete')

# gam undelete vaultmatter|matter <MatterItem>
def doUndeleteVaultMatter():
  doActionVaultMatter('undelete')

# gam update vaultmatter|matter <MatterItem> [name <String>] [description <string>]
#	[addcollaborator|addcollaborators <CollaboratorItemList>] [removecollaborator|removecollaborators <CollaboratorItemList>]
def doUpdateVaultMatter():
  v = buildGAPIObject(API.VAULT)
  matterId, matterNameId = getMatterItem(v)
  body = {}
  addCollaborators = []
  removeCollaborators = []
  cd = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'action':
      action = getChoice(VAULT_MATTER_ACTIONS)
      doActionVaultMatter(action, matterId, matterNameId, v)
      return
    if myarg == 'name':
      body['name'] = getString(Cmd.OB_STRING)
    elif myarg == 'description':
      body['description'] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg in {'addcollaborator', 'addcollaborators'}:
      if not cd:
        cd = buildGAPIObject(API.DIRECTORY)
      addCollaborators.extend(validateCollaborators(cd))
    elif myarg in {'removecollaborator', 'removecollaborators'}:
      if not cd:
        cd = buildGAPIObject(API.DIRECTORY)
      removeCollaborators.extend(validateCollaborators(cd))
    else:
      unknownArgumentExit()
  if body:
    try:
      if 'name' not in body or 'description' not in body:
        # bah, API requires name/description to be sent on update even when it's not changing
        result = callGAPI(v.matters(), 'get',
                          throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                          matterId=matterId, view='BASIC')
        body.setdefault('name', result['name'])
        body.setdefault('description', result.get('description'))
      callGAPI(v.matters(), 'update',
               throwReasons=[GAPI.NOT_FOUND, GAPI.FAILED_PRECONDITION, GAPI.FORBIDDEN],
               matterId=matterId, body=body)
      entityActionPerformed([Ent.VAULT_MATTER, matterNameId])
    except (GAPI.notFound, GAPI.failedPrecondition, GAPI.forbidden, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId], str(e))
      return
  jcount = len(addCollaborators)
  if jcount > 0:
    Act.Set(Act.ADD)
    entityPerformActionNumItems([Ent.VAULT_MATTER, matterNameId], jcount, Ent.COLLABORATOR)
    Ind.Increment()
    j = 0
    for collaborator in addCollaborators:
      j += 1
      try:
        callGAPI(v.matters(), 'addPermissions',
                 throwReasons=[GAPI.FAILED_PRECONDITION, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                 matterId=matterId, body={'matterPermission': {'role': 'COLLABORATOR', 'accountId': collaborator['id']}})
        entityActionPerformed([Ent.VAULT_MATTER, matterNameId, Ent.COLLABORATOR, collaborator['email']], j, jcount)
      except (GAPI.failedPrecondition, GAPI.forbidden, GAPI.invalidArgument) as e:
        entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId], str(e))
        break
    Ind.Decrement()
  jcount = len(removeCollaborators)
  if jcount > 0:
    Act.Set(Act.REMOVE)
    entityPerformActionNumItems([Ent.VAULT_MATTER, matterNameId], jcount, Ent.COLLABORATOR)
    Ind.Increment()
    j = 0
    for collaborator in removeCollaborators:
      j += 1
      try:
        callGAPI(v.matters(), 'removePermissions',
                 throwReasons=[GAPI.FAILED_PRECONDITION, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                 matterId=matterId, body={'accountId': collaborator['id']})
        entityActionPerformed([Ent.VAULT_MATTER, matterNameId, Ent.COLLABORATOR, collaborator['email']], j, jcount)
      except (GAPI.failedPrecondition, GAPI.forbidden, GAPI.invalidArgument) as e:
        entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId], str(e))
        break
    Ind.Decrement()

VAULT_MATTER_FIELDS_CHOICE_MAP = {
  'matterid': 'matterId',
  'name': 'name',
  'description': 'description',
  'state': 'state',
  'matterpermissions': 'matterPermissions',
  }

# gam info vaultmatter|matter <MatterItem>
#	[basic|full|(fields <VaultMatterFieldNameList>)]
#	[formatjson]
def doInfoVaultMatter():
  v = buildGAPIObject(API.VAULT)
  matterId, matterNameId = getMatterItem(v)
  view = 'FULL'
  fieldsList = []
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in PROJECTION_CHOICE_MAP:
      view = PROJECTION_CHOICE_MAP[myarg]
    elif getFieldsList(myarg, VAULT_MATTER_FIELDS_CHOICE_MAP, fieldsList, initialField=['matterId', 'name']):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  fields = getFieldsFromFieldsList(fieldsList)
  try:
    matter = callGAPI(v.matters(), 'get',
                      throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT],
                      matterId=matterId, view=view, fields=fields)
    cd = buildGAPIObject(API.DIRECTORY) if 'matterPermissions' in matter else None
    _showVaultMatter(matter, cd, FJQC)
  except (GAPI.notFound, GAPI.forbidden, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.VAULT_MATTER, matterNameId], str(e))

VAULT_MATTER_STATE_MAP = {'open': 'OPEN', 'closed': 'CLOSED', 'deleted': 'DELETED'}
PRINT_VAULT_MATTERS_TITLES = ['matterId', 'name', 'description', 'state']

# gam print vaultmatters|matters [todrive <ToDriveAttribute>*] [matterstate <MatterStateList>]
#	[basic|full|(fields <VaultMatterFieldNameList>)]
#	[formatjson [quotechar <Character>]]
# gam show vaultmatters|matters [matterstate <MatterStateList>]
#	[basic|full|(fields <VaultMatterFieldNameList>)]
#	[formatjson]
def doPrintShowVaultMatters():
  v = buildGAPIObject(API.VAULT)
  csvPF = CSVPrintFile(PRINT_VAULT_MATTERS_TITLES, 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  cd = None
  view = 'FULL'
  fieldsList = []
  matterStatesList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'matterstate':
      for state in getString(Cmd.OB_STATE_NAME_LIST).lower().replace('_', '').replace(',', ' ').split():
        if state in VAULT_MATTER_STATE_MAP:
          matterStatesList.append(VAULT_MATTER_STATE_MAP[state])
        else:
          invalidChoiceExit(state, list(VAULT_MATTER_STATE_MAP), True)
    elif myarg in PROJECTION_CHOICE_MAP:
      view = PROJECTION_CHOICE_MAP[myarg]
    elif getFieldsList(myarg, VAULT_MATTER_FIELDS_CHOICE_MAP, fieldsList, initialField=['matterId', 'name']):
      pass
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  if fieldsList and matterStatesList:
    fieldsList.append('state')
  fields = f'nextPageToken,matters({getFieldsFromFieldsList(fieldsList)})' if fieldsList else None
  if csvPF and FJQC.formatJSON:
    csvPF.SetJSONTitles(PRINT_VAULT_MATTERS_TITLES+['JSON'])
  # If no states are set, there is no filtering; if 1 state is set, the API can filter; else GAM filters
  matterStates = set()
  stateParm = None
  if matterStatesList:
    if len(matterStatesList) == 1:
      stateParm = matterStatesList[0]
    else:
      matterStates = set(matterStatesList)
    qualifier = f' ({",".join(matterStatesList)})'
  else:
    qualifier = ''
  printGettingAllAccountEntities(Ent.VAULT_MATTER, qualifier=qualifier)
  try:
    matters = callGAPIpages(v.matters(), 'list', 'matters',
                            pageMessage=getPageMessage(),
                            throwReasons=[GAPI.FORBIDDEN, GAPI.INVALID_ARGUMENT, GAPI.INVALID_ARGUMENT],
                            view=view, state=stateParm, fields=fields)
  except (GAPI.forbidden, GAPI.invalidArgument, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.VAULT_MATTER, None], str(e))
    return
  jcount = len(matters)
  if view == 'FULL':
    cd = buildGAPIObject(API.DIRECTORY)
  if not csvPF:
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
    if not FJQC.formatJSON:
      performActionNumItems(jcount, Ent.VAULT_MATTER)
    Ind.Increment()
    j = 0
    for matter in matters:
      j += 1
      if not matterStates or matter['state'] in matterStates:
        _showVaultMatter(matter, cd, FJQC, j, jcount)
    Ind.Decrement()
  else:
    for matter in matters:
      if not matterStates or matter['state'] in matterStates:
        _cleanVaultMatter(matter, cd)
        row = flattenJSON(matter)
        if not FJQC.formatJSON:
          csvPF.WriteRowTitles(row)
        elif csvPF.CheckRowTitles(row):
          csvPF.WriteRowNoFilter({'matterId': matter['matterId'], 'name': matter['name'],
                                  'JSON': json.dumps(cleanJSON(matter), ensure_ascii=False, sort_keys=True)})
  if csvPF:
    csvPF.writeCSVfile('Vault Matters')

PRINT_VAULT_COUNTS_TITLES = ['account', 'count', 'error']

# gam print vaultcounts [todrive <ToDriveAttributes>*]
#	matter <MatterItem> corpus mail|groups
#	(accounts <EmailAddressEntity>) | (orgunit|org|ou <OrgUnitPath>) | everyone
#	(shareddrives|teamdrives (<SharedDriveIDList>|(select <FileSelector>|<CSVFileSelector>))) |
#	    (rooms (<ChatSpaceList>|(select <FileSelector>|<CSVFileSelector>))) |
#	    (sitesurl (<URLList>||(select <FileSelector>|<CSVFileSelector>)))
#	[scope <all_data|held_data|unprocessed_data>]
#	[terms <String>] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>] [timezone <TimeZone>]
#	[excludedrafts <Boolean>]
#	[wait <Integer>]
# gam print vaultcounts [todrive <ToDriveAttributes>*]
#	 matter <MatterItem> operation <String> [wait <Integer>]
def doPrintVaultCounts():
  v = buildGAPIObject(API.VAULT)
  csvPF = CSVPrintFile(PRINT_VAULT_COUNTS_TITLES, 'sortall')
  matterId = name = None
  operationWait = 15
  body = {'view': 'ALL'}
  query = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'matter':
      matterId, _ = getMatterItem(v)
    elif myarg == 'operation':
      name = getString(Cmd.OB_STRING)
    elif myarg in VAULT_QUERY_ARGS:
      _buildVaultQuery(myarg, query, VAULT_COUNTS_CORPUS_ARGUMENT_MAP)
    elif myarg == 'wait':
      operationWait = getInteger(minVal=1)
    else:
      unknownArgumentExit()
  if not matterId:
    missingArgumentExit('matter')
  if name:
    operation = {'name': name}
    doWait = False
  else:
    body['query'] = query
    _validateVaultQuery(body, VAULT_COUNTS_CORPUS_ARGUMENT_MAP)
    try:
      operation = callGAPI(v.matters(), 'count',
                           throwReasons=[GAPI.INVALID_ARGUMENT],
                           matterId=matterId, body=body)
    except GAPI.invalidArgument as e:
      entityActionFailedExit([Ent.VAULT_MATTER, matterId], str(e))
    doWait = True
  printGettingAllAccountEntities(Ent.VAULT_MATTER_ARTIFACT, qualifier=f' for {Ent.Singular(Ent.VAULT_OPERATION)}: {operation["name"]}',
                                 accountType=Ent.VAULT_MATTER)
  while not operation.get('done'):
    if doWait:
      stderrEntityMessage([Ent.VAULT_OPERATION, operation['name']], Msg.IS_NOT_DONE_CHECKING_IN_SECONDS.format(operationWait))
      time.sleep(operationWait)
    try:
      operation = callGAPI(v.operations(), 'get',
                           throwReasons=[GAPI.NOT_FOUND],
                           name=operation['name'])
    except GAPI.notFound as e:
      entityActionFailedExit([Ent.VAULT_OPERATION, operation['name']], str(e))
    doWait = True
  response = operation.get('response', {})
  query = operation['metadata']['query']
  search_method = query.get('searchMethod')
  # ARGH count results don't include accounts with zero items.
  # so we keep track of which accounts we searched and can report
  # zero data for them.
  if search_method == 'ACCOUNT':
    query_accounts = query.get('accountInfo', {}).get('emails', [])
  elif search_method == 'ENTIRE_ORG':
    query_accounts = getItemsToModify(Cmd.ENTITY_ALL_USERS, '')
  elif search_method == 'ORG_UNIT':
    query_accounts = getItemsToModify(Cmd.ENTITY_OU, query['orgUnitInfo']['orgUnitId'])
  mailcounts = response.get('mailCountResult', {})
  groupcounts = response.get('groupsCountResult', {})
  for a_count in [mailcounts, groupcounts]:
    for errored_account in a_count.get('accountCountErrors', []):
      email = errored_account.get('account', {}).get('email', '')
      if email:
        csvPF.WriteRow({'account': email, 'error': errored_account.get('errorType')})
        if email in query_accounts:
          query_accounts.remove(email)
    for account in a_count.get('nonQueryableAccounts', []):
      csvPF.WriteRow({'account': account, 'error': 'Not queried because not on hold'})
      if account in query_accounts:
        query_accounts.remove(account)
    for account in a_count.get('accountCounts', []):
      email = account.get('account', {}).get('email', '')
      if email:
        csvPF.WriteRow({'account': email, 'count': account.get('count', 0)})
        if email in query_accounts:
          query_accounts.remove(email)
  for account in query_accounts:
    csvPF.WriteRow({'account': account, 'count': 0})
  csvPF.writeCSVfile('Vault Counts')

# gam [<UserTypeEntity>] create site <SiteName> <SiteAttribute>*
# gam [<UserTypeEntity>] update site <SiteEntity> <SiteAttribute>+
# gam [<UserTypeEntity>] info site <SiteEntity> [withmappings] [role|roles all|<SiteACLRoleList>]
# gam [<UserTypeEntity>] print sites [todrive <ToDriveAttribute>*] [domain|domains <DomainNameEntity>] [includeallsites]
#	[withmappings] [role|roles all|<SiteACLRoleList>] [startindex <Number>] [maxresults <Number>] [convertcrnl] [delimiter <Character>]
# gam [<UserTypeEntity>] show sites [domain|domains <DomainNameEntity>] [includeallsites]
#	[withmappings] [role|roles all|<SiteACLRoleList>] [startindex <Number>] [maxresults <Number>] [convertcrnl]
# gam [<UserTypeEntity>] create siteacls <SiteEntity> <SiteACLRole> <SiteACLScopeEntity>
# gam [<UserTypeEntity>] update siteacls <SiteEntity> <SiteACLRole> <SiteACLScopeEntity>
# gam [<UserTypeEntity>] delete siteacls <SiteEntity> <SiteACLScopeEntity>
# gam [<UserTypeEntity>] info siteacls <SiteEntity> <SiteACLScopeEntity>
# gam [<UserTypeEntity>] show siteacls <SiteEntity>
# gam [<UserTypeEntity>] print siteacls <SiteEntity> [todrive <ToDriveAttribute>*]
# gam [<UserTypeEntity>] print siteactivity <SiteEntity> [todrive <ToDriveAttribute>*]
#	[startindex <Number>] [maxresults <Number>] [updated_min <Date>] [updated_max <Date>]
def deprecatedUserSites(_):
  deprecatedCommandExit()

def deprecatedDomainSites():
  deprecatedCommandExit()

# <CSVFileInput> [keyfield <FieldName>] [datafield <FieldName>]
def _getGroupOrgUnitMap():

  def getKeyFieldInfo(keyword, defaultField):
    if not checkArgumentPresent(keyword):
      field = defaultField
    else:
      field = getString(Cmd.OB_FIELD_NAME)
    if field not in fieldnames:
      csvFieldErrorExit(field, fieldnames, backupArg=True)
    return field

  filename = getString(Cmd.OB_FILE_NAME)
  f, csvFile, fieldnames = openCSVFileReader(filename)
  keyField = getKeyFieldInfo('keyfield', 'Group')
  dataField = getKeyFieldInfo('datafield', 'OrgUnit')
  groupOrgUnitMap = {}
  for row in csvFile:
    group = row[keyField].strip().lower()
    orgUnit = row[dataField].strip()
    if not group or not orgUnit:
      systemErrorExit(USAGE_ERROR_RC, Msg.GROUP_MAPS_TO_OU_INVALID_ROW.format(filename, group, orgUnit))
    orgUnit = makeOrgUnitPathAbsolute(orgUnit)
    if group in groupOrgUnitMap:
      origOrgUnit = groupOrgUnitMap[group]
      if origOrgUnit != orgUnit:
        systemErrorExit(USAGE_ERROR_RC, Msg.GROUP_MAPS_TO_MULTIPLE_OUS.format(filename, group, ','.join([origOrgUnit, orgUnit])))
    groupOrgUnitMap[group] = orgUnit
  closeFile(f)
  return groupOrgUnitMap

class PasswordOptions():
  def __init__(self, updateCmd):
    self.password = ''
    self.notFoundPassword = ''
    self.b64DecryptPassword = False
    self.clearPassword = True
    self.hashPassword = True
    self.ignoreNullPassword = False
    self.makeRandomPassword = not updateCmd
    self.makeUniqueRandomPassword = False
    self.makeCleanPassword = True
    self.cleanPasswordLen = 25
    self.randomPasswordChars = None
    self.promptForPassword = False
    self.promptForUniquePassword = False
    self.notifyPasswordSet = False
    self.updateCmd = updateCmd
    self.filename = ''

  def GetPassword(self):
    return getString(Cmd.OB_PASSWORD, minLen=1 if not self.ignoreNullPassword else 0, maxLen=100)

  def SetCleanPasswordLen(self):
    self.cleanPasswordLen = getInteger(minVal=8, maxVal=100, default=25)

  def CreateRandomPassword(self):
    rnd = SystemRandom()
    if not self.makeCleanPassword:
    # Generate a password with unicode chars that are not allowed in passwords.
    # We expect "password random nohash" to fail but no one should be using that.
    # Our goal here is to purposefully block logins with this password.
      if not self.randomPasswordChars:
        self.randomPasswordChars = [chr(i) for i in range(1, 55296)]
      return ''.join(rnd.choice(self.randomPasswordChars) for _ in range(4096))
    # Generate a clean password that can be used for logins
    return ''.join(rnd.choice(PASSWORD_SAFE_CHARS) for _ in range(self.cleanPasswordLen))

  def ProcessArgument(self, myarg, notify, notFoundBody):
    if myarg == 'ignorenullpassword':
      self.ignoreNullPassword = True
    elif myarg == 'notifypassword':
      password = self.GetPassword()
      if password:
        notify['password'] = password
        self.notifyPasswordSet = True
    elif myarg == 'nohash':
      self.hashPassword = False
    elif self.updateCmd and myarg == 'notfoundpassword':
      up = 'password'
      password = self.GetPassword()
      if password:
        if password.lower() == 'blocklogin':
          self.makeCleanPassword = False
          notFoundBody[up] = self.CreateRandomPassword()
        elif password.lower() in {'random', 'uniquerandom'}:
          self.SetCleanPasswordLen()
          self.makeCleanPassword = True
          notFoundBody[up] = self.CreateRandomPassword()
        else:
          notFoundBody[up] = password
        self.notFoundPassword = notFoundBody[up]
    elif myarg in {'lograndompassword', 'logpassword'}:
      self.filename = getString(Cmd.OB_FILE_NAME)
    else:
      return False
    return True

  HASH_FUNCTION_MAP = {
    'base64-md5': 'MD5',
    'base64-sha1': 'SHA-1',
    'crypt': 'crypt',
    'md5': 'MD5',
    'sha': 'SHA-1',
    'sha-1': 'SHA-1',
    'sha1': 'SHA-1',
    }

  def ProcessPropertyArgument(self, myarg, up, body):
    if up == 'password':
      password = self.GetPassword()
      if password:
        body[up] = password
        self.makeRandomPassword = self.makeUniqueRandomPassword =\
          self.promptForPassword = self.promptForUniquePassword = False
        if password.lower() == 'blocklogin':
          self.makeRandomPassword = True
          self.makeCleanPassword = False
        elif password.lower() == 'random':
          self.SetCleanPasswordLen()
          self.makeRandomPassword = self.makeCleanPassword = True
        elif password.lower() == 'uniquerandom':
          self.SetCleanPasswordLen()
          if self.updateCmd:
            self.makeUniqueRandomPassword = self.makeCleanPassword = True
          else:
            self.makeRandomPassword = self.makeCleanPassword = True
        elif password.lower() == 'prompt':
          self.promptForPassword = True
        elif password.lower() == 'uniqueprompt':
          if self.updateCmd:
            self.promptForUniquePassword = True
          else:
            self.promptForPassword = True
        else:
          self.password = password
    elif up == 'hashFunction':
      body[up] = self.HASH_FUNCTION_MAP[myarg]
      self.clearPassword = self.hashPassword = False
      self.b64DecryptPassword = myarg.startswith('base64')
    else:
      return False
    return True

  def FinalizePassword(self, body, notify, up):
    if not self.notifyPasswordSet:
      notify[up] = body[up] if self.clearPassword else Msg.CONTACT_ADMINISTRATOR_FOR_PASSWORD
    if self.hashPassword:
      body[up] = sha512_crypt.hash(body[up], rounds=10000)
      body['hashFunction'] = 'crypt'
    elif self.b64DecryptPassword:
      if body[up].lower()[:5] in ['{md5}', '{sha}']:
        body[up] = body[up][5:]
      try:
        body[up] = base64.b64decode(body[up]).hex()
      except Exception:
        pass

  def AssignPassword(self, body, notify, notFoundBody, createIfNotFound, user=None):
    up = 'password'
    if self.makeRandomPassword or self.makeUniqueRandomPassword:
      body[up] = self.CreateRandomPassword()
      self.password = body[up]
    elif user and (self.promptForPassword or self.promptForUniquePassword):
      body[up] = readStdin(f'Enter password for {user}: ')
      self.password = body[up]
    elif self.promptForPassword:
      body[up] = readStdin('Enter password: ')
      self.password = body[up]
    if up in body:
      self.FinalizePassword(body, notify, up)
    elif 'hashFunction' in body:
      body.pop('hashFunction')
    if createIfNotFound:
      if not notFoundBody:
        if up in body:
          notFoundBody[up] = body[up]
          if 'hashfunction' in body:
            notFoundBody['hashfunction'] = body['hashFunction']
          notify['notFoundPassword'] = notify[up]
          self.notFoundPassword = self.password
      else:
        notify['notFoundPassword'] = notify[up] if notify[up] else notFoundBody[up] if self.clearPassword else Msg.CONTACT_ADMINISTRATOR_FOR_PASSWORD
        self.FinalizePassword(notFoundBody, notify, up)

UPDATE_USER_ARGUMENT_TO_PROPERTY_MAP = {
  'address': 'addresses',
  'addresses': 'addresses',
  'archive': 'archived',
  'archived': 'archived',
  'base64-md5': 'hashFunction',
  'base64-sha1': 'hashFunction',
  'changepassword': 'changePasswordAtNextLogin',
  'changepasswordatnextlogin': 'changePasswordAtNextLogin',
  'crypt': 'hashFunction',
  'customerid': 'customerId',
  'displayname': 'displayName',
  'email': 'primaryEmail',
  'emails': 'emails',
  'externalid': 'externalIds',
  'externalids': 'externalIds',
  'familyname': 'familyName',
  'firstname': 'givenName',
  'gal': 'includeInGlobalAddressList',
  'gender': 'gender',
  'givenname': 'givenName',
  'im': 'ims',
  'ims': 'ims',
  'includeinglobaladdresslist': 'includeInGlobalAddressList',
  'ipwhitelisted': 'ipWhitelisted',
  'keyword': 'keywords',
  'keywords': 'keywords',
  'language': 'languages',
  'languages': 'languages',
  'lastname': 'familyName',
  'location': 'locations',
  'locations': 'locations',
  'md5': 'hashFunction',
  'note': 'notes',
  'notes': 'notes',
  'org': 'orgUnitPath',
  'organization': 'organizations',
  'organizations': 'organizations',
  'organisation': 'organizations',
  'organisations': 'organizations',
  'orgunitpath': 'orgUnitPath',
  'otheremail': 'emails',
  'otheremails': 'emails',
  'ou': 'orgUnitPath',
  'password': 'password',
  'phone': 'phones',
  'phones': 'phones',
  'posix': 'posixAccounts',
  'posixaccounts': 'posixAccounts',
  'primaryemail': 'primaryEmail',
  'recoveryemail': 'recoveryEmail',
  'recoveryphone': 'recoveryPhone',
  'relation': 'relations',
  'relations': 'relations',
  'sha': 'hashFunction',
  'sha-1': 'hashFunction',
  'sha1': 'hashFunction',
  'ssh': 'sshPublicKeys',
  'sshkeys': 'sshPublicKeys',
  'sshpublickeys': 'sshPublicKeys',
  'suspend': 'suspended',
  'suspended': 'suspended',
  'username': 'primaryEmail',
  'website': 'websites',
  'websites': 'websites',
  }

ADDRESS_ARGUMENT_TO_FIELD_MAP = {
  'country': 'country',
  'countrycode': 'countryCode',
  'extendedaddress': 'extendedAddress',
  'locality': 'locality',
  'pobox': 'poBox',
  'postalcode': 'postalCode',
  'region': 'region',
  'streetaddress': 'streetAddress',
  }

ORGANIZATION_ARGUMENT_TO_FIELD_MAP = {
  'costcenter': 'costCenter',
  'costcentre': 'costCenter',
  'department': 'department',
  'description': 'description',
  'domain': 'domain',
  'fulltimeequivalent': 'fullTimeEquivalent',
  'location': 'location',
  'name': 'name',
  'symbol': 'symbol',
  'title': 'title',
  }

# (MultiValue, IgnoreEmpty)
SCHEMA_VALUE_PROCESS_MAP = {
  'multivalued': (True, False),
  'multivalue': (True, False),
  'value': (True, False),
  'multinonempty': (True, True),
  'scalarnonempty': (False, True)
  }

USER_JSON_SKIP_FIELDS = ['agreedToTerms', 'aliases', 'creationTime', 'customerId', 'deletionTime', 'groups', 'id',
                         'isAdmin', 'isDelegatedAdmin', 'isEnforcedIn2Sv', 'isEnrolledIn2Sv', 'isMailboxSetup',
                         'lastLoginTime', 'licenses', 'primaryEmail', 'thumbnailPhotoEtag', 'thumbnailPhotoUrl',
                         'ipWhiteListed', 'posixAccounts', 'suspensionReason', 'nonEditableAliases']

ALLOW_EMPTY_CUSTOM_TYPE = 'allowEmptyCustomType'

def getUserAttributes(cd, updateCmd, noUid=False):
  def getKeywordAttribute(keywords, attrdict, **opts):
    if Cmd.ArgumentsRemaining():
      keyword = Cmd.Current().strip().lower()
      if keyword in keywords[UProp.PTKW_KEYWORD_LIST]:
        Cmd.Advance()
        if keyword != keywords[UProp.PTKW_CL_CUSTOM_KEYWORD]:
          attrdict[keywords[UProp.PTKW_ATTR_TYPE_KEYWORD]] = keyword
          attrdict.pop(keywords[UProp.PTKW_ATTR_CUSTOMTYPE_KEYWORD], None)
          return
        if Cmd.ArgumentsRemaining():
          customType = Cmd.Current().strip()
          if customType or opts.get(ALLOW_EMPTY_CUSTOM_TYPE, False):
            Cmd.Advance()
            if keywords[UProp.PTKW_ATTR_TYPE_CUSTOM_VALUE]:
              attrdict[keywords[UProp.PTKW_ATTR_TYPE_KEYWORD]] = keywords[UProp.PTKW_ATTR_TYPE_CUSTOM_VALUE]
            else:
              attrdict.pop(keywords[UProp.PTKW_ATTR_TYPE_KEYWORD], None)
            attrdict[keywords[UProp.PTKW_ATTR_CUSTOMTYPE_KEYWORD]] = customType
            return
        missingArgumentExit('custom attribute type')
      elif DEFAULT_CHOICE in opts:
        attrdict[keywords[UProp.PTKW_ATTR_TYPE_KEYWORD]] = opts[DEFAULT_CHOICE]
        return
      elif keywords[UProp.PTKW_CL_CUSTOM_KEYWORD]:
        if keywords[UProp.PTKW_ATTR_TYPE_CUSTOM_VALUE]:
          attrdict[keywords[UProp.PTKW_ATTR_TYPE_KEYWORD]] = keywords[UProp.PTKW_ATTR_TYPE_CUSTOM_VALUE]
        else:
          attrdict.pop(keywords[UProp.PTKW_ATTR_TYPE_KEYWORD], None)
        attrdict[keywords[UProp.PTKW_ATTR_CUSTOMTYPE_KEYWORD]] = Cmd.Current()
        Cmd.Advance()
        return
      invalidChoiceExit(keyword, keywords[UProp.PTKW_KEYWORD_LIST], False)
    elif DEFAULT_CHOICE in opts:
      attrdict[keywords[UProp.PTKW_ATTR_TYPE_KEYWORD]] = opts[DEFAULT_CHOICE]
      return
    missingChoiceExit(keywords[UProp.PTKW_KEYWORD_LIST])

  def primaryNotPrimary(pnp, entry):
    if pnp == 'notprimary':
      return True
    if pnp == 'primary':
      entry['primary'] = True
      primary['location'] = Cmd.Location()
      return True
    return False

  def getPrimaryNotPrimaryChoice(entry, defaultChoice):
    if getChoice({'primary': True, 'notprimary': False}, defaultChoice=defaultChoice, mapChoice=True):
      entry['primary'] = True
      primary['location'] = Cmd.Location()

  def checkClearBodyList(body, itemName):
    if checkArgumentPresent(Cmd.CLEAR_NONE_ARGUMENT):
      body.pop(itemName, None)
      body[itemName] = None
      return True
    return False

  def appendItemToBodyList(body, itemName, itemValue, checkBlankField=None, checkSystemId=False):
    if (itemName in body) and (body[itemName] is None):
      del body[itemName]
    body.setdefault(itemName, [])
    if checkBlankField is None or itemValue[checkBlankField]:
# Throw an error if multiple items are marked primary
      if itemValue.get('primary', False):
        for citem in body[itemName]:
          if citem.get('primary', False):
            if not checkSystemId or itemValue.get('systemId') == citem.get('systemId'):
              Cmd.SetLocation(primary['location']-1)
              usageErrorExit(Msg.MULTIPLE_ITEMS_MARKED_PRIMARY.format(itemName))
      body[itemName].append(itemValue)

  def _splitSchemaNameDotFieldName(sn_fn, fnRequired=True):
    if sn_fn.find('.') != -1:
      schemaName, fieldName = sn_fn.split('.', 1)
      schemaName = schemaName.strip()
      fieldName = fieldName.strip()
      if schemaName and fieldName:
        return (schemaName, fieldName)
    elif not fnRequired:
      schemaName = sn_fn.strip()
      if schemaName:
        return (schemaName, None)
    invalidArgumentExit(Cmd.OB_SCHEMA_NAME_FIELD_NAME)

  parameters = {
    'verifyNotInvitable': False,
    'createIfNotFound': False,
    'noActionIfAlias': False,
    'notifyOnUpdate': True,
    'setChangePasswordOnCreate': False,
    'immutableOUs': set(),
    'addNumericSuffixOnDuplicate': 0,
    'lic': None,
    LICENSE_PRODUCT_SKUIDS: [],
    }
  if updateCmd:
    body = {}
  else:
    body = {'name': {'givenName': UNKNOWN, 'familyName': UNKNOWN}}
    body['primaryEmail'] = getEmailAddress(noUid=noUid)
  notFoundBody = {}
  notify = {'subject': '', 'message': '', 'html': False, 'charset': UTF8, 'password': ''}
  primary = {}
  updatePrimaryEmail = {}
  groupOrgUnitMap = None
  tagReplacements = _initTagReplacements()
  addGroups = {}
  addAliases = []
  PwdOpts = PasswordOptions(updateCmd)
  resolveConflictAccount = True
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'notify':
      notify['recipients'] = getNormalizedEmailAddressEntity(shlexSplit=True, noLower=True)
    elif myarg == 'subject':
      notify['subject'] = getString(Cmd.OB_STRING)
    elif myarg in SORF_MSG_FILE_ARGUMENTS:
      notify['message'], notify['charset'], notify['html'] = getStringOrFile(myarg)
    elif myarg == 'html':
      notify['html'] = getBoolean()
    elif myarg == 'from':
      notify['from'] = getString(Cmd.OB_EMAIL_ADDRESS)
    elif myarg == 'replyto':
      notify['replyto'] = getString(Cmd.OB_EMAIL_ADDRESS)
    elif myarg == 'mailbox':
      notify['mailbox'] = getString(Cmd.OB_EMAIL_ADDRESS)
    elif PwdOpts.ProcessArgument(myarg, notify, notFoundBody):
      pass
    elif _getTagReplacement(myarg, tagReplacements, True):
      pass
    elif myarg == 'admin':
      value = getBoolean()
      if updateCmd or value:
        Cmd.Backup()
        unknownArgumentExit()
    elif myarg == 'verifynotinvitable':
      parameters['verifyNotInvitable'] = True
    elif myarg == 'alwaysevict':
      resolveConflictAccount = False
    elif updateCmd and myarg == 'createifnotfound':
      parameters['createIfNotFound'] = True
    elif updateCmd and myarg == 'noactionifalias':
      parameters['noActionIfAlias'] = True
    elif updateCmd and myarg == 'notifyonupdate':
      parameters['notifyOnUpdate'] = getBoolean()
    elif updateCmd and myarg == 'setchangepasswordoncreate':
      parameters['setChangePasswordOnCreate'] = getBoolean()
    elif updateCmd and myarg in {'immutableous', 'immutableorgs', 'immutableorgunitpaths'}:
      parameters['immutableOUs'] = set(getEntityList(Cmd.OB_ORGUNIT_ENTITY, shlexSplit=True))
    elif not updateCmd and myarg == 'addnumericsuffixonduplicate':
      parameters['addNumericSuffixOnDuplicate'] = getInteger(minVal=0, default=0)
    elif not updateCmd and myarg in {'license', 'licence', 'licenses', 'licences'}:
      if parameters['lic'] is None:
        parameters['lic'] = buildGAPIObject(API.LICENSING)
      parameters[LICENSE_PRODUCT_SKUIDS] = getGoogleSKUList(allowUnknownProduct=True)
      if checkArgumentPresent(['product', 'productid']):
        productId = getGoogleProduct()
        for productSku in parameters[LICENSE_PRODUCT_SKUIDS]:
          productSku = (productId, productSku[1])
      for productSku in parameters[LICENSE_PRODUCT_SKUIDS]:
        if not productSku[0]:
          invalidChoiceExit(productSku[1], SKU.getSortedSKUList(), True)
    elif updateCmd and myarg == 'updateoufromgroup':
      groupOrgUnitMap = _getGroupOrgUnitMap()
    elif updateCmd and myarg == 'updateprimaryemail':
      updatePrimaryEmail = getREPatternSubstitution(re.IGNORECASE)
    elif myarg == 'json':
      body.update(getJSON(USER_JSON_SKIP_FIELDS))
      if 'name' in body and 'fullName' in body['name']:
        body['name'].pop('fullName')
      if 'sshPublicKeys' in body and 'fingerprint' in body['sshPublicKeys']:
        body['sshPublicKeys'].pop('fingerprint')
      for location in body.get('locations', []):
        location.pop('buildingName', None)
    elif myarg == 'employeeid':
      entry = {'type': 'organization', 'value': getString(Cmd.OB_STRING, minLen=0)}
      appendItemToBodyList(body, 'externalIds', entry, 'value')
    elif myarg == 'manager':
      entry = {'type': 'manager', 'value': getString(Cmd.OB_STRING, minLen=0)}
      appendItemToBodyList(body, 'relations', entry, 'value')
    elif myarg in UPDATE_USER_ARGUMENT_TO_PROPERTY_MAP:
      up = UPDATE_USER_ARGUMENT_TO_PROPERTY_MAP[myarg]
      userProperty = UProp.PROPERTIES[up]
      propertyClass = userProperty[UProp.CLASS]
      if UProp.TYPE_KEYWORDS in userProperty:
        typeKeywords = userProperty[UProp.TYPE_KEYWORDS]
        clTypeKeyword = typeKeywords[UProp.PTKW_CL_TYPE_KEYWORD]
      if up == 'givenName':
        body.setdefault('name', {})
        body['name'][up] = getString(Cmd.OB_STRING, minLen=0, maxLen=60)
      elif up == 'familyName':
        body.setdefault('name', {})
        body['name'][up] = getString(Cmd.OB_STRING, minLen=0, maxLen=60)
      elif up == 'displayName':
        body.setdefault('name', {})
        body['name']['displayName'] = getString(Cmd.OB_STRING, minLen=0, maxLen=256)
      elif PwdOpts.ProcessPropertyArgument(myarg, up, body):
        pass
      elif propertyClass == UProp.PC_BOOLEAN:
        body[up] = getBoolean()
      elif up == 'primaryEmail':
        if updateCmd:
          body[up] = getEmailAddress(noUid=True)
        elif body[up] != getEmailAddress(noUid=True):
          Cmd.Backup()
          unknownArgumentExit()
      elif up == 'recoveryEmail':
        rcvryEmail = getEmailAddress(noUid=True, optional=True)
        body[up] = rcvryEmail if rcvryEmail is not None else ""
      elif up == 'recoveryPhone':
        body[up] = getString(Cmd.OB_STRING, minLen=0)
        if body[up] and body[up][0] != '+':
          body[up] = '+' + body[up]
      elif up == 'customerId':
        body[up] = getString(Cmd.OB_STRING)
      elif up == 'orgUnitPath':
        body[up] = getOrgUnitItem(pathOnly=True, cd=cd)
      elif up == 'languages':
        if checkClearBodyList(body, up):
          continue
        for language in getString(Cmd.OB_LANGUAGE_LIST).replace('_', '-').replace(',', ' ').split():
          langItem = {}
          if language[-1] == '+':
            suffix = '+'
            language = language[:-1]
            langItem['preference'] = 'preferred'
          elif language[-1] == '-':
            suffix = '-'
            language = language[:-1]
            langItem['preference'] = 'not_preferred'
          else:
            suffix = ''
          if language.lower() in LANGUAGE_CODES_MAP:
            langItem['languageCode'] = LANGUAGE_CODES_MAP[language.lower()]
          else:
            if suffix:
              Cmd.Backup()
              usageErrorExit(Msg.SUFFIX_NOT_ALLOWED_WITH_CUSTOMLANGUAGE.format(suffix, language))
            langItem['customLanguage'] = language
          appendItemToBodyList(body, up, langItem)
      elif up == 'gender':
        if checkClearBodyList(body, up):
          continue
        entry = {}
        getChoice([clTypeKeyword], defaultChoice=None)
        getKeywordAttribute(typeKeywords, entry)
        if checkArgumentPresent('addressmeas'):
          entry['addressMeAs'] = getString(Cmd.OB_STRING, minLen=0)
        body[up] = entry
      elif up == 'addresses':
        if checkClearBodyList(body, up):
          continue
        entry = {}
        getChoice([clTypeKeyword], defaultChoice=None)
        getKeywordAttribute(typeKeywords, entry)
        if checkArgumentPresent(['unstructured', 'formatted']):
          entry['sourceIsStructured'] = False
          entry['formatted'] = getStringWithCRsNLs()
        while Cmd.ArgumentsRemaining():
          argument = getArgument()
          if argument in ADDRESS_ARGUMENT_TO_FIELD_MAP:
            value = getString(Cmd.OB_STRING, minLen=0)
            if value:
              entry[ADDRESS_ARGUMENT_TO_FIELD_MAP[argument]] = value
          elif primaryNotPrimary(argument, entry):
            break
          else:
            unknownArgumentExit()
        appendItemToBodyList(body, up, entry)
      elif up == 'ims':
        if checkClearBodyList(body, up):
          continue
        entry = {}
        getChoice([clTypeKeyword], defaultChoice=None)
        getKeywordAttribute(typeKeywords, entry)
        getChoice([UProp.IM_PROTOCOLS[UProp.PTKW_CL_TYPE_KEYWORD]])
        getKeywordAttribute(UProp.IM_PROTOCOLS, entry)
        # Backwards compatability: notprimary|primary on either side of IM address
        getPrimaryNotPrimaryChoice(entry, False)
        entry['im'] = getString(Cmd.OB_STRING, minLen=0)
        getPrimaryNotPrimaryChoice(entry, entry.get('primary', False))
        appendItemToBodyList(body, up, entry, 'im')
      elif up == 'keywords':
        if checkClearBodyList(body, up):
          continue
        entry = {}
        getChoice([clTypeKeyword], defaultChoice=None)
        getKeywordAttribute(typeKeywords, entry)
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        appendItemToBodyList(body, up, entry, 'value')
      elif up == 'locations':
        if checkClearBodyList(body, up):
          continue
        entry = {'type': 'desk'}
        while Cmd.ArgumentsRemaining():
          argument = getArgument()
          if argument == clTypeKeyword:
            getKeywordAttribute(typeKeywords, entry)
          elif argument == 'area':
            entry['area'] = getString(Cmd.OB_STRING)
          elif argument in {'building', 'buildingid'}:
            entry['buildingId'] = _getBuildingByNameOrId(cd, allowNV=True)
          elif argument in {'floor', 'floorname'}:
            entry['floorName'] = getString(Cmd.OB_STRING, minLen=0)
          elif argument in {'section', 'floorsection'}:
            entry['floorSection'] = getString(Cmd.OB_STRING, minLen=0)
          elif argument in {'desk', 'deskcode'}:
            entry['deskCode'] = getString(Cmd.OB_STRING, minLen=0)
          elif argument == 'endlocation':
            break
          else:
            unknownArgumentExit()
        if 'area' not in entry:
          missingArgumentExit('area <String>')
        appendItemToBodyList(body, up, entry)
      elif up == 'notes':
        if checkClearBodyList(body, up):
          continue
        entry = {}
        getKeywordAttribute(typeKeywords, entry, defaultChoice='text_plain')
        entry['value'] = getStringWithCRsNLsOrFile()[0]
        body[up] = entry
      elif up == 'organizations':
        if checkClearBodyList(body, up):
          continue
        entry = {}
        while Cmd.ArgumentsRemaining():
          argument = getArgument()
          if argument == clTypeKeyword:
            getKeywordAttribute(typeKeywords, entry)
          elif argument == typeKeywords[UProp.PTKW_CL_CUSTOMTYPE_KEYWORD]:
            entry[typeKeywords[UProp.PTKW_ATTR_CUSTOMTYPE_KEYWORD]] = getString(Cmd.OB_STRING, minLen=0)
            entry.pop(typeKeywords[UProp.PTKW_ATTR_TYPE_KEYWORD], None)
          elif argument in ORGANIZATION_ARGUMENT_TO_FIELD_MAP:
            argument = ORGANIZATION_ARGUMENT_TO_FIELD_MAP[argument]
            if argument != 'fullTimeEquivalent':
              value = getString(Cmd.OB_STRING, minLen=0)
              if value:
                entry[argument] = value
            else:
              entry[argument] = getInteger(minVal=0, maxVal=100000, default=0)
          elif primaryNotPrimary(argument, entry):
            break
          else:
            unknownArgumentExit()
        appendItemToBodyList(body, up, entry)
      elif up == 'phones':
        if checkClearBodyList(body, up):
          continue
        entry = {}
        while Cmd.ArgumentsRemaining():
          argument = getArgument()
          if argument == clTypeKeyword:
            getKeywordAttribute(typeKeywords, entry)
          elif argument == 'value':
            entry['value'] = getString(Cmd.OB_STRING, minLen=0)
          elif primaryNotPrimary(argument, entry):
            break
          else:
            unknownArgumentExit()
        appendItemToBodyList(body, up, entry, 'value')
      elif up == 'posixAccounts':
        if checkClearBodyList(body, up):
          continue
        entry = {}
        while Cmd.ArgumentsRemaining():
          argument = getArgument()
          if argument in {'username', 'name'}:
            entry['username'] = getString(Cmd.OB_STRING)
          elif argument == 'uid':
            entry['uid'] = getInteger(minVal=1000)
          elif argument == 'gid':
            entry['gid'] = getInteger(minVal=0)
          elif argument in {'system', 'systemid'}:
            entry['systemId'] = getString(Cmd.OB_STRING, minLen=0)
          elif argument in {'home', 'homedirectory'}:
            entry['homeDirectory'] = getString(Cmd.OB_STRING, minLen=0)
          elif argument == 'shell':
            entry['shell'] = getString(Cmd.OB_STRING, minLen=0)
          elif argument == 'gecos':
            entry['gecos'] = getString(Cmd.OB_STRING, minLen=0)
          elif argument == 'primary':
            primary['location'] = Cmd.Location()
            entry['primary'] = getBoolean()
          elif argument in {'os', 'operatingsystemtype'}:
            entry['operatingSystemType'] = getChoice(['linux', 'unspecified', 'windows'])
          elif argument == 'endposix':
            break
          else:
            unknownArgumentExit()
        if 'username' not in entry:
          missingArgumentExit('username <String>')
        if 'uid' not in entry:
          missingArgumentExit('uid <Integer>')
        appendItemToBodyList(body, up, entry, checkSystemId=True)
      elif up == 'relations':
        if checkClearBodyList(body, up):
          continue
        entry = {}
        getChoice([clTypeKeyword], defaultChoice=None)
        getKeywordAttribute(typeKeywords, entry)
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        appendItemToBodyList(body, up, entry, 'value')
      elif up == 'emails':
        if checkClearBodyList(body, up):
          continue
        entry = {}
        getChoice([clTypeKeyword], defaultChoice=None)
        getKeywordAttribute(typeKeywords, entry)
        entry['address'] = getEmailAddress(noUid=True, minLen=0)
        appendItemToBodyList(body, up, entry, 'address')
      elif up == 'sshPublicKeys':
        if checkClearBodyList(body, up):
          continue
        entry = {}
        while Cmd.ArgumentsRemaining():
          argument = getArgument()
          if argument == 'expires':
            entry['expirationTimeUsec'] = getInteger(minVal=0)
          elif argument == 'key':
            entry['key'] = getString(Cmd.OB_STRING)
          elif argument == 'endssh':
            break
          else:
            unknownArgumentExit()
        if 'key' not in entry:
          missingArgumentExit('key <String>')
        appendItemToBodyList(body, up, entry)
      elif up == 'externalIds':
        if checkClearBodyList(body, up):
          continue
        entry = {}
        getChoice([clTypeKeyword], defaultChoice=None)
        getKeywordAttribute(typeKeywords, entry, allowEmptyCustomType=True)
        entry['value'] = getString(Cmd.OB_STRING, minLen=0)
        appendItemToBodyList(body, up, entry, 'value')
      elif up == 'websites':
        if checkClearBodyList(body, up):
          continue
        entry = {}
        getChoice([clTypeKeyword], defaultChoice=None)
        getKeywordAttribute(typeKeywords, entry)
        entry['value'] = getString(Cmd.OB_URL, minLen=0)
        getPrimaryNotPrimaryChoice(entry, False)
        appendItemToBodyList(body, up, entry, 'value')
    elif myarg in {'group', 'groups'}:
      role = getChoice(GROUP_ROLES_MAP, defaultChoice=Ent.ROLE_MEMBER, mapChoice=True)
      delivery_settings = getDeliverySettings()
      for group in getEntityList(Cmd.OB_GROUP_ENTITY):
        addGroups[normalizeEmailAddressOrUID(group)] = {'role': role, 'delivery_settings': delivery_settings}
    elif myarg in {'alias', 'aliases'}:
      addAliases.extend(convertEntityToList(getString(Cmd.OB_EMAIL_ADDRESS_LIST, minLen=0)))
    elif myarg == 'clearschema':
      if not updateCmd:
        unknownArgumentExit()
      schemaName, fieldName = _splitSchemaNameDotFieldName(getString(Cmd.OB_SCHEMA_NAME_FIELD_NAME), False)
      up = 'customSchemas'
      body.setdefault(up, {})
      body[up].setdefault(schemaName, {})
      if fieldName is None:
        try:
          schema = callGAPI(cd.schemas(), 'get',
                            throwReasons=[GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                            customerId=GC.Values[GC.CUSTOMER_ID], schemaKey=schemaName, fields='fields(fieldName)')
          for field in schema['fields']:
            body[up][schemaName][field['fieldName']] = None
        except (GAPI.invalid, GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
          entityDoesNotExistWarning(Ent.USER_SCHEMA, schemaName)
          unknownArgumentExit()
      else:
        body[up][schemaName][fieldName] = None
    elif myarg.find('.') >= 0:
      schemaName, fieldName = _splitSchemaNameDotFieldName(Cmd.Previous())
      up = 'customSchemas'
      body.setdefault(up, {})
      body[up].setdefault(schemaName, {})
      multivalue, ignoreEmpty = getChoice(SCHEMA_VALUE_PROCESS_MAP, defaultChoice=(False, False), mapChoice=True)
      if multivalue:
        body[up][schemaName].setdefault(fieldName, [])
        typeKeywords = UProp.PROPERTIES[up][UProp.TYPE_KEYWORDS]
        clTypeKeyword = typeKeywords[UProp.PTKW_CL_TYPE_KEYWORD]
        schemaValue = {}
        if checkArgumentPresent(clTypeKeyword):
          getKeywordAttribute(typeKeywords, schemaValue)
        else:
          schemaValue['type'] = 'work'
        schemaValue['value'] = getString(Cmd.OB_STRING, minLen=0)
        if schemaValue['value'] or not ignoreEmpty:
          body[up][schemaName][fieldName].append(schemaValue)
      else:
        schemaValue = getString(Cmd.OB_STRING, minLen=0)
        if schemaValue or not ignoreEmpty:
          body[up][schemaName][fieldName] = schemaValue
        elif updateCmd:
          body[up][schemaName][fieldName] = None
    else:
      unknownArgumentExit()
  if PwdOpts.promptForPassword or PwdOpts.promptForUniquePassword:
    if not updateCmd:
      PwdOpts.AssignPassword(body, notify, notFoundBody, parameters['createIfNotFound'], body['primaryEmail'])
    elif not PwdOpts.promptForUniquePassword:
      PwdOpts.AssignPassword(body, notify, notFoundBody, parameters['createIfNotFound'])
  elif not PwdOpts.makeUniqueRandomPassword:
    PwdOpts.AssignPassword(body, notify, notFoundBody, parameters['createIfNotFound'])
  return (body, notify, tagReplacements, addGroups, addAliases, PwdOpts,
          updatePrimaryEmail, notFoundBody, groupOrgUnitMap, parameters,
          resolveConflictAccount)

def createUserAddToGroups(cd, user, addGroups, i, count):
  action = Act.Get()
  Act.Set(Act.ADD)
  Ind.Increment()
  _addUserToGroups(cd, user, set(addGroups), addGroups, i, count)
  Ind.Decrement()
  Act.Set(action)

def createUserAddAliases(cd, user, aliasList, i, count):
  action = Act.Get()
  Act.Set(Act.ADD)
  Ind.Increment()
  _addUserAliases(cd, user, aliasList, i, count)
  Ind.Decrement()
  Act.Set(action)

# gam create user <EmailAddress> <UserAttribute>
#	[verifynotinvitable|alwaysevict]
#	(groups [<GroupRole>] [[delivery] <DeliverySetting>] <GroupEntity>)*
#	[alias|aliases <EmailAddressList>]
#	[license <SKUID> [product|productid <ProductID>]]
#	[notify <EmailAddressList>
#	    [subject <String>]
#	    [notifypassword <String>]
#	    [from <EmailAaddress>]
#	    [replyto <EmailAaddress>]
#	    [<NotifyMessageContent>]
#	    (replace <Tag> <UserReplacement>)*
#	    (replaceregex <REMatchPattern> <RESubstitution> <Tag> <UserReplacement>)*]
#	[logpassword <FileName>] [ignorenullpassword]
#	[addnumericsuffixonduplicate <Number>]
def doCreateUser():
  cd = buildGAPIObject(API.DIRECTORY)
  body, notify, tagReplacements, addGroups, addAliases, PwdOpts, \
    _, _, _, \
    parameters, resolveConflictAccount = getUserAttributes(cd, False, noUid=True)
  suffix = 0
  originalEmail = body['primaryEmail']
  atLoc = originalEmail.find('@')
  fields = '*' if tagReplacements['subs'] else 'primaryEmail,name'
  while True:
    user = body['primaryEmail']
    if parameters['verifyNotInvitable']:
      isInvitableUser, _ = _getIsInvitableUser(None, user)
      if isInvitableUser:
        entityActionNotPerformedWarning([Ent.USER, user], Msg.EMAIL_ADDRESS_IS_UNMANAGED_ACCOUNT)
        return
    try:
      result = callGAPI(cd.users(), 'insert',
                        throwReasons=[GAPI.DUPLICATE, GAPI.DOMAIN_NOT_FOUND,
                                      GAPI.DOMAIN_CANNOT_USE_APIS, GAPI.FORBIDDEN,
                                      GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.INVALID_PARAMETER,
                                      GAPI.INVALID_ORGUNIT, GAPI.INVALID_SCHEMA_VALUE, GAPI.CONDITION_NOT_MET,
                                      GAPI.LIMIT_EXCEEDED],
                        body=body,
                        fields=fields,
                        resolveConflictAccount=resolveConflictAccount)
      entityActionPerformed([Ent.USER, user])
      break
    except GAPI.duplicate:
      if duplicateAliasGroupUserWarning(cd, [Ent.USER, user]) == Ent.USER and parameters['addNumericSuffixOnDuplicate'] > 0:
        parameters['addNumericSuffixOnDuplicate'] -= 1
        suffix +=1
        body['primaryEmail'] = originalEmail[0:atLoc]+str(suffix)+originalEmail[atLoc:]
        setSysExitRC(0)
        continue
      return
    except GAPI.invalidSchemaValue:
      entityActionFailedExit([Ent.USER, user], Msg.INVALID_SCHEMA_VALUE)
    except GAPI.invalidOrgunit:
      entityActionFailedExit([Ent.USER, user], Msg.INVALID_ORGUNIT)
    except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
            GAPI.invalid, GAPI.invalidInput, GAPI.invalidParameter, GAPI.conditionNotMet, GAPI.limitExceeded) as e:
      entityActionFailedExit([Ent.USER, user], str(e))
  if PwdOpts.filename and PwdOpts.password:
    writeFile(PwdOpts.filename, f'{user},{PwdOpts.password}\n', mode='a', continueOnError=True)
  if addGroups:
    createUserAddToGroups(cd, result['primaryEmail'], addGroups, 0, 0)
  if addAliases:
    createUserAddAliases(cd, result['primaryEmail'], addAliases, 0, 0)
  if notify.get('recipients'):
    sendCreateUpdateUserNotification(result, notify, tagReplacements)
  for productSku in parameters[LICENSE_PRODUCT_SKUIDS]:
    productId = productSku[0]
    skuId = productSku[1]
    try:
      callGAPI(parameters['lic'].licenseAssignments(), 'insert',
               throwReasons=[GAPI.INTERNAL_ERROR, GAPI.DUPLICATE, GAPI.CONDITION_NOT_MET, GAPI.INVALID,
                             GAPI.USER_NOT_FOUND, GAPI.FORBIDDEN, GAPI.BACKEND_ERROR, GAPI.SERVICE_NOT_AVAILABLE],
               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
               productId=productId, skuId=skuId, body={'userId': user}, fields='')
      entityActionPerformed([Ent.USER, user, Ent.LICENSE, SKU.formatSKUIdDisplayName(skuId)])
    except (GAPI.internalError, GAPI.duplicate, GAPI.conditionNotMet, GAPI.invalid,
            GAPI.userNotFound, GAPI.forbidden, GAPI.backendError, GAPI.serviceNotAvailable) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.LICENSE, SKU.formatSKUIdDisplayName(skuId)], str(e))

def verifyUserPrimaryEmail(cd, user, createIfNotFound, i, count):
  try:
    result = callGAPI(cd.users(), 'get',
                      throwReasons=GAPI.USER_GET_THROW_REASONS,
                      userKey=user, fields='id,primaryEmail')
    if (result['primaryEmail'].lower() == user) or (result['id'] == user):
      return True
    entityActionNotPerformedWarning([Ent.USER, user], Msg.NOT_A_PRIMARY_EMAIL_ADDRESS, i, count)
    return False
  except GAPI.userNotFound:
    if createIfNotFound:
      return True
  except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.backendError, GAPI.systemError):
    pass
  entityUnknownWarning(Ent.USER, user, i, count)
  return False

# gam <UserTypeEntity> update user <UserAttribute>*
#	[verifynotinvitable|alwaysevict] [noactionifalias]
#	[updateprimaryemail <RESEarchPattern> <RESubstitution>]
#	[updateoufromgroup <CSVFileInput> [keyfield <FieldName>] [datafield <FieldName>]]
#	[immutableous <OrgUnitEntity>]|
#	[clearschema <SchemaName>|<SchemaNameField>]
#	[createifnotfound] [notfoundpassword (random [<Integer>])|blocklogin|<Password>]
#	(groups [<GroupRole>] [[delivery] <DeliverySetting>] <GroupEntity>)*
#	[alias|aliases <EmailAddressList>]
#	[notify <EmailAddressList>
#	    [subject <String>]
#	    [notifypassword <String>]
#	    [from <EmailAaddress>]
#	    [replyto <EmailAaddress>]
#	    [<NotifyMessageContent>
#	        (replace <Tag> <UserReplacement>)*
#	        (replaceregex <REMatchPattern> <RESubstitution> <Tag> <UserReplacement>)*]
#	[notifyonupdate [<Boolean>]]
#	[logpassword <FileName>] [ignorenullpassword]
def updateUsers(entityList):
  def waitingForCreationToComplete(sleep_time):
    writeStderr(Ind.Spaces()+Msg.WAITING_FOR_ITEM_CREATION_TO_COMPLETE_SLEEPING.format(Ent.Singular(Ent.USER), sleep_time))
    time.sleep(sleep_time)

  cd = buildGAPIObject(API.DIRECTORY)
  ci = None
  errorRetries = 5
  updateRetryDelay = 5
  body, notify, tagReplacements, addGroups, addAliases, PwdOpts, \
    updatePrimaryEmail, notFoundBody, groupOrgUnitMap, \
    parameters, resolveConflictAccount = getUserAttributes(cd, True)
  vfe = 'primaryEmail' in body and body['primaryEmail'][:4].lower() == 'vfe@'
  if body.get('orgUnitPath', '') and parameters['immutableOUs']:
    ubody = body.copy()
    checkImmutableOUs = True
  else:
    checkImmutableOUs = False
  i, count, entityList = getEntityArgument(entityList)
  fields = '*' if tagReplacements['subs'] else 'primaryEmail,name'
  for user in entityList:
    i += 1
    user = userKey = normalizeEmailAddressOrUID(user)
    if parameters['noActionIfAlias'] and not verifyUserPrimaryEmail(cd, user, parameters['createIfNotFound'], i, count):
      continue
    if checkImmutableOUs:
      body = ubody.copy()
    try:
      if vfe:
        result = callGAPI(cd.users(), 'get',
                          throwReasons=GAPI.USER_GET_THROW_REASONS,
                          userKey=userKey, fields='primaryEmail,id')
        userKey = result['id']
        userPrimary = result['primaryEmail']
        userName, userDomain = splitEmailAddress(userPrimary)
        body['primaryEmail'] = f'vfe.{userName}.{random.randint(1, 99999):05d}@{userDomain}'
        body['emails'] = [{'type': 'custom',
                           'customType': 'former_employee',
                           'primary': False, 'address': userPrimary}]
      elif updatePrimaryEmail:
        if updatePrimaryEmail[0].search(user) is not None:
          body['primaryEmail'] = re.sub(updatePrimaryEmail[0], updatePrimaryEmail[1], user)
        else:
          body.pop('primaryEmail', None)
          if not body:
            entityActionNotPerformedWarning([Ent.USER, user], Msg.PRIMARY_EMAIL_DID_NOT_MATCH_PATTERN.format(updatePrimaryEmail[0].pattern), i, count)
      if groupOrgUnitMap:
        try:
          groups = callGAPIpages(cd.groups(), 'list', 'groups',
                                 throwReasons=GAPI.GROUP_LIST_USERKEY_THROW_REASONS,
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 userKey=userKey, orderBy='email', fields='nextPageToken,groups(email)')
        except (GAPI.invalidMember, GAPI.invalidInput):
          entityUnknownWarning(Ent.USER, userKey, i, count)
          continue
        groupList = []
        for group in groups:
          orgUnit = groupOrgUnitMap.get(group['email'].lower())
          if orgUnit:
            groupList.append(group['email'])
        jcount = len(groupList)
        if jcount != 1:
          entityActionNotPerformedWarning([Ent.USER, user], Msg.USER_BELONGS_TO_N_GROUPS_THAT_MAP_TO_ORGUNITS.format(jcount, ','.join(groupList)), i, count)
          continue
        body['orgUnitPath'] = orgUnit
      if checkImmutableOUs:
        try:
          result = callGAPI(cd.users(), 'get',
                            throwReasons=GAPI.USER_GET_THROW_REASONS,
                            userKey=userKey, fields='orgUnitPath')
          if result['orgUnitPath'] in parameters['immutableOUs']:
            body.pop('orgUnitPath')
        except Exception:
          pass
      if body:
        if 'primaryEmail' in body and parameters['verifyNotInvitable']:
          isInvitableUser, ci = _getIsInvitableUser(ci, body['primaryEmail'])
          if isInvitableUser:
            entityActionNotPerformedWarning([Ent.USER, body['primaryEmail']], Msg.EMAIL_ADDRESS_IS_UNMANAGED_ACCOUNT, i, count)
            continue
        if PwdOpts.makeUniqueRandomPassword or PwdOpts.promptForUniquePassword:
          PwdOpts.AssignPassword(body, notify, notFoundBody, parameters['createIfNotFound'], userKey)
        retry = 0
        while True:
          try:
            result = callGAPI(cd.users(), 'update',
                              throwReasons=[GAPI.CONDITION_NOT_MET, GAPI.USER_NOT_FOUND, GAPI.DOMAIN_NOT_FOUND,
                                            GAPI.FORBIDDEN, GAPI.BAD_REQUEST,
                                            GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.INVALID_PARAMETER,
                                            GAPI.INVALID_ORGUNIT, GAPI.INVALID_SCHEMA_VALUE, GAPI.DUPLICATE,
                                            GAPI.INSUFFICIENT_ARCHIVED_USER_LICENSES, GAPI.CONFLICT],
                              userKey=userKey, body=body, fields=fields)
            entityActionPerformed([Ent.USER, user], i, count)
            if PwdOpts.filename and PwdOpts.password:
              writeFile(PwdOpts.filename, f'{userKey},{PwdOpts.password}\n', mode='a', continueOnError=True)
            if parameters['notifyOnUpdate'] and notify.get('recipients') and notify['password']:
              sendCreateUpdateUserNotification(result, notify, tagReplacements, i, count, createMessage=False)
            break
          except GAPI.conditionNotMet as e:
            retry += 1
            if ('User creation is not complete' not in str(e)) or retry > errorRetries:
              entityActionFailedWarning([Ent.USER, user], str(e), i, count)
              break
            waitingForCreationToComplete(updateRetryDelay)
            continue
          except GAPI.userNotFound:
            if parameters['createIfNotFound']:
              if notFoundBody and (count == 1) and not vfe and ('password' in notFoundBody) and ('name' in body) and ('givenName' in body['name']) and ('familyName' in body['name']):
                if 'primaryEmail' not in body:
                  body['primaryEmail'] = user
                body.update(notFoundBody)
                if parameters['setChangePasswordOnCreate']:
                  body['changePasswordAtNextLogin'] = True
                Act.Set(Act.CREATE)
                try:
                  result = callGAPI(cd.users(), 'insert',
                                    throwReasons=[GAPI.DUPLICATE, GAPI.DOMAIN_NOT_FOUND, GAPI.FORBIDDEN,
                                                  GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.INVALID_PARAMETER,
                                                  GAPI.INVALID_ORGUNIT, GAPI.INVALID_SCHEMA_VALUE, GAPI.CONDITION_NOT_MET],
                                    body=body,
                                    fields=fields,
                                    resolveConflictAccount=resolveConflictAccount)
                  entityActionPerformed([Ent.USER, body['primaryEmail']], i, count)
                  if PwdOpts.filename and PwdOpts.notFoundPassword:
                    writeFile(PwdOpts.filename, f'{user},{PwdOpts.notFoundPassword}\n', mode='a', continueOnError=True)
                  if addGroups:
                    createUserAddToGroups(cd, result['primaryEmail'], addGroups, i, count)
                  if addAliases:
                    createUserAddAliases(cd, result['primaryEmail'], addAliases, i, count)
                  if notify.get('recipients'):
                    notify['password'] = notify['notFoundPassword']
                    sendCreateUpdateUserNotification(result, notify, tagReplacements, i, count)
                except GAPI.duplicate:
                  duplicateAliasGroupUserWarning(cd, [Ent.USER, body['primaryEmail']], i, count)
              else:
                entityActionFailedWarning([Ent.USER, user], Msg.UNABLE_TO_CREATE_NOT_FOUND_USER, i, count)
            else:
              entityUnknownWarning(Ent.USER, user, i, count)
          break
      else:
        entityActionNotPerformedWarning([Ent.USER, user], Msg.NO_CHANGES, i, count)
    except GAPI.userNotFound:
      entityUnknownWarning(Ent.USER, user, i, count)
    except GAPI.invalidSchemaValue:
      entityActionFailedWarning([Ent.USER, user], Msg.INVALID_SCHEMA_VALUE, i, count)
    except GAPI.duplicate as e:
      entityActionFailedWarning([Ent.USER, user, Ent.USER, body['primaryEmail']], str(e), i, count)
    except GAPI.invalidOrgunit:
      entityActionFailedWarning([Ent.USER, user], Msg.INVALID_ORGUNIT, i, count)
    except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest,
            GAPI.invalid, GAPI.invalidInput, GAPI.invalidParameter, GAPI.insufficientArchivedUserLicenses,
            GAPI.conflict, GAPI.badRequest, GAPI.backendError, GAPI.systemError, GAPI.conditionNotMet) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)

# gam update users <UserTypeEntity> ...
def doUpdateUsers():
  updateUsers(getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)[1])

# gam update user <UserItem> ...
def doUpdateUser():
  updateUsers(getStringReturnInList(Cmd.OB_USER_ITEM))

# gam <UserTypeEntity> delete users [noactionifalias]
def deleteUsers(entityList):
  cd = buildGAPIObject(API.DIRECTORY)
  noActionIfAlias = checkArgumentPresent('noactionifalias')
  checkForExtraneousArguments()
  i, count, entityList = getEntityArgument(entityList)
  for user in entityList:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    if noActionIfAlias and not verifyUserPrimaryEmail(cd, user, False, i, count):
      continue
    try:
      callGAPI(cd.users(), 'delete',
               throwReasons=[GAPI.USER_NOT_FOUND, GAPI.DOMAIN_NOT_FOUND,
                             GAPI.DOMAIN_CANNOT_USE_APIS, GAPI.FORBIDDEN,
                             GAPI.CONDITION_NOT_MET],
               userKey=user)
      entityActionPerformed([Ent.USER, user], i, count)
    except GAPI.userNotFound:
      entityUnknownWarning(Ent.USER, user, i, count)
    except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
    except GAPI.conditionNotMet as e:
      entityActionFailedWarning([Ent.USER, user], Msg.CAN_NOT_DELETE_USER_WITH_VAULT_HOLD.format(str(e), user), i, count)

# gam delete users <UserTypeEntity> [noactionifalias]
def doDeleteUsers():
  deleteUsers(getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)[1])

# gam delete user <UserItem> [noactionifalias]
def doDeleteUser():
  deleteUsers(getStringReturnInList(Cmd.OB_USER_ITEM))

# gam <UserEntity> undelete users [ou|org|orgunit <OrgUnitPath>]
def undeleteUsers(entityList):
  cd = buildGAPIObject(API.DIRECTORY)
  if checkArgumentPresent(['ou', 'org', 'orgunit']):
    entitySelector = getEntitySelector()
    if entitySelector:
      orgUnitPaths = getEntitySelection(entitySelector, True)
    else:
      orgUnitPaths = [getOrgUnitItem()]
    userOrgUnitLists = orgUnitPaths if isinstance(orgUnitPaths, dict) else None
  else:
    orgUnitPaths = ['/']
    userOrgUnitLists = None
  checkForExtraneousArguments()
  i, count, entityList = getEntityArgument(entityList)
  for user in entityList:
    i += 1
    origUser = user
    user = normalizeEmailAddressOrUID(user)
    user_uid = user if user.find('@') == -1 else None
    if not user_uid:
      printEntityKVList([Ent.DELETED_USER, user],
                        [Msg.LOOKING_UP_GOOGLE_UNIQUE_ID, None],
                        i, count)
      try:
        deleted_users = callGAPIpages(cd.users(), 'list', 'users',
                                      throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                                      customer=GC.Values[GC.CUSTOMER_ID], showDeleted=True, orderBy='email',
                                      maxResults=GC.Values[GC.USER_MAX_RESULTS])
      except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
        accessErrorExit(cd)
      matching_users = []
      for deleted_user in deleted_users:
        if str(deleted_user['primaryEmail']).lower() == user:
          matching_users.append(deleted_user)
      jcount = len(matching_users)
      if jcount == 0:
        entityUnknownWarning(Ent.DELETED_USER, user, i, count)
        setSysExitRC(NO_ENTITIES_FOUND_RC)
        continue
      if jcount > 1:
        entityActionNotPerformedWarning([Ent.DELETED_USER, user],
                                        Msg.PLEASE_SELECT_ENTITY_TO_PROCESS.format(jcount, Ent.Plural(Ent.DELETED_USER), 'undelete', 'uid:<String>'),
                                        i, count)
        Ind.Increment()
        j = 0
        for matching_user in matching_users:
          printEntity([Ent.UNIQUE_ID, matching_user['id']], j, jcount)
          Ind.Increment()
          for attr_name in ['creationTime', 'lastLoginTime', 'deletionTime']:
            if attr_name in matching_user:
              printKeyValueList([attr_name, formatLocalTime(matching_user[attr_name])])
          Ind.Decrement()
        Ind.Decrement()
        setSysExitRC(MULTIPLE_DELETED_USERS_FOUND_RC)
        continue
      user_uid = matching_users[0]['id']
    if userOrgUnitLists:
      orgUnitPaths = userOrgUnitLists[origUser]
    try:
      callGAPI(cd.users(), 'undelete',
               throwReasons=[GAPI.DELETED_USER_NOT_FOUND, GAPI.INVALID_ORGUNIT,
                             GAPI.DOMAIN_NOT_FOUND, GAPI.DOMAIN_CANNOT_USE_APIS,
                             GAPI.FORBIDDEN, GAPI.BAD_REQUEST, GAPI.INVALID, GAPI.DUPLICATE,
                             GAPI.LIMIT_EXCEEDED],
               userKey=user_uid, body={'orgUnitPath': makeOrgUnitPathAbsolute(orgUnitPaths[0])})
      entityActionPerformed([Ent.DELETED_USER, user], i, count)
    except GAPI.deletedUserNotFound:
      entityUnknownWarning(Ent.DELETED_USER, user, i, count)
    except GAPI.invalidOrgunit:
      entityActionFailedWarning([Ent.USER, user], Msg.INVALID_ORGUNIT, i, count)
    except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest,
            GAPI.invalid, GAPI.duplicate, GAPI.limitExceeded) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)

# gam undelete users <UserEntity> [ou|org|orgunit <OrgUnitPath>]
def doUndeleteUsers():
  undeleteUsers(getEntityList(Cmd.OB_USER_ENTITY))

# gam undelete user <UserItem> [ou|org|orgunit <OrgUnitPath>]
def doUndeleteUser():
  undeleteUsers(getStringReturnInList(Cmd.OB_USER_ITEM))

# gam <UserTypeEntity> suspend users [noactionifalias]
# gam <UserTypeEntity> unsuspend users [noactionifalias]
def suspendUnsuspendUsers(entityList):
  cd = buildGAPIObject(API.DIRECTORY)
  noActionIfAlias = checkArgumentPresent('noactionifalias')
  checkForExtraneousArguments()
  body = {'suspended': Act.Get() == Act.SUSPEND}
  i, count, entityList = getEntityArgument(entityList)
  for user in entityList:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    if noActionIfAlias and not verifyUserPrimaryEmail(cd, user, False, i, count):
      continue
    try:
      callGAPI(cd.users(), 'update',
               throwReasons=[GAPI.USER_NOT_FOUND, GAPI.DOMAIN_NOT_FOUND,
                             GAPI.DOMAIN_CANNOT_USE_APIS, GAPI.FORBIDDEN, GAPI.BAD_REQUEST],
               userKey=user, body=body)
      entityActionPerformed([Ent.USER, user], i, count)
    except GAPI.userNotFound:
      entityUnknownWarning(Ent.USER, user, i, count)
    except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)

# gam suspend users <UserTypeEntity> [noactionifalias]
# gam unsuspend users <UserTypeEntity> [noactionifalias]
def doSuspendUnsuspendUsers():
  suspendUnsuspendUsers(getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)[1])

# gam suspend user <UserItem> [noactionifalias]
# gam unsuspend user <UserItem> [noactionifalias]
def doSuspendUnsuspendUser():
  suspendUnsuspendUsers(getStringReturnInList(Cmd.OB_USER_ITEM))

# gam <UserTypeEntity> signout
# gam <UserTypeEntity> turnoff2sv
def signoutTurnoff2SVUsers(entityList):
  cd = buildGAPIObject(API.DIRECTORY)
  if Act.Get() == Act.SIGNOUT:
    service = cd.users()
    function = 'signOut'
  else:
    service = cd.twoStepVerification()
    function = 'turnOff'
  checkForExtraneousArguments()
  i, count, entityList = getEntityArgument(entityList)
  for user in entityList:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    try:
      callGAPI(service, function,
               throwReasons=[GAPI.NOT_FOUND, GAPI.USER_NOT_FOUND, GAPI.INVALID, GAPI.INVALID_INPUT,
                             GAPI.DOMAIN_NOT_FOUND, GAPI.DOMAIN_CANNOT_USE_APIS,
                             GAPI.FORBIDDEN, GAPI.AUTH_ERROR],
               userKey=user)
      entityActionPerformed([Ent.USER, user], i, count)
    except (GAPI.notFound, GAPI.userNotFound):
      entityUnknownWarning(Ent.USER, user, i, count)
    except (GAPI.invalid, GAPI.invalidInput, GAPI.domainNotFound, GAPI.domainCannotUseApis,
            GAPI.forbidden, GAPI.authError) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)

# gam <UserTypeEntity> waitformailbox [retries <Number>]
def waitForMailbox(entityList):
  def showRetryStatus(user, exists, i, count):
    kvList = ['Exists', exists, 'mailboxIsSetup', False]
    if maxRetries:
      kvList.extend(['Retry', f'{retries}/{maxRetries}'])
    else:
      kvList.extend(['Retry', f'{retries}'])
    printEntityKVList([Ent.USER, user], kvList, i, count)
    if maxRetries and retries >= maxRetries:
      entityActionFailedWarning([Ent.USER, user], Msg.RETRIES_EXHAUSTED.format(maxRetries), i, count)
      return False
    time.sleep(3)
    return True

  cd = buildGAPIObject(API.DIRECTORY)
  maxRetries = 0
  while Cmd.ArgumentsRemaining():
    argument = getArgument()
    if argument == 'retries':
      maxRetries = getInteger(minVal=0)
    else:
      unknownArgumentExit()
  i, count, entityList = getEntityArgument(entityList)
  for user in entityList:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    retries = 0
    performAction(Ent.USER, user, i, count)
    Ind.Increment()
    while True:
      retries += 1
      try:
        result = callGAPI(cd.users(), 'get',
                          throwReasons=GAPI.USER_GET_THROW_REASONS+[GAPI.INVALID_INPUT, GAPI.RESOURCE_NOT_FOUND],
                          userKey=user, fields='isMailboxSetup')
        if result.get('isMailboxSetup', False):
          entityActionPerformed([Ent.USER, user], i, count)
          break
        if not showRetryStatus(user, True, i, count):
          break
      except GAPI.userNotFound:
        if not showRetryStatus(user, False, i, count):
          break
      except (GAPI.invalid, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.authError) as e:
        entityActionFailedWarning([Ent.USER, user], str(e), i, count)
        break
      except KeyboardInterrupt:
        entityActionFailedWarning([Ent.USER, user], Msg.CHECK_INTERRUPTED, i, count)
        break
    Ind.Decrement()

def getUserLicenses(lic, user, skus):
  def _callbackGetLicense(_, response, exception):
    if exception is None:
      if response and 'skuId' in response:
        licenses.append(response['skuId'])

  licenses = []
  svcargs = dict([('userId', user['primaryEmail']), ('productId', None), ('skuId', None), ('fields', 'skuId')]+GM.Globals[GM.EXTRA_ARGS_LIST])
  method = getattr(lic.licenseAssignments(), 'get')
  dbatch = lic.new_batch_http_request(callback=_callbackGetLicense)
  for sku in skus:
    svcparms = svcargs.copy()
    svcparms['productId'] = sku[0]
    svcparms['skuId'] = sku[1]
    dbatch.add(method(**svcparms))
  dbatch.execute()
  return licenses

USER_NAME_PROPERTY_PRINT_ORDER = [
  'givenName',
  'familyName',
  'fullName',
  'displayName',
  ]
USER_LANGUAGE_PROPERTY_PRINT_ORDER = [
  'languages',
  ]
USER_SCALAR_PROPERTY_PRINT_ORDER = [
  'isAdmin',
  'isDelegatedAdmin',
  'isEnrolledIn2Sv',
  'isEnforcedIn2Sv',
  'agreedToTerms',
  'ipWhitelisted',
  'suspended',
  'suspensionReason',
  'archived',
  'changePasswordAtNextLogin',
  'id',
  'customerId',
  'isMailboxSetup',
  'includeInGlobalAddressList',
  'creationTime',
  'lastLoginTime',
  'deletionTime',
  'orgUnitPath',
  'recoveryEmail',
  'recoveryPhone',
  'thumbnailPhotoUrl',
  ]
USER_ARRAY_PROPERTY_PRINT_ORDER = [
  'gender',
  'keywords',
  'notes',
  'addresses',
  'locations',
  'organizations',
  'relations',
  'emails',
  'ims',
  'phones',
  'posixAccounts',
  'sshPublicKeys',
  'externalIds',
  'websites',
  ]

USER_ADDRESSES_PROPERTY_PRINT_ORDER = [
  'primary',
  'sourceIsStructured',
  'formatted',
  'extendedAddress',
  'streetAddress',
  'poBox',
  'locality',
  'region',
  'postalCode',
  'country',
  'countryCode',
  ]

USER_LOCATIONS_PROPERTY_PRINT_ORDER = [
  'area',
  'buildingId',
  'buildingName',
  'floorName',
  'floorSection',
  'deskCode',
  ]

USER_ORGANIZATIONS_PROPERTY_PRINT_ORDER = [
  'name',
  'description',
  'domain',
  'symbol',
  'location',
  'costCenter',
  'department',
  'title',
  'fullTimeEquivalent',
  'primary',
  ]

USER_POSIX_PROPERTY_PRINT_ORDER = [
  'accountId',
  'uid',
  'gid',
  'systemId',
  'operatingSystemType',
  'homeDirectory',
  'shell',
  'gecos',
  'primary',
  ]

USER_SSH_PROPERTY_PRINT_ORDER = [
  'expirationTimeUsec',
  'fingerprint',
  ]

USER_FIELDS_CHOICE_MAP = {
  'address': 'addresses',
  'addresses': 'addresses',
  'admin': ['isAdmin', 'isDelegatedAdmin'],
  'agreed2terms': 'agreedToTerms',
  'agreedtoterms': 'agreedToTerms',
  'aliases': ['aliases', 'nonEditableAliases'],
  'archived': 'archived',
  'changepassword': 'changePasswordAtNextLogin',
  'changepasswordatnextlogin': 'changePasswordAtNextLogin',
  'creationtime': 'creationTime',
  'customerid': 'customerId',
  'deletiontime': 'deletionTime',
  'displayname': 'name.displayName',
  'email': 'emails',
  'emails': 'emails',
  'employeeid': 'externalIds',
  'externalid': 'externalIds',
  'externalids': 'externalIds',
  'familyname': 'name.familyName',
  'firstname': 'name.givenName',
  'fullname': 'name.fullName',
  'gal': 'includeInGlobalAddressList',
  'givenname': 'name.givenName',
  'gender': ['gender.type', 'gender.customGender', 'gender.addressMeAs'],
  'id': 'id',
  'im': 'ims',
  'ims': 'ims',
  'includeinglobaladdresslist': 'includeInGlobalAddressList',
  'ipwhitelisted': 'ipWhitelisted',
  'isadmin': ['isAdmin', 'isDelegatedAdmin'],
  'isdelegatedadmin': ['isAdmin', 'isDelegatedAdmin'],
  'isenforcedin2sv': 'isEnforcedIn2Sv',
  'isenrolledin2sv': 'isEnrolledIn2Sv',
  'is2svenforced': 'isEnforcedIn2Sv',
  'is2svenrolled': 'isEnrolledIn2Sv',
  'ismailboxsetup': 'isMailboxSetup',
  'keyword': 'keywords',
  'keywords': 'keywords',
  'language': 'languages',
  'languages': 'languages',
  'lastlogintime': 'lastLoginTime',
  'lastname': 'name.familyName',
  'location': 'locations',
  'locations': 'locations',
  'manager': 'relations',
  'name': ['name.givenName', 'name.familyName', 'name.fullName', 'name.displayName'],
  'nicknames': ['aliases', 'nonEditableAliases'],
  'noneditablealiases': ['aliases', 'nonEditableAliases'],
  'note': 'notes',
  'notes': 'notes',
  'org': 'orgUnitPath',
  'organization': 'organizations',
  'organizations': 'organizations',
  'organisation': 'organizations',
  'organisations': 'organizations',
  'orgunit': 'orgUnitPath',
  'orgunitpath': 'orgUnitPath',
  'otheremail': 'emails',
  'otheremails': 'emails',
  'ou': 'orgUnitPath',
  'phone': 'phones',
  'phones': 'phones',
  'photo': 'thumbnailPhotoUrl',
  'photourl': 'thumbnailPhotoUrl',
  'posix': 'posixAccounts',
  'posixaccounts': 'posixAccounts',
  'primaryemail': 'primaryEmail',
  'recoveryemail': 'recoveryEmail',
  'recoveryphone': 'recoveryPhone',
  'relation': 'relations',
  'relations': 'relations',
  'ssh': 'sshPublicKeys',
  'sshkeys': 'sshPublicKeys',
  'sshpublickeys': 'sshPublicKeys',
  'suspended': ['suspended', 'suspensionReason'],
  'thumbnailphotourl': 'thumbnailPhotoUrl',
  'username': 'primaryEmail',
  'website': 'websites',
  'websites': 'websites',
  }

INFO_USER_OPTIONS = {'noaliases', 'nobuildingnames', 'nogroups', 'nolicenses', 'nolicences', 'noschemas', 'schemas', 'userview'}
USER_SKIP_OBJECTS = {'thumbnailPhotoEtag'}
USER_TIME_OBJECTS = {'creationTime', 'deletionTime', 'lastLoginTime'}

def _formatLanguagesList(propertyValue, delimiter):
  languages = []
  for language in propertyValue:
    if 'languageCode' in language:
      lang = language['languageCode']
      if language.get('preference') == 'preferred':
        lang += '+'
      elif language.get('preference') == 'not_preferred':
        lang += '-'
    else:
      lang = language.get('customLanguage')
    if lang:
      languages.append(lang)
  return delimiter.join(languages)

def _initSchemaParms(projection):
  return {'projection': projection, 'customFieldMask': None, 'selectedSchemaFields': {}}

def _getSchemaNameList(schemaParms):
  customFieldMask = getString(Cmd.OB_SCHEMA_NAME_LIST).replace(' ', ',')
  if customFieldMask.lower() == 'all':
    schemaParms['projection'] = 'full'
    schemaParms['customFieldMask'] = None
    schemaParms['selectedSchemaFields'] = {}
  else:
    schemaParms['projection'] = 'custom'
    customFieldMaskList = []
    for schemaField in customFieldMask.split(','):
      if schemaField.find('.') == -1:
        customFieldMaskList.append(schemaField)
      else:
        schemaName, fieldName = schemaField.split('.', 1)
        customFieldMaskList.append(schemaName)
        schemaParms['selectedSchemaFields'] .setdefault(schemaName, set())
        schemaParms['selectedSchemaFields'][schemaName].add(fieldName)
    schemaParms['customFieldMask'] = ','.join(customFieldMaskList)

def _filterSchemaFields(userEntity, schemaParms):
  schemas = userEntity.pop('customSchemas', None)
  if schemas is None:
    return
  customSchemas = {}
  for schema in sorted(schemas):
    if schema in schemaParms['selectedSchemaFields']:
      for field, value in sorted(iter(schemas[schema].items())):
        if field not in schemaParms['selectedSchemaFields'][schema]:
          continue
        customSchemas.setdefault(schema, {})
        customSchemas[schema][field] = value
    else:
      customSchemas[schema] = schemas[schema]
  userEntity['customSchemas'] = customSchemas

def infoUsers(entityList):
  def printUserCIGroupMap(parent, group_name_mappings, seen_group_count, edges, direction):
    for a_parent, a_child in edges:
      if a_parent == parent:
        output = f'{Ind.Spaces()}{a_child}: {group_name_mappings[a_child]} ({direction})'
        if seen_group_count[a_child] > 1:
          output += ' *'
        printLine(output)
        Ind.Increment()
        printUserCIGroupMap(a_child, group_name_mappings, seen_group_count, edges, 'inherited')
        Ind.Decrement()

  def _showType(row, typeKey, typeCustomValue, customTypeKey, defaultType=None):
    if typeKey in row:
      if row[typeKey] != typeCustomValue or not row.get(customTypeKey):
        printKeyValueList([typeKey, row[typeKey]])
      else:
        printKeyValueList([typeKey, row[typeKey]])
        Ind.Increment()
        printKeyValueList([customTypeKey, row[customTypeKey]])
        Ind.Decrement()
      return True
    if customTypeKey in row:
      printKeyValueList([customTypeKey, row[customTypeKey]])
      return True
    if defaultType:
      printKeyValueList([typeKey, defaultType])
      return True
    return False

  cd = buildGAPIObject(API.DIRECTORY)
  ci = None
  getAliases = getBuildingNames = getCIGroupsTree = getGroups = getLicenses = getSchemas = not GC.Values[GC.QUICK_INFO_USER]
  getGroupsTree = False
  FJQC = FormatJSONQuoteChar()
  schemaParms = _initSchemaParms('full')
  viewType = 'admin_view'
  fieldsList = []
  groups = []
  memberships = []
  skus = SKU.getAllSKUs() if not GM.Globals[GM.LICENSE_SKUS] else GM.Globals[GM.LICENSE_SKUS]
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'quick':
      getAliases = getBuildingNames = getCIGroupsTree = getGroups = getGroupsTree = getLicenses = getSchemas = False
    elif myarg in {'noaliases', 'aliases'}:
      getAliases = myarg == 'aliases'
    elif myarg in {'nobuildingnames', 'buildingnames'}:
      getBuildingNames = myarg == 'buildingnames'
    elif myarg in {'nogroups', 'groups', 'grouptree', 'cigrouptree'}:
      getGroups = getGroupsTree = getCIGroupsTree = False
      getGroups = myarg == 'groups'
      getGroupsTree = myarg == 'grouptree'
      getCIGroupsTree = myarg == 'cigrouptree'
    elif myarg in {'nolicenses', 'nolicences', 'licenses', 'licences'}:
      getLicenses = myarg in {'licenses', 'licences'}
    elif myarg == 'noschemas':
      getSchemas = False
      schemaParms = _initSchemaParms('basic')
    elif myarg in {'allschemas', 'custom', 'schemas', 'customschemas'}:
      if myarg == 'allschemas':
        schemaParms = _initSchemaParms('full')
      else:
        _getSchemaNameList(schemaParms)
      getSchemas = True
    elif myarg in {'products', 'product'}:
      skus = SKU.convertProductListToSKUList(getGoogleProductList())
    elif myarg in {'sku', 'skus'}:
      skus = getGoogleSKUList()
    elif myarg == 'userview':
      viewType = 'domain_public'
      getGroups = getLicenses = False
    elif getFieldsList(myarg, USER_FIELDS_CHOICE_MAP, fieldsList):
      pass
# Ignore info group arguments that may have come from whatis
    elif myarg in INFO_GROUP_OPTIONS:
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  if fieldsList:
    fieldsList.append('primaryEmail')
    if getSchemas:
      fieldsList.append('customSchemas')
    if getAliases:
      fieldsList.extend(['aliases', 'nonEditableAliases'])
  fields = getFieldsFromFieldsList(fieldsList)
  if getLicenses:
    lic = buildGAPIObject(API.LICENSING)
# Special case; for info users, 'all users' means 'all users_ns_susp'
  if isinstance(entityList, dict) and entityList.get('entityType') == Cmd.ENTITY_ALL_USERS:
    entityList['entityType'] = Cmd.ENTITY_ALL_USERS_NS_SUSP
  groupParents = {}
  i, count, entityList = getEntityArgument(entityList)
  for userEmail in entityList:
    i += 1
    userEmail = normalizeEmailAddressOrUID(userEmail)
    try:
      user = callGAPI(cd.users(), 'get',
                      throwReasons=GAPI.USER_GET_THROW_REASONS+[GAPI.INVALID_INPUT, GAPI.RESOURCE_NOT_FOUND],
                      userKey=userEmail, projection=schemaParms['projection'], customFieldMask=schemaParms['customFieldMask'],
                      viewType=viewType, fields=fields)
      groups = []
      memberships = []
      if getGroups or getGroupsTree:
        kwargs = {}
        if GC.Values[GC.ENABLE_DASA]:
          # Allows groups.list() to function but will limit
          # returned groups to those in same domain as user
          # so only do this for DASA admins
          kwargs['domain'] = GC.Values[GC.DOMAIN]
        try:
          groups = callGAPIpages(cd.groups(), 'list', 'groups',
                                 throwReasons=GAPI.GROUP_LIST_USERKEY_THROW_REASONS,
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 userKey=user['primaryEmail'], orderBy='email', fields='nextPageToken,groups(name,email)', **kwargs)
        except (GAPI.forbidden, GAPI.domainNotFound):
### Print some message
          pass
      elif getCIGroupsTree:
        ci, memberships = getCIGroupMembershipGraph(ci, user['primaryEmail'])
        if memberships is None:
          getCIGroupsTree = False
      licenses = getUserLicenses(lic, user, skus) if getLicenses else []
      if FJQC.formatJSON:
        if getGroups or getGroupsTree:
          user['groups'] = groups
          if getGroupsTree:
            for group in user['groups']:
              groupEmail = group['email']
              if groupEmail not in groupParents:
                getGroupParents(cd, groupParents, groupEmail, group['name'], {})
              addJsonGroupParents(groupParents, group, groupEmail)
        if getLicenses:
          user['licenses'] = [SKU.formatSKUIdDisplayName(u_license) for u_license in licenses]
        if getBuildingNames:
          for location in user.get('locations', []):
            location['buildingName'] = _getBuildingNameById(cd, location.get('buildingId', ''))
        if not getAliases:
          user.pop('aliases', None)
          user.pop('nonEditableAliases', None)
        if not getSchemas:
          user.pop('customSchemas', None)
        printLine(json.dumps(cleanJSON(user, skipObjects=USER_SKIP_OBJECTS, timeObjects=USER_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
        continue
      printEntity([Ent.USER, user['primaryEmail']], i, count)
      Ind.Increment()
      printKeyValueList(['Settings', None])
      Ind.Increment()
      if 'name' in user:
        for up in USER_NAME_PROPERTY_PRINT_ORDER:
          if up in user['name']:
            printKeyValueList([UProp.PROPERTIES[up][UProp.TITLE], user['name'][up]])
      up = 'languages'
      if up in user:
        printKeyValueList([UProp.PROPERTIES[up][UProp.TITLE], _formatLanguagesList(user[up], ',')])
      for up in USER_SCALAR_PROPERTY_PRINT_ORDER:
        if up in user:
          if up not in USER_TIME_OBJECTS:
            printKeyValueList([UProp.PROPERTIES[up][UProp.TITLE], user[up]])
          else:
            printKeyValueList([UProp.PROPERTIES[up][UProp.TITLE], formatLocalTime(user[up])])
      Ind.Decrement()
      for up in USER_ARRAY_PROPERTY_PRINT_ORDER:
        if up not in user:
          continue
        propertyValue = user[up]
        userProperty = UProp.PROPERTIES[up]
        propertyClass = userProperty[UProp.CLASS]
        propertyTitle = userProperty[UProp.TITLE]
        if UProp.TYPE_KEYWORDS in userProperty:
          typeKey = userProperty[UProp.TYPE_KEYWORDS][UProp.PTKW_ATTR_TYPE_KEYWORD]
          typeCustomValue = userProperty[UProp.TYPE_KEYWORDS][UProp.PTKW_ATTR_TYPE_CUSTOM_VALUE]
          customTypeKey = userProperty[UProp.TYPE_KEYWORDS][UProp.PTKW_ATTR_CUSTOMTYPE_KEYWORD]
        if propertyClass == UProp.PC_ARRAY:
          if propertyValue:
            printKeyValueList([propertyTitle, None])
            Ind.Increment()
            for row in propertyValue:
              _showType(row, typeKey, typeCustomValue, customTypeKey)
              Ind.Increment()
              for key in row:
                if key in [typeKey, customTypeKey]:
                  continue
                printKeyValueList([key, row[key]])
              Ind.Decrement()
            Ind.Decrement()
        elif propertyClass == UProp.PC_GENDER:
          if propertyValue:
            printKeyValueList([propertyTitle, None])
            Ind.Increment()
            _showType(propertyValue, typeKey, typeCustomValue, customTypeKey)
            if 'addressMeAs' in propertyValue:
              printKeyValueList(['addressMeAs', propertyValue['addressMeAs']])
            Ind.Decrement()
        elif propertyClass == UProp.PC_ADDRESSES:
          if propertyValue:
            printKeyValueList([propertyTitle, None])
            Ind.Increment()
            for row in propertyValue:
              _showType(row, typeKey, typeCustomValue, customTypeKey)
              Ind.Increment()
              for key in USER_ADDRESSES_PROPERTY_PRINT_ORDER:
                if key in row:
                  if key != 'formatted':
                    printKeyValueList([key, row[key]])
                  else:
                    printKeyValueWithCRsNLs(key, row[key])
              Ind.Decrement()
            Ind.Decrement()
        elif propertyClass == UProp.PC_EMAILS:
          if propertyValue:
            needTitle = True
            for row in propertyValue:
              if row['address'].lower() == user['primaryEmail'].lower():
                continue
              if needTitle:
                needTitle = False
                printKeyValueList([propertyTitle, None])
                Ind.Increment()
              if not _showType(row, typeKey, typeCustomValue, customTypeKey):
                if not getAliases:
                  continue
                printKeyValueList([typeKey, 'alias'])
              Ind.Increment()
              for key in row:
                if key in [typeKey, customTypeKey]:
                  continue
                printKeyValueList([key, row[key]])
              Ind.Decrement()
            if not needTitle:
              Ind.Decrement()
        elif propertyClass == UProp.PC_IMS:
          if propertyValue:
            printKeyValueList([propertyTitle, None])
            Ind.Increment()
            protocolKey = UProp.IM_PROTOCOLS[UProp.PTKW_ATTR_TYPE_KEYWORD]
            protocolCustomValue = UProp.IM_PROTOCOLS[UProp.PTKW_ATTR_TYPE_CUSTOM_VALUE]
            customProtocolKey = UProp.IM_PROTOCOLS[UProp.PTKW_ATTR_CUSTOMTYPE_KEYWORD]
            for row in propertyValue:
              _showType(row, typeKey, typeCustomValue, customTypeKey)
              Ind.Increment()
              _showType(row, protocolKey, protocolCustomValue, customProtocolKey)
              for key in row:
                if key in [typeKey, customTypeKey, protocolKey, customProtocolKey]:
                  continue
                printKeyValueList([key, row[key]])
              Ind.Decrement()
            Ind.Decrement()
        elif propertyClass == UProp.PC_NOTES:
          if propertyValue:
            printKeyValueList([propertyTitle, None])
            Ind.Increment()
            if isinstance(propertyValue, dict):
              typeVal = propertyValue.get(typeKey, 'text_plain')
              printKeyValueList([typeKey, typeVal])
              Ind.Increment()
              if typeVal == 'text_html':
                printKeyValueWithCRsNLs('value', dehtml(propertyValue['value']))
              else:
                printKeyValueWithCRsNLs('value', propertyValue['value'])
              Ind.Decrement()
            else:
              printKeyValueList([Ind.MultiLineText(propertyValue)])
            Ind.Decrement()
        elif propertyClass == UProp.PC_LOCATIONS:
          if propertyValue:
            printKeyValueList([propertyTitle, None])
            Ind.Increment()
            if isinstance(propertyValue, list):
              for row in propertyValue:
                _showType(row, typeKey, typeCustomValue, customTypeKey)
                Ind.Increment()
                if getBuildingNames:
                  row['buildingName'] = _getBuildingNameById(cd, row.get('buildingId', ''))
                for key in USER_LOCATIONS_PROPERTY_PRINT_ORDER:
                  if key in row:
                    printKeyValueList([key, row[key]])
                Ind.Decrement()
            else:
              printKeyValueList([Ind.MultiLineText(propertyValue)])
            Ind.Decrement()
        elif propertyClass == UProp.PC_ORGANIZATIONS:
          if propertyValue:
            printKeyValueList([propertyTitle, None])
            Ind.Increment()
            for row in propertyValue:
              _showType(row, typeKey, typeCustomValue, customTypeKey)
              Ind.Increment()
              for key in USER_ORGANIZATIONS_PROPERTY_PRINT_ORDER:
                if key in row:
                  printKeyValueList([key, row[key]])
              Ind.Decrement()
            Ind.Decrement()
        elif propertyClass == UProp.PC_POSIX:
          if propertyValue:
            printKeyValueList([propertyTitle, None])
            Ind.Increment()
            if isinstance(propertyValue, list):
              for row in propertyValue:
                printKeyValueList(['username', row.get('username')])
                Ind.Increment()
                for key in USER_POSIX_PROPERTY_PRINT_ORDER:
                  if key in row:
                    printKeyValueList([key, row[key]])
                Ind.Decrement()
            else:
              printKeyValueList([Ind.MultiLineText(propertyValue)])
            Ind.Decrement()
        elif propertyClass == UProp.PC_SSH:
          if propertyValue:
            printKeyValueList([propertyTitle, None])
            Ind.Increment()
            if isinstance(propertyValue, list):
              for row in propertyValue:
                printKeyValueList(['key', row['key']])
                Ind.Increment()
                for key in USER_SSH_PROPERTY_PRINT_ORDER:
                  if key in row:
                    printKeyValueList([key, row[key]])
                Ind.Decrement()
            else:
              printKeyValueList([Ind.MultiLineText(propertyValue)])
            Ind.Decrement()
      if getSchemas:
        up = 'customSchemas'
        if up in user:
          propertyValue = user[up]
          userProperty = UProp.PROPERTIES[up]
          propertyTitle = userProperty[UProp.TITLE]
          typeKey = userProperty[UProp.TYPE_KEYWORDS][UProp.PTKW_ATTR_TYPE_KEYWORD]
          typeCustomValue = userProperty[UProp.TYPE_KEYWORDS][UProp.PTKW_ATTR_TYPE_CUSTOM_VALUE]
          customTypeKey = userProperty[UProp.TYPE_KEYWORDS][UProp.PTKW_ATTR_CUSTOMTYPE_KEYWORD]
          if schemaParms['selectedSchemaFields']:
            _filterSchemaFields(user, schemaParms)
          propertyValue = user[up]
          if propertyValue:
            printKeyValueList([UProp.PROPERTIES[up][UProp.TITLE], None])
            Ind.Increment()
            for schema in sorted(propertyValue):
              printKeyValueList(['Schema', schema])
              Ind.Increment()
              for field in propertyValue[schema]:
                if isinstance(propertyValue[schema][field], list):
                  printKeyValueList([field])
                  Ind.Increment()
                  for an_item in propertyValue[schema][field]:
                    _showType(an_item, typeKey, typeCustomValue, customTypeKey, defaultType='work')
                    Ind.Increment()
                    printKeyValueList(['value', an_item['value']])
                    Ind.Decrement()
                  Ind.Decrement()
                else:
                  printKeyValueList([field, propertyValue[schema][field]])
              Ind.Decrement()
            Ind.Decrement()
      if getAliases:
        for up in ['aliases', 'nonEditableAliases']:
          propertyValue = user.get(up, [])
          if propertyValue:
            printEntitiesCount([Ent.NONEDITABLE_ALIAS, Ent.EMAIL_ALIAS][up == 'aliases'], propertyValue)
            Ind.Increment()
            for alias in propertyValue:
              printKeyValueList(['alias', alias])
            Ind.Decrement()
      if getGroups:
        printEntitiesCount(Ent.GROUP, groups)
        Ind.Increment()
        for group in groups:
          printKeyValueList([group['name'], group['email']])
        Ind.Decrement()
      elif getGroupsTree:
        printEntity([Ent.GROUP_MEMBERSHIP_TREE, ''])
        Ind.Increment()
        for group in groups:
          groupEmail = group['email']
          if groupEmail not in groupParents:
            getGroupParents(cd, groupParents, groupEmail, group['name'], {})
          showGroupParents(groupParents, groupEmail, None, 0, 0)
        Ind.Decrement()
      elif getCIGroupsTree:
        printEntity([Ent.GROUP_MEMBERSHIP_TREE, ''])
        if memberships:
          Ind.Increment()
          group_name_mapping = {}
          group_displayname_mapping = {}
          groups = memberships.get('groups', [])
          for group in groups:
            group_name = group.get('name')
            group_key = group.get('groupKey', {})
            group_email = group_key.get('id', '')
            group_display_name = group.get('displayName', '')
            group_name_mapping[group_name] = group_email
            group_displayname_mapping[group_email] = group_display_name
          edges = []
          seen_group_count = {}
          for adj in memberships.get('adjacencyList', []):
            group_name = adj.get('group', '')
            group_email = group_name_mapping[group_name]
            for edge in adj.get('edges', []):
              seen_group_count[group_email] = seen_group_count.get(group_email, 0) + 1
              member_email = edge.get('preferredMemberKey', {}).get('id')
              edges.append((member_email, group_email))
          printUserCIGroupMap(user['primaryEmail'], group_displayname_mapping, seen_group_count, edges, 'direct')
          if max(seen_group_count.values()) > 1:
            printLine(f'{Ind.Spaces()}* {Msg.USER_HAS_MULTIPLE_DIRECT_OR_INHERITED_MEMBERSHIPS_IN_GROUP}')
          Ind.Decrement()
      if licenses:
        printEntitiesCount(Ent.LICENSE, licenses)
        Ind.Increment()
        for u_license in licenses:
          printKeyValueList([SKU.formatSKUIdDisplayName(u_license)])
        Ind.Decrement()
      Ind.Decrement()
    except GAPI.userNotFound:
      entityUnknownWarning(Ent.USER, userEmail, i, count)
    except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
            GAPI.badRequest, GAPI.backendError, GAPI.systemError) as e:
      entityActionFailedWarning([Ent.USER, userEmail], str(e), i, count)
    except (GAPI.invalidInput, GAPI.invalidMember) as e:
      if schemaParms['customFieldMask']:
        entityActionFailedWarning([Ent.USER, userEmail], invalidUserSchema(schemaParms['customFieldMask']), i, count)
      else:
        entityActionFailedWarning([Ent.USER, userEmail], str(e), i, count)

# gam info users <UserTypeEntity>
#	[quick]
#	[noaliases|aliases]
#	[nobuildingnames|buildingnames]
#	[nogroups|groups|grouptree|cigrouptree]
#	[nolicenses|nolicences|licenses|licences]
#	[(products|product <ProductIDList>)|(skus|sku <SKUIDList>)]
#	[noschemas|allschemas|(schemas|custom|customschemas <SchemaNameList>)]
#	[userview] <UserFieldName>* [fields <UserFieldNameList>]
#	[formatjson]
def doInfoUsers():
  infoUsers(getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS, delayGet=True)[1])

# gam info user <UserItem>
#	[quick]
#	[noaliases|aliases]
#	[nobuildingnames|buildingnames]
#	[nogroups|groups|grouptree|cigrouptree]
#	[nolicenses|nolicences|licenses|licences]
#	[(products|product <ProductIDList>)|(skus|sku <SKUIDList>)]
#	[noschemas|allschemas|(schemas|custom|customschemas <SchemaNameList>)]
#	[userview] <UserFieldName>* [fields <UserFieldNameList>]
#	[formatjson]
# gam info user
def doInfoUser():
  if Cmd.ArgumentsRemaining():
    infoUsers(getStringReturnInList(Cmd.OB_USER_ITEM))
  else:
    infoUsers([_getAdminEmail()])

USERS_ORDERBY_CHOICE_MAP = {
  'familyname': 'familyName',
  'lastname': 'familyName',
  'givenname': 'givenName',
  'firstname': 'givenName',
  'email': 'email',
  }
USERS_INDEXED_TITLES = ['addresses', 'aliases', 'nonEditableAliases', 'emails', 'externalIds',
                        'ims', 'keywords', 'locations', 'organizations',
                        'phones', 'posixAccounts', 'relations', 'sshPublicKeys', 'websites']

# gam print users [todrive <ToDriveAttribute>*]
#	([domain|domains <DomainNameEntity>] [(query <QueryUser>)|(queries <QueryUserList>)]
#	 [limittoou <OrgUnitItem>] [deleted_only|only_deleted])|[select <UserTypeEntity>]
#	[groups|groupsincolumns]
#	[license|licenses|licence|licences|licensebyuser|licensesbyuser|licencebyuser|licencesbyuser]
#	[(products|product <ProductIDList>)|(skus|sku <SKUIDList>)]
#	[emailpart|emailparts|username] [schemas|custom all|<SchemaNameList>]
#	[orderby <UserOrderByFieldName> [ascending|descending]]
#	[userview] [basic|full|allfields | <UserFieldName>* | fields <UserFieldNameList>]
#	[delimiter <Character>] [sortheaders] [formatjson [quotechar <Character>]] [quoteplusphonenumbers]
#	[convertcrnl]
#	[issuspended <Boolean>] [aliasmatchpattern <REMatchPattern>]
# 	[showitemcountonly]
#	[showvalidcolumn] (addcsvdata <FieldName> <String>)*
#
# gam <UserTypeEntity> print users [todrive <ToDriveAttribute>*]
#	[groups|groupsincolumns]
#	[license|licenses|licence|licences|licensebyuser|licensesbyuser|licencebyuser|licencesbyuser]
#	[(products|product <ProductIDList>)|(skus|sku <SKUIDList>)]
#	[emailpart|emailparts|username] [schemas|custom all|<SchemaNameList>]
#	[orderby <UserOrderByFieldName> [ascending|descending]]
#	[userview] [basic|full|allfields | <UserFieldName>* | fields <UserFieldNameList>]
#	[delimiter <Character>] [sortheaders] [formatjson [quotechar <Character>]] [quoteplusphonenumbers]
#	[convertcrnl]
#	[issuspended <Boolean>] [aliasmatchpattern <REMatchPattern>]
# 	[showitemcountonly]
#	[showvalidcolumn] (addcsvdata <FieldName> <String>)*
#
# gam print users [todrive <ToDriveAttribute>*]
#	([domain <DomainName>] [(query <QueryUser>)|(queries <QueryUserList>)]
#	 [limittoou <OrgUnitItem>] [deleted_only|only_deleted])|[select <UserTypeEntity>]
#	[formatjson [quotechar <Character>]] [countonly]
#	[issuspended <Boolean>] [aliasmatchpattern <REMatchPattern>]
# 	[showitemcountonly]
#	[showvalidcolumn] (addcsvdata <FieldName> <String>)*
#
# gam <UserTypeEntity> print users [todrive <ToDriveAttribute>*]
#	[formatjson [quotechar <Character>]] [countonly]
#	[issuspended <Boolean>]
# 	[showitemcountonly]
def doPrintUsers(entityList=None):
  def _writeUserEntity(userEntity):
    if addCSVData:
      userEntity.update(addCSVData)
    row = flattenJSON(userEntity, skipObjects=USER_SKIP_OBJECTS, timeObjects=USER_TIME_OBJECTS)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      row = {'primaryEmail': userEntity['primaryEmail']}
      if showValidColumn:
        row[showValidColumn] = userEntity[showValidColumn]
      row['JSON'] = json.dumps(cleanJSON(userEntity, skipObjects=USER_SKIP_OBJECTS, timeObjects=USER_TIME_OBJECTS),
                               ensure_ascii=False, sort_keys=True)
      csvPF.WriteRowNoFilter(row)

  def _printUser(userEntity, i, count):
    if isSuspended is None or isSuspended == userEntity.get('suspended', isSuspended):
      if showValidColumn:
        userEntity[showValidColumn] = True
      userEmail = userEntity['primaryEmail']
      if printOptions['emailParts']:
        if userEmail.find('@') != -1:
          userEntity['primaryEmailLocal'], userEntity['primaryEmailDomain'] = splitEmailAddress(userEmail)
      if 'languages' in userEntity and not FJQC.formatJSON:
        userEntity['languages'] = _formatLanguagesList(userEntity.pop('languages'), delimiter)
      for location in userEntity.get('locations', []):
        location['buildingName'] = _getBuildingNameById(cd, location.get('buildingId', ''))
      if quotePlusPhoneNumbers:
        for phone in userEntity.get('phones', []):
          phoneNumber = phone.get('value', '')
          if phoneNumber.startswith('+'):
            phone['value'] = "'"+phoneNumber
      if schemaParms['selectedSchemaFields']:
        _filterSchemaFields(userEntity, schemaParms)
      if printOptions['getGroupFeed']:
        printGettingAllEntityItemsForWhom(Ent.GROUP_MEMBERSHIP, userEmail, i, count)
        try:
          groups = callGAPIpages(cd.groups(), 'list', 'groups',
                                 pageMessage=getPageMessageForWhom(),
                                 throwReasons=GAPI.GROUP_LIST_USERKEY_THROW_REASONS,
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 userKey=userEmail, orderBy='email', fields='nextPageToken,groups(email)')
          numGroups = len(groups)
          if not printOptions['groupsInColumns']:
            userEntity['GroupsCount'] = numGroups
            userEntity['Groups'] = delimiter.join([groupname['email'] for groupname in groups])
          else:
            if numGroups > printOptions['maxGroups']:
              printOptions['maxGroups'] = numGroups
            userEntity['Groups'] = numGroups
            for j, group in enumerate(groups):
              userEntity[f'Groups{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{j}'] = group['email']
        except (GAPI.invalidMember, GAPI.invalidInput):
          badRequestWarning(Ent.GROUP, Ent.MEMBER, userEmail)
        except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.forbidden, GAPI.badRequest):
          accessErrorExit(cd)
      if aliasMatchPattern and 'aliases' in userEntity:
        userEntity['aliases'] = [alias for alias in userEntity['aliases'] if aliasMatchPattern.match(alias)]
      if printOptions['getLicenseFeed'] or printOptions['getLicenseFeedByUser']:
        if printOptions['getLicenseFeed']:
          u_licenses = licenses.get(userEmail.lower(), [])
        else:
          u_licenses = getUserLicenses(lic, userEntity, skus)
        if not oneLicensePerRow:
          userEntity['LicensesCount'] = len(u_licenses)
          if u_licenses:
            userEntity['Licenses'] = delimiter.join(u_licenses)
            userEntity['LicensesDisplay'] = delimiter.join([SKU.skuIdToDisplayName(skuId) for skuId in u_licenses])
      else:
        u_licenses = []
      if not oneLicensePerRow:
        _writeUserEntity(userEntity)
      else:
        if u_licenses:
          for skuId in u_licenses:
            userEntity['License'] = skuId
            userEntity['LicenseDisplay'] = SKU.skuIdToDisplayName(skuId)
            _writeUserEntity(userEntity)
        else:
          userEntity['License'] = userEntity['LicenseDisplay'] = ''
          _writeUserEntity(userEntity)

  def _updateDomainCounts(emailAddress):
    nonlocal domainCounts
    atLoc = emailAddress.find('@')
    if atLoc == -1:
      dom = UNKNOWN
    else:
      dom = emailAddress[atLoc+1:].lower()
    domainCounts.setdefault(dom, 0)
    domainCounts[dom] += 1

  _PRINT_USER_REASON_TO_MESSAGE_MAP = {GAPI.RESOURCE_NOT_FOUND: Msg.DOES_NOT_EXIST}
  def _callbackPrintUser(request_id, response, exception):
    ri = request_id.splitlines()
    if exception is None:
      _printUser(response, int(ri[RI_J]), int(ri[RI_JCOUNT]))
    else:
      http_status, reason, message = checkGAPIError(exception)
      if reason in GAPI.USER_GET_THROW_REASONS:
        if not showValidColumn:
          entityUnknownWarning(Ent.USER, ri[RI_ITEM], int(ri[RI_J]), int(ri[RI_JCOUNT]))
        else:
          _writeUserEntity({'primaryEmail': ri[RI_ITEM], showValidColumn: False})
      elif (reason == GAPI.INVALID_INPUT) and schemaParms['customFieldMask']:
        entityActionFailedWarning([Ent.USER, ri[RI_ITEM]], invalidUserSchema(schemaParms['customFieldMask']), int(ri[RI_J]), int(ri[RI_JCOUNT]))
      elif reason not in GAPI.DEFAULT_RETRY_REASONS:
        errMsg = getHTTPError(_PRINT_USER_REASON_TO_MESSAGE_MAP, http_status, reason, message)
        printKeyValueList([ERROR, errMsg])
      else:
        waitOnFailure(1, 10, reason, message)
        try:
          user = callGAPI(cd.users(), 'get',
                          throwReasons=GAPI.USER_GET_THROW_REASONS+[GAPI.INVALID_INPUT, GAPI.RESOURCE_NOT_FOUND, GAPI.RATE_LIMIT_EXCEEDED],
                          userKey=ri[RI_ITEM], projection=schemaParms['projection'], customFieldMask=schemaParms['customFieldMask'],
                          viewType=viewType, fields=fields)
          _printUser(user, int(ri[RI_J]), int(ri[RI_JCOUNT]))
        except (GAPI.userNotFound, GAPI.resourceNotFound):
          if not showValidColumn:
            entityUnknownWarning(Ent.USER, ri[RI_ITEM], int(ri[RI_J]), int(ri[RI_JCOUNT]))
          else:
            _writeUserEntity({'primaryEmail': ri[RI_ITEM], showValidColumn: False})
        except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
                GAPI.badRequest, GAPI.backendError, GAPI.systemError, GAPI.rateLimitExceeded) as e:
          entityActionFailedWarning([Ent.USER, ri[RI_ITEM]], str(e), int(ri[RI_J]), int(ri[RI_JCOUNT]))
        except GAPI.invalidInput as e:
          if schemaParms['customFieldMask']:
            entityActionFailedWarning([Ent.USER, ri[RI_ITEM]], invalidUserSchema(schemaParms['customFieldMask']), int(ri[RI_J]), int(ri[RI_JCOUNT]))
          else:
            entityActionFailedWarning([Ent.USER, ri[RI_ITEM]], str(e), int(ri[RI_J]), int(ri[RI_JCOUNT]))

  cd = buildGAPIObject(API.DIRECTORY)
  fieldsList = ['primaryEmail']
  csvPF = CSVPrintFile(fieldsList, indexedTitles=USERS_INDEXED_TITLES)
  FJQC = FormatJSONQuoteChar(csvPF)
  printOptions = {
    'countOnly': False,
    'emailParts': False,
    'getGroupFeed': False,
    'getLicenseFeed': False,
    'getLicenseFeedByUser': False,
    'groupsInColumns': False,
    'scalarsFirst': False,
    'sortHeaders': False,
    'maxGroups': 0
    }
  kwargsDict = initUserGroupDomainQueryFilters()
  licenses = {}
  lic = None
  skus = None
  maxResults = GC.Values[GC.USER_MAX_RESULTS]
  schemaParms = _initSchemaParms('basic')
  projectionSet = False
  oneLicensePerRow = quotePlusPhoneNumbers = showDeleted = False
  aliasMatchPattern = isSuspended = orgUnitPath = orgUnitPathLower = orderBy = sortOrder = None
  viewType = 'admin_view'
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  showValidColumn = ''
  showItemCountOnly = False
  addCSVData = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif entityList is None and myarg == 'limittoou':
      orgUnitPath = getOrgUnitItem(pathOnly=True, cd=cd)
      orgUnitPathLower = orgUnitPath.lower()
    elif entityList is None and getUserGroupDomainQueryFilters(myarg, kwargsDict):
      pass
    elif entityList is None and myarg in {'deletedonly', 'onlydeleted'}:
      showDeleted = True
    elif entityList is None and myarg == 'select':
      _, entityList = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)
    elif myarg == 'issuspended':
      isSuspended = getBoolean()
    elif myarg == 'orderby':
      orderBy, sortOrder = getOrderBySortOrder(USERS_ORDERBY_CHOICE_MAP)
    elif myarg == 'userview':
      viewType = 'domain_public'
    elif myarg in {'allfields', 'basic'}:
      schemaParms = _initSchemaParms('basic')
      projectionSet = printOptions['sortHeaders'] = True
      fieldsList = []
    elif myarg == 'full':
      if schemaParms['projection'] != 'custom':
        schemaParms = _initSchemaParms(myarg)
      projectionSet = printOptions['sortHeaders'] = True
      fieldsList = []
    elif myarg in {'custom', 'schemas', 'customschemas'}:
      projectionSet = True
      _getSchemaNameList(schemaParms)
      if fieldsList:
        fieldsList.append('customSchemas')
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    elif myarg == 'sortheaders':
      printOptions['sortHeaders'] = getBoolean()
    elif myarg == 'scalarsfirst':
      printOptions['scalarsFirst'] = getBoolean()
    elif csvPF.GetFieldsListTitles(myarg, USER_FIELDS_CHOICE_MAP, fieldsList, initialField='primaryEmail'):
      pass
    elif myarg == 'groups':
      printOptions['getGroupFeed'] = True
      printOptions['groupsInColumns'] = False
    elif myarg == 'groupsincolumns':
      printOptions['getGroupFeed'] = True
      printOptions['groupsInColumns'] = True
    elif myarg in {'license', 'licenses', 'licence', 'licences'}:
      printOptions['getLicenseFeed'] = True
      printOptions['getLicenseFeedByUser'] = False
    elif myarg in {'licensebyuser', 'licensesbyuser', 'licencebyuser', 'licencesbyuser'}:
      printOptions['getLicenseFeedByUser'] = True
      printOptions['getLicenseFeed'] = False
    elif myarg in {'onelicenseperrow', 'onelicenceperrow'}:
      oneLicensePerRow = True
    elif myarg in {'products', 'product'}:
      skus = SKU.convertProductListToSKUList(getGoogleProductList())
    elif myarg in {'sku', 'skus'}:
      skus = getGoogleSKUList()
    elif myarg == 'aliasmatchpattern':
      aliasMatchPattern = getREPattern(re.IGNORECASE)
    elif myarg in {'emailpart', 'emailparts', 'username'}:
      printOptions['emailParts'] = True
    elif myarg in {'countonly', 'countsonly'}:
      printOptions['countOnly'] = True
    elif myarg == 'maxresults':
      maxResults = getInteger(minVal=1, maxVal=500)
    elif myarg == 'quoteplusphonenumbers':
      quotePlusPhoneNumbers = True
    elif myarg == 'showitemcountonly':
      showItemCountOnly = True
    elif myarg == 'showvalidcolumn':
      showValidColumn = 'Valid'
    elif myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  _, _, entityList = getEntityArgument(entityList)
  if printOptions['countOnly']:
    fieldsList = ['primaryEmail']
    domainCounts = {}
    if not FJQC.formatJSON:
      csvPF.SetTitles(['domain', 'count'])
    else:
      csvPF.SetJSONTitles(['JSON'])
  else:
    if FJQC.formatJSON:
      printOptions['sortHeaders'] = False
      titles = ['primaryEmail']
      if showValidColumn:
        titles.append(showValidColumn)
      titles.append('JSON')
      csvPF.SetJSONTitles(titles)
    else:
      if showValidColumn:
        csvPF.AddTitles([showValidColumn])
      if addCSVData:
        csvPF.AddTitles(sorted(addCSVData.keys()))
      if printOptions['getGroupFeed']:
        if not printOptions['groupsInColumns']:
          csvPF.AddTitles(['GroupsCount', 'Groups'])
        else:
          csvPF.AddTitles(['Groups'])
      if printOptions['getLicenseFeed'] or printOptions['getLicenseFeedByUser']:
        if not oneLicensePerRow:
          licenseTitles = ['LicensesCount', 'Licenses', 'LicensesDisplay']
        else:
          licenseTitles = ['License', 'LicenseDisplay']
        csvPF.AddTitles(licenseTitles)
    if printOptions['getLicenseFeed']:
      if skus is None and GM.Globals[GM.LICENSE_SKUS]:
        skus = GM.Globals[GM.LICENSE_SKUS]
      licenses = doPrintLicenses(returnFields=['userId', 'skuId'], skus=skus)
    elif printOptions['getLicenseFeedByUser']:
      lic = buildGAPIObject(API.LICENSING)
      if skus is None:
        skus = SKU.getAllSKUs() if not GM.Globals[GM.LICENSE_SKUS] else GM.Globals[GM.LICENSE_SKUS]
  if entityList is None:
    sortRows = False
    if orgUnitPath is not None and fieldsList:
      fieldsList.append('orgUnitPath')
    fields = getItemFieldsFromFieldsList('users', fieldsList)
    itemCount = 0
    for kwargsQuery in makeUserGroupDomainQueryFilters(kwargsDict):
      kwargs = kwargsQuery[0]
      query  = kwargsQuery[1]
      query, pquery = userFilters(kwargs, query, orgUnitPath, isSuspended)
      printGettingAllAccountEntities(Ent.USER, pquery)
      pageMessage = getPageMessage(showFirstLastItems=True)
      try:
        feed = yieldGAPIpages(cd.users(), 'list', 'users',
                              pageMessage=pageMessage, messageAttribute='primaryEmail',
                              throwReasons=[GAPI.DOMAIN_NOT_FOUND, GAPI.INVALID_ORGUNIT, GAPI.INVALID_INPUT,
                                            GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN,
                                            GAPI.UNKNOWN_ERROR, GAPI.FAILED_PRECONDITION],
                              retryReasons=[GAPI.UNKNOWN_ERROR, GAPI.FAILED_PRECONDITION],
                              query=query, fields=fields,
                              showDeleted=showDeleted, orderBy=orderBy, sortOrder=sortOrder, viewType=viewType,
                              projection=schemaParms['projection'], customFieldMask=schemaParms['customFieldMask'],
                              maxResults=maxResults, **kwargs)
        for users in feed:
          if orgUnitPath is None:
            if showItemCountOnly:
              itemCount += len(users)
            elif not printOptions['countOnly']:
              for user in users:
                _printUser(user, 0, 0)
            else:
              for user in users:
                _updateDomainCounts(user['primaryEmail'])
          else:
            if showItemCountOnly:
              for user in users:
                if orgUnitPathLower == user.get('orgUnitPath', '').lower():
                  itemCount += 1
            elif not printOptions['countOnly']:
              for user in users:
                if orgUnitPathLower == user.get('orgUnitPath', '').lower():
                  _printUser(user, 0, 0)
            else:
              for user in users:
                if orgUnitPathLower == user.get('orgUnitPath', '').lower():
                  _updateDomainCounts(user['primaryEmail'])
      except GAPI.domainNotFound:
        entityActionFailedWarning([Ent.USER, None, Ent.DOMAIN, kwargs['domain']], Msg.NOT_FOUND)
        continue
      except (GAPI.invalidOrgunit, GAPI.invalidInput) as e:
        if query and not schemaParms['customFieldMask']:
          entityActionFailedWarning([Ent.USER, None], invalidQuery(query))
        elif schemaParms['customFieldMask'] and not query:
          entityActionFailedWarning([Ent.USER, None], invalidUserSchema(schemaParms['customFieldMask']))
        elif query and schemaParms['customFieldMask']:
          entityActionFailedWarning([Ent.USER, None], f'{invalidQuery(query)} or {invalidUserSchema(schemaParms["customFieldMask"])}')
        else:
          entityActionFailedWarning([Ent.USER, None], str(e))
        continue
      except (GAPI.unknownError, GAPI.failedPrecondition) as e:
        entityActionFailedExit([Ent.USER, None], str(e))
      except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
        accessErrorExit(cd)
    if showItemCountOnly:
      writeStdout(f'{itemCount}\n')
      return
  else:
    if showItemCountOnly:
      writeStdout(f'{len(entityList)}\n')
      return
    sortRows = True
# If no individual fields were specified (allfields, basic, full) or individual fields other than primaryEmail were specified, look up each user
    if isSuspended is not None and fieldsList:
      fieldsList.append('suspended')
    if projectionSet or len(set(fieldsList)) > 1 or showValidColumn:
      jcount = len(entityList)
      fields = getFieldsFromFieldsList(fieldsList)
      if GC.Values[GC.BATCH_SIZE] > 1 and jcount > 1:
        svcargs = dict([('userKey', None), ('fields', fields),
                        ('projection', schemaParms['projection']), ('customFieldMask', schemaParms['customFieldMask']),
                        ('viewType', viewType)]+GM.Globals[GM.EXTRA_ARGS_LIST])
        method = getattr(cd.users(), 'get')
        dbatch = cd.new_batch_http_request(callback=_callbackPrintUser)
        bcount = 0
        j = 0
        for userEntity in entityList:
          j += 1
          svcparms = svcargs.copy()
          svcparms['userKey'] = normalizeEmailAddressOrUID(userEntity)
          dbatch.add(method(**svcparms), request_id=batchRequestID('', 0, 0, j, jcount, svcparms['userKey']))
          bcount += 1
          if bcount >= GC.Values[GC.BATCH_SIZE]:
            executeBatch(dbatch)
            dbatch = cd.new_batch_http_request(callback=_callbackPrintUser)
            bcount = 0
        if bcount > 0:
          dbatch.execute()
      else:
        j = 0
        for userEntity in entityList:
          j += 1
          userEmail = normalizeEmailAddressOrUID(userEntity)
          try:
            user = callGAPI(cd.users(), 'get',
                            throwReasons=GAPI.USER_GET_THROW_REASONS+[GAPI.INVALID_INPUT, GAPI.RESOURCE_NOT_FOUND, GAPI.RATE_LIMIT_EXCEEDED],
                            userKey=userEmail, projection=schemaParms['projection'], customFieldMask=schemaParms['customFieldMask'],
                            viewType=viewType, fields=fields)
            _printUser(user, j, jcount)
          except (GAPI.userNotFound, GAPI.resourceNotFound):
            if not showValidColumn:
              entityUnknownWarning(Ent.USER, userEmail, j, jcount)
            else:
              _writeUserEntity({'primaryEmail': userEmail, showValidColumn: False})
          except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
                  GAPI.badRequest, GAPI.backendError, GAPI.systemError, GAPI.rateLimitExceeded) as e:
            entityActionFailedWarning([Ent.USER, userEmail], str(e), j, jcount)
          except GAPI.invalidInput as e:
            if schemaParms['customFieldMask']:
              entityActionFailedWarning([Ent.USER, userEmail], invalidUserSchema(schemaParms['customFieldMask']), j, jcount)
            else:
              entityActionFailedWarning([Ent.USER, userEmail], str(e), j, jcount)
# The only field specified was primaryEmail, just list the users/count the domains
    elif not printOptions['countOnly']:
      for userEntity in entityList:
        _printUser({'primaryEmail': normalizeEmailAddressOrUID(userEntity)}, 0, 0)
    else:
      for userEntity in entityList:
        _updateDomainCounts(normalizeEmailAddressOrUID(userEntity))
  if not printOptions['countOnly']:
    if not FJQC.formatJSON:
      if printOptions['sortHeaders']:
        sortTitles = ['primaryEmail']
        if printOptions['scalarsFirst']:
          sortTitles.extend([f'name{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{field}' for field in USER_NAME_PROPERTY_PRINT_ORDER]+sorted(USER_LANGUAGE_PROPERTY_PRINT_ORDER+USER_SCALAR_PROPERTY_PRINT_ORDER))
        csvPF.SetSortTitles(sortTitles)
        csvPF.SortTitles()
        csvPF.SetSortTitles([])
      if printOptions['getGroupFeed']:
        if not printOptions['groupsInColumns']:
          csvPF.MoveTitlesToEnd(['GroupsCount', 'Groups'])
        else:
          csvPF.MoveTitlesToEnd(['Groups']+[f'Groups{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{j}' for j in range(printOptions['maxGroups'])])
      if printOptions['getLicenseFeed'] or printOptions['getLicenseFeedByUser']:
        csvPF.MoveTitlesToEnd(licenseTitles)
      if sortRows and orderBy:
        orderBy = 'primaryEmail' if orderBy == 'email' else f'name.{orderBy}'
        csvPF.SortRows(orderBy, reverse=sortOrder == 'DESCENDING')
    else:
      if sortRows and orderBy == 'email':
        csvPF.SortRows('primaryEmail', reverse=sortOrder == 'DESCENDING')
  elif not FJQC.formatJSON:
    for domain, count in sorted(iter(domainCounts.items())):
      csvPF.WriteRowNoFilter({'domain': domain, 'count': count})
  else:
    csvPF.WriteRowNoFilter({'JSON': json.dumps(cleanJSON(domainCounts), ensure_ascii=False, sort_keys=True)})
  if printOptions['countOnly']:
    csvPF.SetIndexedTitles([])
  csvPF.writeCSVfile('Users' if not printOptions['countOnly'] else 'User Domain Counts')

# gam <UserTypeEntity> print users
def doPrintUserEntity(entityList):
  if not Cmd.ArgumentsRemaining():
    writeEntityNoHeaderCSVFile(Ent.USER, entityList)
  else:
    doPrintUsers(entityList)

# gam <UserTypeEntity> print userlist [todrive <ToDriveAttribute>*]
#	[title <String>]
#	[delimiter <Character>] [formatjson] [quotechar <Character>]
def doPrintUserList(entityList):
  csvPF = CSVPrintFile(['title', 'count', 'users'])
  FJQC = FormatJSONQuoteChar(csvPF)
  title = 'Users'
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'title':
      title = getString(Cmd.OB_STRING)
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  _, count, entityList = getEntityArgument(entityList)
  if not FJQC.formatJSON:
    csvPF.WriteRow({'title': title, 'count': count, 'users': delimiter.join(entityList)})
  else:
    csvPF.WriteRow({'title': title, 'count': count, 'users': json.dumps(cleanJSON(entityList), ensure_ascii=False, sort_keys=True)})
  csvPF.writeCSVfile('User List')

# gam print usercountsbyorgunit [todrive <ToDriveAttribute>*]
#	[domain <String>]
def doPrintUserCountsByOrgUnit():
  def _printUserCounts(title, v):
    csvPF.WriteRow({'orgUnitPath': title, 'archived': v['archived'], 'active': v['active'], 'suspended': v['suspended'], 'total': v['total']})

  USER_COUNTS_FIELDS = ['archived', 'active', 'suspended', 'total']
  USER_COUNTS_ZERO_FIELDS = {'archived': 0, 'active': 0, 'suspended': 0, 'total': 0}
  cd = buildGAPIObject(API.DIRECTORY)
  csvPF = CSVPrintFile(['orgUnitPath']+USER_COUNTS_FIELDS)
  FJQC = FormatJSONQuoteChar(csvPF)
  kwargs = {'customer': GC.Values[GC.CUSTOMER_ID]}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'domain':
      kwargs = {'domain': getString(Cmd.OB_DOMAIN_NAME)}
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  if 'domain' in kwargs:
    printGettingAllEntityItemsForWhom(Ent.USER, kwargs['domain'], entityType=Ent.DOMAIN)
    pageMessage = getPageMessageForWhom()
    title = f"Total({kwargs['domain']})"
  else:
    printGettingAllAccountEntities(Ent.USER)
    pageMessage = getPageMessage()
    title = f"Total({kwargs['customer']})"
  userCounts = {}
  try:
    result = callGAPIpages(cd.users(), 'list', 'users',
                           pageMessage=pageMessage,
                           throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.DOMAIN_NOT_FOUND, GAPI.FORBIDDEN],
                           orderBy='email', fields='nextPageToken,users(orgUnitPath,archived,suspended)',
                           maxResults=GC.Values[GC.USER_MAX_RESULTS], **kwargs)
  except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden, GAPI.domainNotFound):
    if 'domain' in kwargs:
      checkEntityDNEorAccessErrorExit(cd, Ent.DOMAIN, kwargs['domain'])
    else:
      checkEntityDNEorAccessErrorExit(cd, Ent.CUSTOMER_ID, kwargs['customer'])
    return
  for user in result:
    orgUnitPath = user['orgUnitPath']
    if orgUnitPath not in userCounts:
      userCounts[orgUnitPath] = USER_COUNTS_ZERO_FIELDS.copy()
    if user['suspended'] or user['archived']:
      if user['archived']:
        userCounts[orgUnitPath]['archived'] += 1
      if user['suspended']:
        userCounts[orgUnitPath]['suspended'] += 1
    else:
      userCounts[orgUnitPath]['active'] += 1
    userCounts[orgUnitPath]['total'] += 1
  totalCounts = USER_COUNTS_ZERO_FIELDS.copy()
  for k, v in sorted(iter(userCounts.items())):
    _printUserCounts(k, v)
    for f in USER_COUNTS_FIELDS:
      totalCounts[f] += v[f]
  _printUserCounts(title, totalCounts)
  csvPF.writeCSVfile('User Counts by OrgUnit')

def isolateCIUserInvitatonsEmail(name):
  ''' converts long name into email address'''
  return name.split('/')[-1]

def quotedCIUserInvitatonsEmail(customer, email):
  return f"{customer}/userinvitations/{quote_plus(email, safe='@')}"

def _getCIUserInvitationsEntity(ci=None, email=None):
  if ci is None:
    ci = buildGAPIObject(API.CLOUDIDENTITY_USERINVITATIONS)
  customer = _getCustomersCustomerIdWithC()
  if email is None:
    email = getString(Cmd.OB_EMAIL_ADDRESS)
  pattern = re.compile(rf'^{customer}/userinvitations/(.+)$')
  mg = pattern.match(email)
  if mg:
    email = mg.group(1)
  else:
    email = normalizeEmailAddressOrUID(email, noUid=True)
  return (quotedCIUserInvitatonsEmail(customer, email), email, ci)

def _getIsInvitableUser(ci, email):
  name, _, ci = _getCIUserInvitationsEntity(ci, email)
  try:
    result = callGAPI(ci.customers().userinvitations(), 'isInvitableUser',
                      throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                      name=name)
    return (result['isInvitableUser'], ci)
  except GAPI.notFound:
    return (False, ci)
  except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied):
    return (False, ci)

# gam send userinvitation <EmailAddress>
# gam cancel userinvitation <EmailAddress>
def doCIUserInvitationsAction():
  name, user, ci = _getCIUserInvitationsEntity()
  checkForExtraneousArguments()
  if Act.Get() == Act.CANCEL:
    action = 'cancel'
  else:
    Act.Set(Act.SEND)
    action = 'send'
  entityPerformAction([Ent.USER_INVITATION, user])
  try:
    result = callGAPI(ci.customers().userinvitations(), action,
                      throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                      name=name)
    name = result.get('response', {}).get('name')
    if name:
      result['response']['name'] = isolateCIUserInvitatonsEmail(name)
    Ind.Increment()
    showJSON(None, result)
    Ind.Decrement()
  except GAPI.notFound:
    entityUnknownWarning(Ent.USER_INVITATION, f'{user}')
  except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied) as e:
    entityActionFailedWarning([Ent.USER_INVITATION, f'{user}'], str(e))

CI_USERINVITATION_TIME_OBJECTS = {'updateTime'}

def _showUserInvitation(invitation, FJQC, i=0, count=0):
  if FJQC is not None and FJQC.formatJSON:
    invitation['email'] = isolateCIUserInvitatonsEmail(invitation['name'])
    printLine(json.dumps(cleanJSON(invitation, timeObjects=CI_USERINVITATION_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.USER_INVITATION, isolateCIUserInvitatonsEmail(invitation['name'])], i, count)
  Ind.Increment()
  showJSON(None, invitation, timeObjects=CI_USERINVITATION_TIME_OBJECTS)
  Ind.Decrement()

# gam check userinvitation|isinvitable <EmailAddress>
def doCheckCIUserInvitations():
  name, user, ci = _getCIUserInvitationsEntity()
  checkForExtraneousArguments()
  try:
    result = callGAPI(ci.customers().userinvitations(), 'isInvitableUser',
                      throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                      name=name)
    printEntity([Ent.USER_INVITATION, user])
    Ind.Increment()
    showJSON(None, result)
    Ind.Decrement()
  except GAPI.notFound:
    entityUnknownWarning(Ent.USER_INVITATION, f'{user}')
  except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied) as e:
    entityActionFailedWarning([Ent.USER_INVITATION, f'{user}'], str(e))

def infoCIUserInvitations(name, user, ci, FJQC):
  try:
    invitation = callGAPI(ci.customers().userinvitations(), 'get',
                          throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                          name=name)
    _showUserInvitation(invitation, FJQC)
  except GAPI.notFound:
    entityUnknownWarning(Ent.USER_INVITATION, f'{user}')
  except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied) as e:
    entityActionFailedWarning([Ent.USER_INVITATION, f'{user}'], str(e))

# gam info userinvitation <EmailAddress> [formatjson]
def doInfoCIUserInvitations():
  name, user, ci = _getCIUserInvitationsEntity()
  FJQC = FormatJSONQuoteChar(formatJSONOnly=True)
  infoCIUserInvitations(name, user, ci, FJQC)

CI_USERINVITATION_ORDERBY_CHOICE_MAP = {
  'email': 'email',
  'updatetime': 'update_time',
  }

CI_USERINVITATION_STATE_CHOICE_MAP = {
  'accepted': 'ACCEPTED',
  'declined': 'DECLINED',
  'invited': 'INVITED',
  'notyetsent': 'NOT_YET_SENT',
  }

# gam show userinvitations
#	[state notyetsent|invited|accepted|declined]
#	[orderby email|updatetime [ascending|descending]]
#	[formatjson]
# gam print userinvitations [todrive <ToDriveAttribute>*]
#	[state notyetsent|invited|accepted|declined]
#	[orderby email|updatetime [ascending|descending]]
#	[[formatjson [quotechar <Character>]]
def doPrintShowCIUserInvitations():
  def _printUserInvitation(invitation):
    invitation['email'] = isolateCIUserInvitatonsEmail(invitation['name'])
    row = flattenJSON(invitation, timeObjects=CI_USERINVITATION_TIME_OBJECTS)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'email': invitation['email'],
                              'JSON': json.dumps(cleanJSON(invitation, timeObjects=CI_USERINVITATION_TIME_OBJECTS),
                                                 ensure_ascii=False, sort_keys=True)})

  ci = buildGAPIObject(API.CLOUDIDENTITY_USERINVITATIONS)
  customer = _getCustomersCustomerIdWithC()
  csvPF = CSVPrintFile(['email']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  OBY = OrderBy(CI_USERINVITATION_ORDERBY_CHOICE_MAP)
  ifilter = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'state':
      state = getChoice(CI_USERINVITATION_STATE_CHOICE_MAP, mapChoice=True)
      ifilter = f"state=='{state}'"
    elif myarg == 'orderby':
      OBY.GetChoice()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  printGettingAllAccountEntities(Ent.USER_INVITATION, ifilter)
  pageMessage = getPageMessage()
  try:
    invitations = callGAPIpages(ci.customers().userinvitations(), 'list', 'userInvitations',
                                throwReasons=[GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                                pageMessage=pageMessage,
                                parent=customer, filter=ifilter, orderBy=OBY.orderBy)
  except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied) as e:
    entityActionFailedWarning([Ent.USER_INVITATION, None], str(e))
    return
  if not csvPF:
    jcount = len(invitations)
    performActionNumItems(jcount, Ent.USER_INVITATION)
    Ind.Increment()
    j = 0
    for invitation in invitations:
      j += 1
      _showUserInvitation(invitation, FJQC, j, jcount)
    Ind.Decrement()
  else:
    for invitation in invitations:
      _printUserInvitation(invitation)
  if csvPF:
    csvPF.writeCSVfile('User Invitations')

# gam <UserTypeEntity> check isinvitable [todrive <ToDriveAttribute>*]
# /batch is broken for Cloud Identity. Once fixed move this to using batch.
# Current serial implementation will be SLOW...
def checkCIUserIsInvitable(users):
  ci = buildGAPIObject(API.CLOUDIDENTITY_USERINVITATIONS)
  customer = _getCustomersCustomerIdWithC()
  csvPF = CSVPrintFile(['invitableUsers'])
  getTodriveOnly(csvPF)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user = convertUIDtoEmailAddress(user)
    name = quotedCIUserInvitatonsEmail(customer, user)
    try:
      result = callGAPI(ci.customers().userinvitations(), 'isInvitableUser',
                        throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.PERMISSION_DENIED],
                        name=name)
      if result.get('isInvitableUser'):
        csvPF.WriteRow({'invitableUsers': user})
    except GAPI.notFound:
      entityUnknownWarning(Ent.USER_INVITATION, f'{user}', i, count)
    except (GAPI.invalid, GAPI.permissionDenied) as e:
      entityActionFailedWarning([Ent.USER_INVITATION, user], str(e), i, count)
      return
  csvPF.writeCSVfile('Invitable Users')

INBOUNDSSO_MODE_CHOICE_MAP = {
  'ssooff': 'SSO_OFF',
  'samlsso': 'SAML_SSO',
  'domainwidesamlifenabled': 'DOMAIN_WIDE_SAML_IF_ENABLED'
  }

def getCIOrgunitID(cd, orgunit):
  ou_id = getOrgUnitId(cd, orgunit)[1]
  if ou_id.startswith('id:'):
    ou_id = ou_id[3:]
  return f'orgUnits/{ou_id}'

def _getInboundSSOProfiles(ci):
  customer = normalizeChannelCustomerID(GC.Values[GC.CUSTOMER_ID])
  try:
    return callGAPIpages(ci.inboundSamlSsoProfiles(), 'list', 'inboundSamlSsoProfiles',
                         throwReasons=GAPI.CISSO_LIST_THROW_REASONS,
                         retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                         bailOnInternalError=True,
                         filter=f'customer=="{customer}"')
  except (GAPI.notFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
          GAPI.forbidden, GAPI.badRequest, GAPI.invalid,
          GAPI.systemError, GAPI.permissionDenied, GAPI.internalError, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning([Ent.INBOUND_SSO_PROFILE, customer], str(e))
    return []

def _convertInboundSSOProfileDisplaynameToName(ci=None, displayName=''):
  if displayName.lower().startswith('id:') or displayName.lower().startswith('uid:'):
    displayName = displayName.split(':', 1)[1]
    if not displayName.startswith('inboundSamlSsoProfiles/'):
      displayName = f'inboundSamlSsoProfiles/{displayName}'
    return displayName
  if not ci:
    ci = buildGAPIObject(API.CLOUDIDENTITY_INBOUND_SSO)
  profiles = _getInboundSSOProfiles(ci)
  matches = []
  for profile in profiles:
    if displayName.lower() == profile.get('displayName', '').lower():
      matches.append(profile)
  if len(matches) == 1:
    return matches[0]['name']
  if len(matches) == 0:
    usageErrorExit(Msg.NO_SSO_PROFILE_MATCHES.format(displayName))
  errMsg = Msg.MULTIPLE_SSO_PROFILES_MATCH.format(displayName)
  for m in matches:
    errMsg += f'  {m["name"]}  {m["displayName"]}\n'
  usageErrorExit(errMsg)

def _getInboundSSOProfileArguments(body):
  returnNameOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'name':
      body['displayName'] = getString(Cmd.OB_STRING)
    elif myarg == 'entityid':
      body.setdefault('idpConfig', {})['entityId'] = getString(Cmd.OB_STRING)
    elif myarg == 'loginurl':
      body.setdefault('idpConfig', {})['singleSignOnServiceUri'] = getString(Cmd.OB_STRING)
    elif myarg == 'logouturl':
      body.setdefault('idpConfig', {})['logoutRedirectUri'] = getString(Cmd.OB_STRING)
    elif myarg == 'changepasswordurl':
      body.setdefault('idpConfig', {})['changePasswordUri'] = getString(Cmd.OB_STRING)
    elif myarg == 'returnnameonly':
      returnNameOnly = True
    else:
      unknownArgumentExit()
  return (returnNameOnly, body)

def _showInboundSSOProfile(profile, FJQC, i=0, count=0):
  if FJQC is not None and FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(profile), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.INBOUND_SSO_PROFILE, profile['name']], i, count)
  Ind.Increment()
  showJSON(None, profile)
  Ind.Decrement()

def _processInboundSSOProfileResult(result, returnNameOnly, kvlist, function):
  if GC.Values[GC.DEBUG_LEVEL] > 0:
    writeStderr(f'inboundSSOProfileResult: {result}\n')
  if result.get('done', False):
    if 'error' not in result:
      if 'response' in result:
        if not returnNameOnly:
          _showInboundSSOProfile(result['response'], None)
        else:
          writeStdout(f'{result["response"]["name"]}\n')
      else:
        entityActionPerformed(kvlist)
    else:
      entityActionFailedWarning(kvlist, result['error']['message'])
  elif not returnNameOnly:
    entityActionPerformedMessage(kvlist, Msg.ACTION_IN_PROGRESS.format(f'{function} inboundssoprofile'))
  else:
    writeStdout('inProgress\n')

# gam create inboundssoprofile [name <SSOProfileName>]
#	[entityid <String>] [loginurl <URL>] [logouturl <URL>] [changepasswordurl <URL>]
#	[returnnameonly]
def doCreateInboundSSOProfile():
  ci = buildGAPIObject(API.CLOUDIDENTITY_INBOUND_SSO)
  body = {'customer': normalizeChannelCustomerID(GC.Values[GC.CUSTOMER_ID]),
          'displayName': 'SSO Profile'
         }
  returnNameOnly, body = _getInboundSSOProfileArguments(body)
  kvlist = [Ent.INBOUND_SSO_PROFILE, body['displayName']]
  try:
    result = callGAPI(ci.inboundSamlSsoProfiles(), 'create',
                      throwReasons=GAPI.CISSO_CREATE_THROW_REASONS,
                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                      bailOnInternalError=True,
                      body=body)
    _processInboundSSOProfileResult(result, returnNameOnly, kvlist, 'create')
  except (GAPI.failedPrecondition, GAPI.notFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.invalid, GAPI.invalidInput, GAPI.invalidArgument,
          GAPI.systemError, GAPI.permissionDenied, GAPI.internalError, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning(kvlist, str(e))

# gam update inboundssoprofile <SSOProfileItem>
#	[entityid <String>] [loginurl <URL>] [logouturl <URL>] [changepasswordurl <URL>]
#	[returnnameonly]
def doUpdateInboundSSOProfile():
  ci = buildGAPIObject(API.CLOUDIDENTITY_INBOUND_SSO)
  name = _convertInboundSSOProfileDisplaynameToName(ci, getString(Cmd.OB_STRING))
  returnNameOnly, body = _getInboundSSOProfileArguments({})
  kvlist = [Ent.INBOUND_SSO_PROFILE, name]
  try:
    result = callGAPI(ci.inboundSamlSsoProfiles(), 'patch',
                      throwReasons=GAPI.CISSO_UPDATE_THROW_REASONS,
                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                      bailOnInternalError=True,
                      name=name, updateMask=','.join(body.keys()), body=body)
    _processInboundSSOProfileResult(result, returnNameOnly, kvlist, 'update')
  except GAPI.notFound:
    entityActionFailedWarning(kvlist, Msg.DOES_NOT_EXIST)
  except (GAPI.failedPrecondition, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.invalid, GAPI.invalidInput, GAPI.invalidArgument,
          GAPI.systemError, GAPI.permissionDenied, GAPI.internalError, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning(kvlist, str(e))

# gam delete inboundssoprofile <SSOProfileItem>
def doDeleteInboundSSOProfile():
  ci = buildGAPIObject(API.CLOUDIDENTITY_INBOUND_SSO)
  name = _convertInboundSSOProfileDisplaynameToName(ci, getString(Cmd.OB_STRING))
  checkForExtraneousArguments()
  kvlist = [Ent.INBOUND_SSO_PROFILE, name]
  try:
    result = callGAPI(ci.inboundSamlSsoProfiles(), 'delete',
                      throwReasons=GAPI.CISSO_UPDATE_THROW_REASONS,
                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                      bailOnInternalError=True,
                      name=name)
    _processInboundSSOProfileResult(result, True, kvlist, 'delete')
  except GAPI.notFound:
    entityActionFailedWarning(kvlist, Msg.DOES_NOT_EXIST)
  except (GAPI.failedPrecondition, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.invalid, GAPI.invalidInput, GAPI.invalidArgument,
          GAPI.systemError, GAPI.permissionDenied, GAPI.internalError, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning(kvlist, str(e))

def _getInboundSSOProfile(ci, name):
  kvlist = [Ent.INBOUND_SSO_PROFILE, name]
  try:
    return callGAPI(ci.inboundSamlSsoProfiles(), 'get',
                    throwReasons=GAPI.CISSO_GET_THROW_REASONS,
                    retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                    bailOnInternalError=True,
                    name=name)
  except GAPI.notFound:
    entityActionFailedWarning(kvlist, Msg.DOES_NOT_EXIST)
  except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.invalid, GAPI.systemError, GAPI.permissionDenied, GAPI.internalError, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning(kvlist, str(e))
  return None

# gam info inboundssoprofile <SSOProfileItem> [formatjson]
def doInfoInboundSSOProfile():
  ci = buildGAPIObject(API.CLOUDIDENTITY_INBOUND_SSO)
  name = _convertInboundSSOProfileDisplaynameToName(ci, getString(Cmd.OB_STRING))
  FJQC = FormatJSONQuoteChar(formatJSONOnly=True)
  profile = _getInboundSSOProfile(ci, name)
  if profile:
    _showInboundSSOProfile(profile, FJQC)

# gam show inboundssoprofile
#	[formatjson]
# gam print inboundssoprofile [todrive <ToDriveAttribute>*]
#	[[formatjson [quotechar <Character>]]
def doPrintShowInboundSSOProfiles():
  ci = buildGAPIObject(API.CLOUDIDENTITY_INBOUND_SSO)
  customer = normalizeChannelCustomerID(GC.Values[GC.CUSTOMER_ID])
  csvPF = CSVPrintFile(['name']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  cfilter = f'customer=="{customer}"'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if csvPF:
    printGettingAllAccountEntities(Ent.INBOUND_SSO_PROFILE, cfilter)
  profiles = _getInboundSSOProfiles(ci)
  if not csvPF:
    count = len(profiles)
    if not FJQC.formatJSON:
      performActionNumItems(count, Ent.INBOUND_SSO_PROFILE)
    Ind.Increment()
    i = 0
    for profile in profiles:
      i += 1
      _showInboundSSOProfile(profile, FJQC, i, count)
    Ind.Decrement()
  else:
    for profile in profiles:
      row = flattenJSON(profile)
      if not FJQC.formatJSON:
        csvPF.WriteRowTitles(row)
      elif csvPF.CheckRowTitles(row):
        csvPF.WriteRowNoFilter({'name': profile['name'],
                                'JSON': json.dumps(cleanJSON(profile),
                                                   ensure_ascii=False, sort_keys=True)})
  if csvPF:
    csvPF.writeCSVfile('Inbound SSO Profiles')

def getInboundSSOProfileCredentials(ci, profile):
  try:
    return callGAPIpages(ci.inboundSamlSsoProfiles().idpCredentials(), 'list', 'idpCredentials',
                         throwReasons=GAPI.CISSO_LIST_THROW_REASONS,
                         retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                         bailOnInternalError=True,
                         parent=profile)
  except (GAPI.notFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
          GAPI.forbidden, GAPI.badRequest, GAPI.invalid,
          GAPI.systemError, GAPI.permissionDenied, GAPI.internalError, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning([Ent.INBOUND_SSO_PROFILE, profile], str(e))
    return None

def getInboundSSOCredentialsName():
  name = getString(Cmd.OB_STRING)
  if name.startswith('id:') or name.startswith('uid:'):
    name = name.split(':', 1)[1]
  return name

INBOUNDSSO_CREDENTIALS_TIME_OBJECTS = ['updateTime']

def _showInboundSSOCredentials(credentials, FJQC, i=0, count=0):
  if FJQC is not None and FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(credentials, timeObjects=INBOUNDSSO_CREDENTIALS_TIME_OBJECTS),
                         ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.INBOUND_SSO_CREDENTIALS, credentials['name']], i, count)
  Ind.Increment()
  showJSON(None, credentials, timeObjects=INBOUNDSSO_CREDENTIALS_TIME_OBJECTS)
  Ind.Decrement()

def _processInboundSSOCredentialsResult(result, kvlist, function):
  if result.get('done', False):
    if 'error' not in result:
      if 'response' in result:
        _showInboundSSOCredentials(result['response'], None)
      else:
        entityActionPerformed(kvlist)
    else:
      entityActionFailedWarning(kvlist, result['error']['message'])
  else:
    entityActionPerformedMessage(kvlist, Msg.ACTION_IN_PROGRESS.format(f'{function} inboundssocredentials'))

# gam create inboundssocredentials profile <SSOProfileItem>
#	(pemfile <FileName>)|(generatekey [keysize 1024|2048|4096]) [replaceolddest]
def doCreateInboundSSOCredential():
  ci = buildGAPIObject(API.CLOUDIDENTITY_INBOUND_SSO)
  profile = None
  generateKey = replaceOldest = False
  keySize = 2048
  pemData = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'profile':
      profile = _convertInboundSSOProfileDisplaynameToName(ci, getString(Cmd.OB_STRING))
    elif myarg == 'pemfile':
      pemData = readFile(getString(Cmd.OB_FILE_NAME))
    elif myarg == 'generatekey':
      generateKey = True
    elif myarg == 'replaceoldest':
      replaceOldest = True
    elif myarg == 'keysize':
      keySize=int(getChoice([1024, 2048, 4096]))
    else:
      unknownArgumentExit()
  if not profile:
    missingArgumentExit('profile')
  if not pemData and not generateKey:
    missingArgumentExit('pemfile|generatekey')
  if pemData and generateKey:
    usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format('pemfile', 'generatekey'))
  if replaceOldest:
    credentials = getInboundSSOProfileCredentials(ci, profile)
    if credentials is None:
      return
    count = len(credentials)
    if count == 2:
      oldest_key = min(credentials, key=lambda x:x['updateTime'])
      action = Act.Get()
      Act.Set(Act.DELETE)
      doDeleteInboundSSOCredential(ci=ci, name=oldest_key['name'])
      Act.Set(action)
    else:
      writeStdout(Msg.NO_CREDENTIALS_REPLACEMENT.format(Ent.Singular(Ent.INBOUND_SSO_PROFILE), profile,
                                                        count, Ent.Choose(Ent.INBOUND_SSO_CREDENTIALS, count)))
  if generateKey:
    privKey, pemData = _generatePrivateKeyAndPublicCert('', '', 'GAM', keySize, b64enc_pub=False)
    timestamp = datetime.datetime.now(GC.Values[GC.TIMEZONE]).strftime('%Y%m%d-%I%M%S')
    priv_file = f'privatekey-{timestamp}.pem'
    writeFile(priv_file, privKey)
    writeStdout(Msg.WROTE_PRIVATE_KEY_DATA.format(priv_file))
    pub_file = f'publiccert-{timestamp}.pem'
    writeFile(pub_file, pemData)
    writeStdout(Msg.WROTE_PUBLIC_CERTIFICATE.format(pub_file))
    kvlist = [Ent.INBOUND_SSO_CREDENTIALS, profile]
  try:
    result = callGAPI(ci.inboundSamlSsoProfiles().idpCredentials(), 'add',
                      throwReasons=GAPI.CISSO_UPDATE_THROW_REASONS,
                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                      bailOnInternalError=True,
                      parent=profile, body={'pemData': pemData})
    _processInboundSSOCredentialsResult(result, kvlist, 'create')
  except GAPI.notFound as e:
    entityActionFailedWarning([Ent.INBOUND_SSO_PROFILE, profile], str(e))
  except (GAPI.failedPrecondition, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.invalid, GAPI.invalidInput, GAPI.invalidArgument,
          GAPI.systemError, GAPI.permissionDenied, GAPI.internalError, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning(kvlist, str(e))

# gam delete inboundssocredential <SSOCredentialsName>
def doDeleteInboundSSOCredential(ci=None, name=None):
  if not ci:
    ci = buildGAPIObject(API.CLOUDIDENTITY_INBOUND_SSO)
  if not name:
    name = getInboundSSOCredentialsName()
  checkForExtraneousArguments()
  kvlist = [Ent.INBOUND_SSO_CREDENTIALS, name]
  try:
    result = callGAPI(ci.inboundSamlSsoProfiles().idpCredentials(), 'delete',
                      throwReasons=GAPI.CISSO_UPDATE_THROW_REASONS,
                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                      bailOnInternalError=True,
                      name=name)
    _processInboundSSOCredentialsResult(result, kvlist, 'delete')
  except GAPI.notFound:
    entityActionFailedWarning(kvlist, Msg.DOES_NOT_EXIST)
  except (GAPI.failedPrecondition, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.invalid, GAPI.invalidInput, GAPI.invalidArgument,
          GAPI.systemError, GAPI.permissionDenied, GAPI.internalError, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning(kvlist, str(e))

# gam info inboundssocredential <SSOCredentialsName> [formatjson]
def doInfoInboundSSOCredential():
  ci = buildGAPIObject(API.CLOUDIDENTITY_INBOUND_SSO)
  name = getInboundSSOCredentialsName()
  FJQC = FormatJSONQuoteChar(formatJSONOnly=True)
  kvlist = [Ent.INBOUND_SSO_CREDENTIALS, name]
  try:
    credentials = callGAPI(ci.inboundSamlSsoProfiles().idpCredentials(), 'get',
                          throwReasons=GAPI.CISSO_GET_THROW_REASONS,
                           retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                          bailOnInternalError=True,
                          name=name)
    _showInboundSSOCredentials(credentials, FJQC)
  except GAPI.notFound:
    entityActionFailedWarning(kvlist, Msg.DOES_NOT_EXIST)
  except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.invalid, GAPI.systemError, GAPI.permissionDenied, GAPI.internalError, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning(kvlist, str(e))

# gam show inboundssocredentials [profile|profiles <SSOProfileItemList>]
#	[formatjson]
# gam print inboundssocredentials [profile|profiles <SSOProfileItemList>]
#	[[formatjson [quotechar <Character>]]
def doPrintShowInboundSSOCredentials():
  ci = buildGAPIObject(API.CLOUDIDENTITY_INBOUND_SSO)
  csvPF = CSVPrintFile(['name']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  profiles = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'profile', 'profiles'}:
      profiles = [_convertInboundSSOProfileDisplaynameToName(ci, profile) for profile in getString(Cmd.OB_STRING_LIST).split(',')]
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if not profiles:
    profiles = [p['name'] for p in _getInboundSSOProfiles(ci)]
  count = len(profiles)
  i = 0
  for profile in profiles:
    i += 1
    credentials = getInboundSSOProfileCredentials(ci, profile)
    if credentials is None:
      continue
    if not csvPF:
      jcount = len(credentials)
      if not FJQC.formatJSON:
        entityPerformActionNumItems([Ent.INBOUND_SSO_PROFILE, profile], jcount, Ent.INBOUND_SSO_CREDENTIALS, i, count)
      Ind.Increment()
      j = 0
      for credential in credentials:
        j += 1
        _showInboundSSOCredentials(credential, FJQC, j, jcount)
      Ind.Decrement()
    else:
      for credential in credentials:
        row = flattenJSON(credential, timeObjects=INBOUNDSSO_CREDENTIALS_TIME_OBJECTS)
        if not FJQC.formatJSON:
          csvPF.WriteRowTitles(row)
        elif csvPF.CheckRowTitles(row):
          csvPF.WriteRowNoFilter({'name': credential['name'],
                                  'JSON': json.dumps(cleanJSON(credential, timeObjects=INBOUNDSSO_CREDENTIALS_TIME_OBJECTS),
                                                     ensure_ascii=False, sort_keys=True)})
  if csvPF:
    csvPF.writeCSVfile('Inbound SSO Credentials')

def _getInboundSSOAssignment(ci, name):
  kvlist = [Ent.INBOUND_SSO_ASSIGNMENT, name]
  try:
    return callGAPI(ci.inboundSsoAssignments(), 'get',
                    throwReasons=GAPI.CISSO_GET_THROW_REASONS,
                    retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                    bailOnInternalError=True,
                    name=name)
  except GAPI.notFound:
    entityActionFailedWarning(kvlist, Msg.DOES_NOT_EXIST)
  except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.invalid, GAPI.systemError, GAPI.permissionDenied, GAPI.internalError, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning(kvlist, str(e))
  return None

def _getInboundSSOAssignments(ci):
  customer = normalizeChannelCustomerID(GC.Values[GC.CUSTOMER_ID])
  try:
    return callGAPIpages(ci.inboundSsoAssignments(), 'list', 'inboundSsoAssignments',
                         throwReasons=GAPI.CISSO_LIST_THROW_REASONS,
                         retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                         bailOnInternalError=True,
                         filter=f'customer=="{customer}"')
  except (GAPI.notFound, GAPI.domainNotFound, GAPI.domainCannotUseApis,
          GAPI.forbidden, GAPI.badRequest, GAPI.invalid,
          GAPI.systemError, GAPI.permissionDenied, GAPI.internalError, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning([Ent.INBOUND_SSO_ASSIGNMENT, customer], str(e))
    return None

def _getInboundSSOAssignmentName():
  name = getString(Cmd.OB_STRING)
  if name.startswith('id:') or name.startswith('uid:'):
    name = name.split(':', 1)[1]
  if not name.startswith('inboundSsoAssignments/'):
    name = f'inboundSsoAssignments/{name}'
  return name

def _getInboundSSOAssignmentByTarget(ci, cd, target):
  targetType = 'name'
  if target.startswith('id:') or target.startswith('uid:'):
    target = target.split(':', 1)[1]
  elif re.match(r'^groups/[^/]+$', target):
    targetType = 'targetGroup'
  elif re.match(r'^orgUnits/[^/]+$', target):
    targetType = 'targetOrgUnit'
  elif target.lower().startswith('group:'):
    targetType = 'targetGroup'
    _, target, _ = convertGroupEmailToCloudID(ci, target[6:])
  elif target.lower().startswith('orgunit:'):
    targetType = 'targetOrgUnit'
    target = getCIOrgunitID(cd, target[8:])
  elif not target.startswith('inboundSsoAssignments/'):
    target = f'inboundSsoAssignments/{target}'
  if targetType == 'name':
    return _getInboundSSOAssignment(ci, target)
  assignments = _getInboundSSOAssignments(ci)
  if assignments is not None:
    for assignment in assignments:
      if targetType in assignment and assignment[targetType] == target:
        return assignment
  usageErrorExit(Msg.NO_SSO_PROFILE_ASSIGNED.format(targetType, target))

def _getInboundSSOAssignmentArguments(ci, cd, body):
  rank = 0
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'rank':
      rank = getInteger(minVal=1)
    elif myarg == 'mode':
      body['ssoMode'] = getChoice(INBOUNDSSO_MODE_CHOICE_MAP, mapChoice=True)
    elif myarg == 'profile':
      body['samlSsoInfo'] = {'inboundSamlSsoProfile':
                               _convertInboundSSOProfileDisplaynameToName(ci, getString(Cmd.OB_STRING))}
    elif myarg == 'neverredirect':
      body['signInBehavior'] = {'redirectCondition': 'NEVER'}
    elif myarg == 'group':
      _, body['targetGroup'], _ = convertGroupEmailToCloudID(ci, getString(Cmd.OB_STRING))
    elif myarg in {'ou', 'org', 'orgunit'}:
      body['targetOrgUnit'] = getCIOrgunitID(cd, getString(Cmd.OB_ORGUNIT_ITEM))
    else:
      unknownArgumentExit()
  if 'ssoMode' not in body:
    missingArgumentExit('mode')
  if body['ssoMode'] == 'SAML_SSO' and 'samlSsoInfo' not in body:
    missingArgumentExit('profile')
  if 'targetGroup' in body:
    if 'targetOrgUnit' in body:
      usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format('group', 'ou|org|orgunit'))
    if not rank:
      missingArgumentExit('rank')
    body['rank'] = rank
  return body

def _updateInboundAssignmentTargetNames(ci, cd, assignment):
  if 'targetGroup' in assignment:
    _, _, assignment['targetGroupEmail'] = convertGroupCloudIDToEmail(ci, assignment['targetGroup'])
  elif 'targetOrgUnit' in assignment:
    assignment['targetOrgUnitPath'] = convertOrgUnitIDtoPath(cd, assignment['targetOrgUnit'])

def _showInboundSSOAssignment(assignment, FJQC, ci, cd, i=0, count=0):
  if FJQC is not None and FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(assignment), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.INBOUND_SSO_ASSIGNMENT, assignment['name']], i, count)
  _updateInboundAssignmentTargetNames(ci, cd, assignment)
  Ind.Increment()
  showJSON(None, assignment)
  Ind.Decrement()

def _processInboundSSOAssignmentResult(result, kvlist, ci, cd, function):
  if result['done']:
    if 'error' not in result:
      if 'response' in result:
        _showInboundSSOAssignment(result['response'], None, ci, cd)
      else:
        entityActionPerformed(kvlist)
    else:
      entityActionFailedWarning(kvlist, result['error']['message'])
  else:
    entityActionPerformedMessage(kvlist, Msg.ACTION_IN_PROGRESS.format(f'{function} inboundssoassignment'))

# gam create inboundssoassignment (group <GroupItem> rank <Number>)|(ou|org|orgunit <OrgUnitItem>)
#	(mode sso_off)|(mode saml_sso profile <SSOProfileItem>)(mode domain_wide_saml_if_enabled) [neverredirect]
def doCreateInboundSSOAssignment():
  cd = buildGAPIObject(API.DIRECTORY)
  ci = buildGAPIObject(API.CLOUDIDENTITY_INBOUND_SSO)
  body = {'customer': normalizeChannelCustomerID(GC.Values[GC.CUSTOMER_ID])}
  body = _getInboundSSOAssignmentArguments(ci, cd, body)
  kvlist = [Ent.INBOUND_SSO_ASSIGNMENT, body['customer']]
  try:
    result = callGAPI(ci.inboundSsoAssignments(), 'create',
                      throwReasons=GAPI.CISSO_CREATE_THROW_REASONS,
                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                      bailOnInternalError=True,
                      body=body)
    _processInboundSSOAssignmentResult(result, kvlist, ci, cd, 'create')
  except (GAPI.failedPrecondition, GAPI.notFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.invalid, GAPI.invalidInput, GAPI.invalidArgument,
          GAPI.systemError, GAPI.permissionDenied, GAPI.internalError, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning(kvlist, str(e))

# gam update inboundssoassignment [(group <GroupItem> rank <Number>)|(ou|org|orgunit <OrgUnitItem>)]
#	[(mode sso_off)|(mode saml_sso profile <SSOProfileItem>)(mode domain_wide_saml_if_enabled)] [neverredirect]
def doUpdateInboundSSOAssignment():
  cd = buildGAPIObject(API.DIRECTORY)
  ci = buildGAPIObject(API.CLOUDIDENTITY_INBOUND_SSO)
  name = _getInboundSSOAssignmentName()
  body = _getInboundSSOAssignmentArguments(ci,cd, {})
  kvlist = [Ent.INBOUND_SSO_ASSIGNMENT, name]
  try:
    result = callGAPI(ci.inboundSsoAssignments(), 'patch',
                      throwReasons=GAPI.CISSO_UPDATE_THROW_REASONS,
                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                      bailOnInternalError=True,
                      name=name, updateMask=','.join(list(body.keys())), body=body)
    _processInboundSSOAssignmentResult(result, kvlist, ci, cd, 'update')
  except GAPI.notFound:
    entityActionFailedWarning(kvlist, Msg.DOES_NOT_EXIST)
  except (GAPI.failedPrecondition, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.invalid, GAPI.invalidInput, GAPI.invalidArgument,
          GAPI.systemError, GAPI.permissionDenied, GAPI.internalError, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning(kvlist, str(e))

# gam delete inboundssoassignment <SSOAssignmentSelector>
def doDeleteInboundSSOAssignment():
  cd = buildGAPIObject(API.DIRECTORY)
  ci = buildGAPIObject(API.CLOUDIDENTITY_INBOUND_SSO)
  target = getString(Cmd.OB_STRING)
  assignment = _getInboundSSOAssignmentByTarget(ci, cd, target)
  if assignment is None:
    return
  name = assignment['name']
  checkForExtraneousArguments()
  kvlist = [Ent.INBOUND_SSO_ASSIGNMENT, name]
  try:
    result = callGAPI(ci.inboundSsoAssignments(), 'delete',
                      throwReasons=GAPI.CISSO_UPDATE_THROW_REASONS,
                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                      bailOnInternalError=True,
                      name=name)
    _processInboundSSOAssignmentResult(result, kvlist, None, None, 'delete')
  except GAPI.notFound:
    entityActionFailedWarning(kvlist, Msg.DOES_NOT_EXIST)
  except (GAPI.failedPrecondition, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
          GAPI.badRequest, GAPI.invalid, GAPI.invalidInput, GAPI.invalidArgument,
          GAPI.systemError, GAPI.permissionDenied, GAPI.internalError, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning(kvlist, str(e))

# gam info inboundssoassignment <SSOAssignmentSelector> [formatjson]
def doInfoInboundSSOAssignment():
  cd = buildGAPIObject(API.DIRECTORY)
  ci = buildGAPIObject(API.CLOUDIDENTITY_INBOUND_SSO)
  target = getString(Cmd.OB_STRING)
  FJQC = FormatJSONQuoteChar(formatJSONOnly=True)
  assignment = _getInboundSSOAssignmentByTarget(ci, cd, target)
  if assignment is None:
    return
  name = assignment.get('samlSsoInfo', {}).get('inboundSamlSsoProfile')
  if name:
    profile = _getInboundSSOProfile(ci, name)
    if profile:
      assignment['samlSsoInfo']['inboundSamlSsoProfile'] = profile
  _showInboundSSOAssignment(assignment, FJQC, ci, cd)

# gam show inboundssoassignment
#	[formatjson]
# gam print inboundssoassignment [todrive <ToDriveAttribute>*]
#	[[formatjson [quotechar <Character>]]
def doPrintShowInboundSSOAssignments():
  cd = buildGAPIObject(API.DIRECTORY)
  ci = buildGAPIObject(API.CLOUDIDENTITY_INBOUND_SSO)
  customer = normalizeChannelCustomerID(GC.Values[GC.CUSTOMER_ID])
  csvPF = CSVPrintFile(['name']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  cfilter = f'customer=="{customer}"'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if csvPF:
    printGettingAllAccountEntities(Ent.INBOUND_SSO_ASSIGNMENT, cfilter)
  assignments = _getInboundSSOAssignments(ci)
  if assignments is None:
    return
  if not csvPF:
    count = len(assignments)
    if not FJQC.formatJSON:
      performActionNumItems(count, Ent.INBOUND_SSO_ASSIGNMENT)
    Ind.Increment()
    i = 0
    for assignment in assignments:
      i += 1
      _showInboundSSOAssignment(assignment, FJQC, ci, cd, i, count)
    Ind.Decrement()
  else:
    for assignment in assignments:
      _updateInboundAssignmentTargetNames(ci, cd, assignment)
      row = flattenJSON(assignment)
      if not FJQC.formatJSON:
        csvPF.WriteRowTitles(row)
      elif csvPF.CheckRowTitles(row):
        csvPF.WriteRowNoFilter({'name': assignment['name'],
                                'JSON': json.dumps(cleanJSON(assignment),
                                                   ensure_ascii=False, sort_keys=True)})
  if csvPF:
    csvPF.writeCSVfile('Inbound SSO Assignments')

SITEVERIFICATION_METHOD_CHOICE_MAP = {
  'cname': 'DNS_CNAME',
  'dnscname': 'DNS_CNAME',
  'dnstxt': 'DNS_TXT',
  'txt': 'DNS_TXT',
  'text': 'DNS_TXT',
  'file': 'FILE',
  'site': 'FILE',
  }

# gam create verify|verification <DomainName>
def doCreateSiteVerification():
  verif = buildGAPIObject(API.SITEVERIFICATION)
  a_domain = getString(Cmd.OB_DOMAIN_NAME)
  checkForExtraneousArguments()
  txt_record = callGAPI(verif.webResource(), 'getToken',
                        body={'site': {'type': 'INET_DOMAIN', 'identifier': a_domain},
                              'verificationMethod': 'DNS_TXT'})
  printKeyValueList(['TXT Record Name ', a_domain])
  printKeyValueList(['TXT Record Value', txt_record['token']])
  printBlankLine()
  cname_record = callGAPI(verif.webResource(), 'getToken',
                          body={'site': {'type': 'INET_DOMAIN', 'identifier': a_domain},
                                'verificationMethod': 'DNS_CNAME'})
  cname_token = cname_record['token']
  cname_list = cname_token.split(' ')
  cname_subdomain = cname_list[0]
  cname_value = cname_list[1]
  printKeyValueList(['CNAME Record Name ', f'{cname_subdomain}.{a_domain}'])
  printKeyValueList(['CNAME Record Value', cname_value])
  printBlankLine()
  webserver_file_record = callGAPI(verif.webResource(), 'getToken',
                                   body={'site': {'type': 'SITE', 'identifier': f'http://{a_domain}/'},
                                         'verificationMethod': 'FILE'})
  webserver_file_token = webserver_file_record['token']
  printKeyValueList(['Saving web server verification file to', webserver_file_token])
  writeFile(webserver_file_token, f'google-site-verification: {webserver_file_token}', continueOnError=True)
  printKeyValueList(['Verification File URL', f'http://{a_domain}/{webserver_file_token}'])
  printBlankLine()
  webserver_meta_record = callGAPI(verif.webResource(), 'getToken',
                                   body={'site': {'type': 'SITE', 'identifier': f'http://{a_domain}/'},
                                         'verificationMethod': 'META'})
  printKeyValueList(['Meta URL', f'//{a_domain}/'])
  printKeyValueList(['Meta HTML Header Data', webserver_meta_record['token']])
  printBlankLine()

def _showSiteVerificationInfo(site):
  printKeyValueList(['Site', site['site']['identifier']])
  Ind.Increment()
  printKeyValueList(['ID', unquote(site['id'])])
  printKeyValueList(['Type', site['site']['type']])
  printKeyValueList(['All Owners', None])
  if 'owners' in site:
    Ind.Increment()
    for owner in sorted(site['owners']):
      printKeyValueList([owner])
    Ind.Decrement()
  Ind.Decrement()

DNS_ERROR_CODES_MAP = {
  1: 'DNS Query Format Error',
  2: 'Server failed to complete the DNS request',
  3: 'Domain name does not exist',
  4: 'Function not implemented',
  5: 'The server refused to answer for the query',
  6: 'Name that should not exist, does exist',
  7: 'RRset that should not exist, does exist',
  8: 'Server not authoritative for the zone',
  9: 'Name not in zone'
  }

# gam update verify|verification <DomainName> cname|txt|text|file|site
def doUpdateSiteVerification():
  def showDNSrecords():
    try:
      verify_data = callGAPI(verif.webResource(), 'getToken',
                             throwReasons=[GAPI.BAD_REQUEST, GAPI.INVALID_PARAMETER],
                             body=body)
    except (GAPI.badRequest, GAPI.invalidParameter) as e:
      printKeyValueList([ERROR, str(e)])
      return
    printKeyValueList(['Method', verify_data['method']])
    if verify_data['method'] in {'DNS_CNAME', 'DNS_TXT'}:
      if verify_data['method'] == 'DNS_CNAME':
        cname_subdomain, cname_target = verify_data['token'].split(' ')
        query_params = {'name': f'{cname_subdomain}.{a_domain}', 'type': 'cname'}
        printKeyValueList(['Expected Record',
                           f'{query_params["name"]} IN CNAME {cname_target}'])
      else:
        query_params = {'name': f'{a_domain}', 'type': 'txt'}
        printKeyValueList(['Expected Record',
                           f'{query_params["name"]} IN TXT {verify_data["token"]}'])
      _, content = getHttpObj().request('https://dns.google/resolve?' + urlencode(query_params), 'GET')
      try:
        result = json.loads(content.decode(UTF8))
        status = result['Status']
        if status == 0 and 'Answer' in result:
          if verify_data['method'] == 'DNS_CNAME':
            printKeyValueList(['DNS      Record',
                               f'{result["Answer"][0]["name"].rstrip(".")} IN CNAME {result["Answer"][0]["data"]}'])
          else:
            found = False
            for answer in result['Answer']:
              answer['data'] = answer['data'].strip('"')
              if answer['data'].startswith('google-site-verification'):
                found = True
                printKeyValueList(['DNS      Record',
                                   f'{answer["name"].rstrip(".")} IN TXT {answer["data"]}'])
            if not found:
              printKeyValueList(['DNS      Record', 'No matching record found'])
        elif status == 0:
          systemErrorExit(NETWORK_ERROR_RC, Msg.DOMAIN_NOT_FOUND_IN_DNS)
        else:
          systemErrorExit(NETWORK_ERROR_RC, DNS_ERROR_CODES_MAP.get(status, f'Unknown error {status}'))
      except (IndexError, KeyError, SyntaxError, TypeError, ValueError):
        systemErrorExit(INVALID_JSON_RC, Msg.INVALID_JSON_INFORMATION)

  verif = buildGAPIObject(API.SITEVERIFICATION)
  a_domain = getString(Cmd.OB_DOMAIN_NAME)
  verificationMethod = getChoice(SITEVERIFICATION_METHOD_CHOICE_MAP, mapChoice=True)
  if verificationMethod in {'DNS_TXT', 'DNS_CNAME'}:
    verify_type = 'INET_DOMAIN'
    identifier = a_domain
    showDNS = True
  else:
    verify_type = 'SITE'
    identifier = f'http://{a_domain}/'
    showDNS = False
  checkForExtraneousArguments()
  body = {'site': {'type': verify_type, 'identifier': identifier},
          'verificationMethod': verificationMethod}
  try:
    verify_result = callGAPI(verif.webResource(), 'insert',
                             throwReasons=[GAPI.BAD_REQUEST, GAPI.INVALID_PARAMETER],
                             verificationMethod=verificationMethod, body=body)
  except GAPI.badRequest as e:
    printKeyValueList([ERROR, str(e)])
    if showDNS:
      showDNSrecords()
    return
  except GAPI.invalidParameter as e:
    printKeyValueList([ERROR, str(e)])
    return
  printKeyValueList(['Verified!'])
  if showDNS:
    showDNSrecords()
  _showSiteVerificationInfo(verify_result)
  printKeyValueList([Msg.YOU_CAN_ADD_DOMAIN_TO_ACCOUNT.format(a_domain, GC.Values[GC.DOMAIN])])

# gam info verify|verification
def doInfoSiteVerification():
  verif = buildGAPIObject(API.SITEVERIFICATION)
  checkForExtraneousArguments()
  sites = callGAPIitems(verif.webResource(), 'list', 'items')
  if sites:
    for site in sorted(sites, key=lambda k: (k['site']['type'], k['site']['identifier'])):
      _showSiteVerificationInfo(site)
  else:
    printKeyValueList(['No Sites Verified.'])

def checkCourseExists(croom, courseId, i=0, count=0, entityType=Ent.COURSE):
  courseId = addCourseIdScope(courseId)
  try:
    result = callGAPI(croom.courses(), 'get',
                      throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                      id=courseId, fields='id,ownerId')
    return result
  except GAPI.notFound:
    entityActionFailedWarning([entityType, removeCourseIdScope(courseId)], Msg.DOES_NOT_EXIST, i, count)
  except (GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning([Ent.COURSE, removeCourseIdScope(courseId)], str(e), i, count)
  return None

COURSE_MEMBER_ARGUMENTS = ['none', 'all', 'students', 'teachers']
COURSE_STATE_MAPS = {
  Cmd.OB_COURSE_STATE_LIST: {
    'active': 'ACTIVE',
    'archived': 'ARCHIVED',
    'provisioned': 'PROVISIONED',
    'declined': 'DECLINED',
    'suspended': 'SUSPENDED',
    },
  Cmd.OB_COURSE_ANNOUNCEMENT_STATE_LIST: {
    'draft': 'DRAFT',
    'published': 'PUBLISHED',
    'deleted': 'DELETED',
    },
  Cmd.OB_COURSE_WORK_STATE_LIST: {
    'draft': 'DRAFT',
    'published': 'PUBLISHED',
    'deleted': 'DELETED',
    },
  Cmd.OB_COURSE_MATERIAL_STATE_LIST: {
    'draft': 'DRAFT',
    'published': 'PUBLISHED',
    'deleted': 'DELETED',
    },
  Cmd.OB_COURSE_SUBMISSION_STATE_LIST: {
    'new': 'NEW',
    'created': 'CREATED',
    'turnedin': 'TURNED_IN',
    'returned': 'RETURNED',
    'reclaimedbystudent': 'RECLAIMED_BY_STUDENT',
    },
  }

def _getCourseStates(item, states):
  stateMap = COURSE_STATE_MAPS[item]
  for state in getString(item).lower().replace(',', ' ').split():
    if state == 'all':
      states.extend([stateMap[state] for state in stateMap])
    elif state in stateMap:
      states.append(stateMap[state])
    else:
      invalidChoiceExit(state, stateMap, True)

def _gettingCourseEntityQuery(entityType, courseWorkStates):
  query = ''
  if courseWorkStates:
    query += f'{Ent.Choose(entityType, len(courseWorkStates))}: {",".join(courseWorkStates)}, '
  if query:
    query = query[:-2]
  return query

class CourseAttributes():

  def __init__(self, croom, updateMode):
    self.croom = croom
    self.ocroom = croom
    self.tcroom = None
    self.updateMode = updateMode
    self.body = {}
    self.courseId = None
    self.ownerId = None
    self.markDraftAsPublished = False
    self.markPublishedAsDraft = False
    self.removeDueDate = False
    self.mapShareModeStudentCopy = None
    self.copyMaterialsFiles = False
    self.members = 'none'
    self.teachers = []
    self.students = []
    self.announcementStates = []
    self.courseAnnouncements = []
    self.materialStates = []
    self.courseMaterials = []
    self.workStates = []
    self.courseWorks = []
    self.individualStudentAnnouncements = 'copy'
    self.individualStudentMaterials = 'copy'
    self.individualStudentCourseWork = 'copy'
    self.copyTopics = False
    self.topicsById = {}
    self.reversedTopicIdList = []
    self.currDateTime = None
    self.csvPF = None

  COURSE_ANNOUNCEMENT_READONLY_FIELDS = [
    'alternateLink',
    'courseId',
    'creationTime',
    'creatorUserId',
    'updateTime',
    ]
  COURSE_MATERIAL_READONLY_FIELDS = [
    'alternateLink',
    'courseId',
    'creationTime',
    'creatorUserId',
    'updateTime',
    ]

  COURSE_COURSEWORK_READONLY_FIELDS = [
    'alternateLink',
    'assignment',
    'associatedWithDeveloper',
    'courseId',
    'creationTime',
    'creatorUserId',
    'updateTime',
    ]

  MAX_TITLE_DISPLAY_LENGTH = 34

  def trimTitle(self, title):
    if len(title) <= self.MAX_TITLE_DISPLAY_LENGTH:
      return title
    return title[:self.MAX_TITLE_DISPLAY_LENGTH]+'...'

  def CleanMaterials(self, body, entityType, entityId):
    if 'materials' not in body:
      return
    materials = body.pop('materials')
    body['materials'] = []
    for material in materials:
      if 'driveFile' in material:
        material['driveFile']['driveFile'].pop('title', None)
        material['driveFile']['driveFile'].pop('alternateLink', None)
        material['driveFile']['driveFile'].pop('thumbnailUrl', None)
        if material['driveFile'].get('shareMode', '') == 'STUDENT_COPY' and self.mapShareModeStudentCopy is not None:
          material['driveFile']['shareMode'] = self.mapShareModeStudentCopy
        body['materials'].append(material)
        if self.csvPF and not self.copyMaterialsFiles:
          self.csvPF.WriteRow({'courseId': self.courseId, 'ownerId': self.ownerId, 'fileId': material['driveFile']['driveFile']['id']})
      elif 'youtubeVideo' in material:
        material['youtubeVideo'].pop('title', None)
        material['youtubeVideo'].pop('alternateLink', None)
        material['youtubeVideo'].pop('thumbnailUrl', None)
        body['materials'].append(material)
      elif 'link' in material:
        material['link'].pop('title', None)
        material['link'].pop('thumbnailUrl', None)
        body['materials'].append(material)
      elif 'form' in material:
        action = Act.Get()
        Act.Set(Act.COPY)
        entityActionNotPerformedWarning([Ent.COURSE, self.courseId, entityType, entityId,
                                         Ent.COURSE_MATERIAL_FORM, self.trimTitle(material['form'].get('title', UNKNOWN))],
                                        Msg.NOT_COPYABLE)
        Act.Set(action)

  @staticmethod
  def CleanAssignments(body):
    if 'assignment' in body and 'studentWorkFolder' in body['assignment']:
      body['assignment']['studentWorkFolder'].pop('title', None)
      body['assignment']['studentWorkFolder'].pop('alternateLink', None)

  COURSE_MATERIAL_SHAREMODE_MAP = {
    'edit': 'EDIT',
    'none': None,
    'view': 'VIEW'
    }

  COURSE_INDIVIDUAL_STUDENT_OPTIONS = {'copy', 'delete', 'maptoall'}

  def GetAttributes(self):
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if not self.updateMode and myarg in {'alias', 'id'}:
        self.body['id'] = getCourseAlias()
      elif myarg == 'name':
        self.body['name'] = getString(Cmd.OB_STRING)
      elif myarg == 'section':
        self.body['section'] = getString(Cmd.OB_STRING, minLen=0)
      elif myarg in {'heading', 'descriptionheading'}:
        self.body['descriptionHeading'] = getString(Cmd.OB_STRING, minLen=0)
      elif myarg == 'description':
        self.body['description'] = getStringWithCRsNLs()
      elif myarg == 'room':
        self.body['room'] = getString(Cmd.OB_STRING, minLen=0)
      elif myarg in {'owner', 'ownerid', 'teacher'}:
        self.body['ownerId'] = getEmailAddress()
      elif myarg in {'state', 'status', 'coursestate'}:
        self.body['courseState'] = getChoice(COURSE_STATE_MAPS[Cmd.OB_COURSE_STATE_LIST], mapChoice=True)
      elif myarg == 'guardiansenabled':
        self.body['guardiansEnabled'] = getBoolean()
      elif myarg == 'copyfrom':
        self.courseId = getString(Cmd.OB_COURSE_ID)
      elif myarg in {'announcementstate', 'announcementstates'}:
        _getCourseStates(Cmd.OB_COURSE_ANNOUNCEMENT_STATE_LIST, self.announcementStates)
      elif myarg in {'workstate', 'workstates', 'courseworkstate', 'courseworkstates'}:
        _getCourseStates(Cmd.OB_COURSE_WORK_STATE_LIST, self.workStates)
      elif myarg in {'materialstate', 'materialstates', 'coursematerialstate', 'coursematerialstates'}:
        _getCourseStates(Cmd.OB_COURSE_MATERIAL_STATE_LIST, self.materialStates)
      elif myarg == 'individualstudentannouncements':
        self.individualStudentAnnouncements = getChoice(self.COURSE_INDIVIDUAL_STUDENT_OPTIONS)
      elif myarg == 'individualstudentmaterials':
        self.individualStudentMaterials = getChoice(self.COURSE_INDIVIDUAL_STUDENT_OPTIONS)
      elif myarg == 'individualstudentcoursework':
        self.individualStudentCourseWork = getChoice(self.COURSE_INDIVIDUAL_STUDENT_OPTIONS)
      elif myarg == 'individualstudentassignments':
        self.individualStudentAnnouncements = self.individualStudentMaterials = self.individualStudentCourseWork =\
          getChoice(self.COURSE_INDIVIDUAL_STUDENT_OPTIONS)
      elif myarg == 'members':
        self.members = getChoice(COURSE_MEMBER_ARGUMENTS)
      elif myarg == 'markdraftaspublished':
        self.markDraftAsPublished = getBoolean()
      elif myarg == 'markpublishedasdraft':
        self.markPublishedAsDraft = getBoolean()
      elif myarg == 'removeduedate':
        self.removeDueDate = getBoolean()
      elif myarg == 'mapsharemodestudentcopy':
        self.mapShareModeStudentCopy = getChoice(self.COURSE_MATERIAL_SHAREMODE_MAP, mapChoice=True)
      elif myarg == 'copymaterialsfiles':
        self.copyMaterialsFiles = getBoolean()
      elif myarg == 'copytopics':
        self.copyTopics = getBoolean()
      elif myarg == 'logdrivefileids':
        if getBoolean():
          self.csvPF = CSVPrintFile(['courseId', 'ownerId', 'fileId'])
        else:
          self.csvPF = None
      else:
        unknownArgumentExit()
    if not self.updateMode:
      if 'ownerId' not in self.body:
        missingArgumentExit('teacher <UserItem>')
      if 'name' not in self.body:
        missingArgumentExit('name <String>')
    if self.courseId:
      copyFromCourseInfo = checkCourseExists(self.croom, self.courseId, entityType=Ent.COPYFROM_COURSE)
      if copyFromCourseInfo is None:
        return False
      self.ownerId = copyFromCourseInfo['ownerId']
      if (self.announcementStates or self.materialStates or self.workStates) and self.copyMaterialsFiles:
        self.body['courseState'] = 'ACTIVE'
    elif self.members != 'none' or self.announcementStates or self.materialStates or self.workStates or self.copyTopics:
      missingArgumentExit('copyfrom <CourseID>')
    else:
      return True
# ocroom - copyfrom course owner
    self.ocroom = self.croom
    if GC.Values[GC.USE_COURSE_OWNER_ACCESS]:
      if self.announcementStates or self.materialStates or self.workStates or self.copyTopics or self.members != 'none':
        _, self.ocroom = buildGAPIServiceObject(API.CLASSROOM, f'uid:{self.ownerId}')
        if self.ocroom is None:
          return False
    if self.members != 'none':
      _, self.teachers, self.students = _getCourseAliasesMembers(self.croom, self.ocroom, self.courseId, {'members': self.members},
                                                                 'nextPageToken,teachers(profile(emailAddress,id))',
                                                                 'nextPageToken,students(profile(emailAddress))')
    if self.announcementStates:
      printGettingAllEntityItemsForWhom(Ent.COURSE_ANNOUNCEMENT_ID, Ent.TypeName(Ent.COURSE, self.courseId), 0, 0,
                                        _gettingCourseEntityQuery(Ent.COURSE_ANNOUNCEMENT_STATE, self.announcementStates))
      try:
        self.courseAnnouncements = callGAPIpages(self.ocroom.courses().announcements(), 'list', 'announcements',
                                                 pageMessage=getPageMessageForWhom(),
                                                 throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE],
                                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                                 courseId=self.courseId, announcementStates=self.announcementStates,
                                                 pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS])
        for courseAnnouncement in self.courseAnnouncements:
          for field in self.COURSE_ANNOUNCEMENT_READONLY_FIELDS:
            courseAnnouncement.pop(field, None)
          self.CleanMaterials(courseAnnouncement, Ent.COURSE_ANNOUNCEMENT_ID, courseAnnouncement['id'])
      except (GAPI.notFound, GAPI.insufficientPermissions, GAPI.permissionDenied, GAPI.forbidden, GAPI.invalidArgument, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.COURSE, self.courseId], str(e))
        return False
    if self.materialStates:
      printGettingAllEntityItemsForWhom(Ent.COURSE_MATERIAL_ID, Ent.TypeName(Ent.COURSE, self.courseId), 0, 0,
                                        _gettingCourseEntityQuery(Ent.COURSE_MATERIAL_STATE, self.materialStates))
      try:
        self.courseMaterials = callGAPIpages(self.ocroom.courses().courseWorkMaterials(), 'list', 'courseWorkMaterial',
                                             pageMessage=getPageMessageForWhom(),
                                             throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE],
                                             retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                             courseId=self.courseId, courseWorkMaterialStates=self.materialStates,
                                             pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS])
        for courseMaterial in self.courseMaterials:
          for field in self.COURSE_MATERIAL_READONLY_FIELDS:
            courseMaterial.pop(field, None)
          if self.markPublishedAsDraft and courseMaterial['state'] == 'PUBLISHED':
            courseMaterial['state'] = 'DRAFT'
          elif self.markDraftAsPublished and courseMaterial['state'] == 'DRAFT':
            courseMaterial['state'] = 'PUBLISHED'
          self.CleanMaterials(courseMaterial, Ent.COURSE_MATERIAL_ID, courseMaterial['id'])
      except (GAPI.notFound, GAPI.insufficientPermissions, GAPI.permissionDenied, GAPI.forbidden, GAPI.invalidArgument, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.COURSE, self.courseId], str(e))
        return False
    if self.workStates:
      printGettingAllEntityItemsForWhom(Ent.COURSE_WORK_ID, Ent.TypeName(Ent.COURSE, self.courseId), 0, 0,
                                        _gettingCourseEntityQuery(Ent.COURSE_WORK_STATE, self.workStates))
      try:
        self.courseWorks = callGAPIpages(self.ocroom.courses().courseWork(), 'list', 'courseWork',
                                         pageMessage=getPageMessageForWhom(),
                                         throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE],
                                         retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                         courseId=self.courseId, courseWorkStates=self.workStates,
                                         pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS])
        for courseWork in self.courseWorks:
          for field in self.COURSE_COURSEWORK_READONLY_FIELDS:
            courseWork.pop(field, None)
          self.CleanMaterials(courseWork, Ent.COURSE_WORK_ID, courseWork['id'])
          self.CleanAssignments(courseWork)
          if self.markPublishedAsDraft and courseWork['state'] == 'PUBLISHED':
            courseWork['state'] = 'DRAFT'
          elif self.markDraftAsPublished and courseWork['state'] == 'DRAFT':
            courseWork['state'] = 'PUBLISHED'
          if self.removeDueDate:
            courseWork.pop('dueDate', None)
            courseWork.pop('dueTime', None)
      except (GAPI.notFound, GAPI.insufficientPermissions, GAPI.permissionDenied, GAPI.forbidden, GAPI.invalidArgument, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.COURSE, self.courseId], str(e))
        return False
    if self.copyTopics:
      printGettingAllEntityItemsForWhom(Ent.COURSE_TOPIC, Ent.TypeName(Ent.COURSE, self.courseId), 0, 0)
      try:
        courseTopics = callGAPIpages(self.ocroom.courses().topics(), 'list', 'topic',
                                     pageMessage=getPageMessageForWhom(),
                                     throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE],
                                     retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                     courseId=self.courseId, fields='nextPageToken,topic(topicId,name)',
                                     pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS])
        for topic in courseTopics:
          self.topicsById[topic['topicId']] = topic['name']
          self.reversedTopicIdList.append(topic['topicId'])
        self.reversedTopicIdList.reverse()
      except (GAPI.notFound, GAPI.insufficientPermissions, GAPI.permissionDenied, GAPI.forbidden, GAPI.invalidArgument, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.COURSE, self.courseId], str(e))
        return False
    return True

  def CopyMaterials(self, drive, newCourseId, body, entityType, entityId, teacherFolderId):
    def _copyMaterialsError(fileId, errMsg):
      entityModifierItemValueListActionNotPerformedWarning([Ent.COURSE, newCourseId, entityType, entityId, Ent.COURSE_MATERIAL_DRIVEFILE, ''], Act.MODIFIER_FROM,
                                                           [Ent.COURSE, self.courseId, Ent.COURSE_MATERIAL_DRIVEFILE, fileId], errMsg)

    if 'materials' not in body:
      return
    action = Act.Get()
    Act.Set(Act.COPY)
    materials = body.pop('materials')
    body['materials'] = []
    for material in materials:
      if 'driveFile' in material:
        fileId = material['driveFile']['driveFile']['id']
        try:
          source = callGAPI(drive.files(), 'get',
                            throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                            fileId=fileId,
                            fields='name,appProperties,capabilities,contentHints,copyRequiresWriterPermission,'\
                              'description,mimeType,modifiedTime,properties,starred,driveId,viewedByMeTime,writersCanShare',
                            supportsAllDrives=True)
          if not source.pop('capabilities')['canCopy']:
            _copyMaterialsError(fileId, Msg.NOT_COPYABLE)
            continue
          source['parents'] = [teacherFolderId]
          result = callGAPI(drive.files(), 'copy',
                            throwReasons=GAPI.DRIVE_COPY_THROW_REASONS,
                            fileId=fileId, body=source, fields='id', supportsAllDrives=True)
          material['driveFile']['driveFile']['id'] = result['id']
          body['materials'].append(material)
          entityModifierItemValueListActionPerformed([Ent.COURSE, newCourseId, entityType, entityId, Ent.COURSE_MATERIAL_DRIVEFILE, result['id']], Act.MODIFIER_FROM,
                                                     [Ent.COURSE, self.courseId, Ent.COURSE_MATERIAL_DRIVEFILE, fileId])
          if self.csvPF:
            self.csvPF.WriteRow({'courseId': self.courseId, 'ownerId': self.ownerId, 'fileId': result['id']})
        except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
                GAPI.invalid, GAPI.cannotCopyFile, GAPI.badRequest, GAPI.responsePreparationFailure, GAPI.fileNeverWritable, GAPI.fieldNotWritable,
                GAPI.teamDrivesSharingRestrictionNotAllowed, GAPI.rateLimitExceeded, GAPI.userRateLimitExceeded) as e:
          _copyMaterialsError(fileId, str(e))
        except (GAPI.storageQuotaExceeded, GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep) as e:
          _copyMaterialsError(fileId, str(e))
          break
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          _copyMaterialsError(fileId, str(e))
          break
      else:
        body['materials'].append(material)
    Act.Set(action)

  def checkDueDate(self, body):
    if 'dueDate' in body and 'dueTime' in body:
      try:
        return self.currDateTime < datetime.datetime(body['dueDate']['year'], body['dueDate']['month'], body['dueDate']['day'],
                                                     body['dueTime'].get('hours', 0), body['dueTime'].get('minutes', 0), tzinfo=iso8601.UTC)
      except ValueError:
        pass
    return False

  def getItemIdTitle(self, body):
    itemId = body.pop('id')
    return self.trimTitle(body.get('title', itemId))

  def checkItemCopyable(self, state, newCourseId, entityType, entityId, body, individualStudentOption, clarg, j, jcount):
    if state == 'DELETED':
      entityModifierItemValueListActionNotPerformedWarning([Ent.COURSE, newCourseId, entityType, entityId], Act.MODIFIER_FROM,
                                                           [Ent.COURSE, self.courseId], Msg.DELETED, j, jcount)
      return False
    if body['assigneeMode'] == 'INDIVIDUAL_STUDENTS':
      if individualStudentOption == 'delete':
        entityModifierItemValueListActionNotPerformedWarning([Ent.COURSE, newCourseId, entityType, entityId], Act.MODIFIER_FROM,
                                                             [Ent.COURSE, self.courseId], f'{clarg} delete', j, jcount)
        return False
      if individualStudentOption == 'maptoall':
        body['assigneeMode'] = 'ALL_STUDENTS'
        body.pop('individualStudentsOptions', None)
      else: # individualStudentOption == 'copy':
        if 'individualStudentsOptions' not in body:
          body['assigneeMode'] = 'ALL_STUDENTS'
    return True

  def CopyAttributes(self, newCourse, i=0, count=0):
    newCourseId = newCourse['id']
    ownerId = newCourse['ownerId']
    teacherFolderId = newCourse['teacherFolder']['id']
# tcroom - new/update course owner
    if self.announcementStates or self.materialStates or self.workStates or self.copyTopics:
      _, self.tcroom = buildGAPIServiceObject(API.CLASSROOM, f'uid:{ownerId}')
      if self.tcroom is None:
        return
    if (self.announcementStates or self.materialStates or self.workStates) and self.copyMaterialsFiles:
      _, tdrive = buildGAPIServiceObject(API.DRIVE3, f'uid:{ownerId}')
      if tdrive is None:
        return
# Adds are done with domain admin
    if self.members in {'all', 'students'}:
      addParticipants = [student['profile']['emailAddress'] for student in self.students if 'emailAddress' in student['profile']]
      _batchAddItemsToCourse(self.croom, newCourseId, i, count, addParticipants, Ent.STUDENT)
    if self.members in {'all', 'teachers'}:
      addParticipants = [teacher['profile']['emailAddress'] for teacher in self.teachers if teacher['profile']['id'] != ownerId and 'emailAddress' in teacher['profile']]
      _batchAddItemsToCourse(self.croom, newCourseId, i, count, addParticipants, Ent.TEACHER)
    if self.copyTopics:
      try:
        newCourseTopics = callGAPIpages(self.tcroom.courses().topics(), 'list', 'topic',
                                        throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.FAILED_PRECONDITION, GAPI.SERVICE_NOT_AVAILABLE],
                                        retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                        courseId=newCourseId, fields='nextPageToken,topic(topicId,name)',
                                        pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS])
        newTopicsByName = {}
        for topic in newCourseTopics:
          newTopicsByName[topic['name']] = topic['topicId']
      except GAPI.notFound as e:
        entityActionFailedWarning([Ent.COURSE, newCourseId], str(e), i, count)
        return
      except (GAPI.insufficientPermissions, GAPI.permissionDenied, GAPI.forbidden, GAPI.failedPrecondition, GAPI.invalidArgument, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.COURSE, newCourseId], str(e), i, count)
      jcount = len(self.topicsById)
      j = 0
      for topicId in self.reversedTopicIdList:
        topicName = self.topicsById[topicId]
        j += 1
        if topicName in newTopicsByName:
          entityModifierItemValueListActionNotPerformedWarning([Ent.COURSE, newCourseId, Ent.COURSE_TOPIC, topicName], Act.MODIFIER_FROM,
                                                               [Ent.COURSE, self.courseId], Msg.DUPLICATE, j, jcount)
          continue
        try:
          result = callGAPI(self.tcroom.courses().topics(), 'create',
                            throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.FAILED_PRECONDITION, GAPI.INVALID_ARGUMENT, GAPI.SERVICE_NOT_AVAILABLE],
                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                            courseId=newCourseId, body={'name': topicName}, fields='topicId')
          newTopicsByName[topicName] = result['topicId']
          entityModifierItemValueListActionPerformed([Ent.COURSE, newCourseId, Ent.COURSE_TOPIC, topicName], Act.MODIFIER_FROM,
                                                     [Ent.COURSE, self.courseId], j, jcount)
        except GAPI.notFound as e:
          entityActionFailedWarning([Ent.COURSE, newCourseId], str(e), i, count)
          return
        except (GAPI.failedPrecondition, GAPI.invalidArgument, GAPI.forbidden, GAPI.serviceNotAvailable) as e:
          entityModifierItemValueListActionFailedWarning([Ent.COURSE, newCourseId], Act.MODIFIER_FROM,
                                                         [Ent.COURSE, self.courseId, Ent.COURSE_TOPIC, topicName], str(e), j, jcount)
    if self.courseAnnouncements:
      jcount = len(self.courseAnnouncements)
      j = 0
      for courseAnnouncement in self.courseAnnouncements:
        j += 1
        body = courseAnnouncement.copy()
        courseAnnouncementId = self.getItemIdTitle(body)
        if not self.checkItemCopyable(courseAnnouncement['state'], newCourseId, Ent.COURSE_ANNOUNCEMENT, courseAnnouncementId,
                                      body, self.individualStudentAnnouncements, 'individualstudentannouncements', j, jcount):
          continue
        if self.copyMaterialsFiles:
          self.CopyMaterials(tdrive, newCourseId, body, Ent.COURSE_ANNOUNCEMENT, courseAnnouncementId, teacherFolderId)
        try:
          result = callGAPI(self.tcroom.courses().announcements(), 'create',
                            throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.FORBIDDEN,
                                          GAPI.BAD_REQUEST, GAPI.FAILED_PRECONDITION, GAPI.BACKEND_ERROR, GAPI.INTERNAL_ERROR, GAPI.SERVICE_NOT_AVAILABLE],
                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                            courseId=newCourseId, body=body, fields='id')
          entityModifierItemValueListActionPerformed([Ent.COURSE, newCourseId, Ent.COURSE_ANNOUNCEMENT_ID, result['id']], Act.MODIFIER_FROM,
                                                     [Ent.COURSE, self.courseId, Ent.COURSE_ANNOUNCEMENT, courseAnnouncementId], j, jcount)
        except GAPI.notFound as e:
          entityActionFailedWarning([Ent.COURSE, newCourseId], str(e), i, count)
          return
        except (GAPI.badRequest, GAPI.failedPrecondition, GAPI.backendError, GAPI.internalError,
                GAPI.permissionDenied, GAPI.forbidden, GAPI.serviceNotAvailable) as e:
          entityModifierItemValueListActionFailedWarning([Ent.COURSE, newCourseId], Act.MODIFIER_FROM,
                                                         [Ent.COURSE, self.courseId, Ent.COURSE_ANNOUNCEMENT, courseAnnouncementId], str(e), j, jcount)
    if self.courseMaterials:
      jcount = len(self.courseMaterials)
      j = 0
      for courseMaterial in self.courseMaterials:
        j += 1
        body = courseMaterial.copy()
        courseMaterialId = self.getItemIdTitle(body)
        if not self.checkItemCopyable(courseMaterial['state'], newCourseId, Ent.COURSE_MATERIAL, courseMaterialId,
                                      body, self.individualStudentMaterials, 'individualstudentmaterials', j, jcount):
          continue
        if self.copyMaterialsFiles:
          self.CopyMaterials(tdrive, newCourseId, body, Ent.COURSE_MATERIAL, courseMaterialId, teacherFolderId)
        topicId = body.pop('topicId', None)
        if self.copyTopics:
          if topicId:
            topicName = self.topicsById.get(topicId)
            if topicName:
              newTopicId = newTopicsByName.get(topicName)
              if newTopicId:
                body['topicId'] = newTopicId
        try:
          result = callGAPI(self.tcroom.courses().courseWorkMaterials(), 'create',
                            throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.FORBIDDEN,
                                          GAPI.BAD_REQUEST, GAPI.FAILED_PRECONDITION, GAPI.BACKEND_ERROR, GAPI.INTERNAL_ERROR, GAPI.SERVICE_NOT_AVAILABLE],
                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                            courseId=newCourseId, body=body, fields='id')
          entityModifierItemValueListActionPerformed([Ent.COURSE, newCourseId, Ent.COURSE_MATERIAL_ID, result['id']], Act.MODIFIER_FROM,
                                                     [Ent.COURSE, self.courseId, Ent.COURSE_MATERIAL, courseMaterialId], j, jcount)
        except GAPI.notFound as e:
          entityActionFailedWarning([Ent.COURSE, newCourseId], str(e), i, count)
          return
        except (GAPI.badRequest, GAPI.failedPrecondition, GAPI.backendError, GAPI.internalError,
                GAPI.permissionDenied, GAPI.forbidden, GAPI.serviceNotAvailable) as e:
          entityModifierItemValueListActionFailedWarning([Ent.COURSE, newCourseId], Act.MODIFIER_FROM,
                                                         [Ent.COURSE, self.courseId, Ent.COURSE_MATERIAL, courseMaterialId], str(e), j, jcount)
    if self.courseWorks:
      jcount = len(self.courseWorks)
      j = 0
      for courseWork in self.courseWorks:
        j += 1
        body = courseWork.copy()
        courseWorkId = self.getItemIdTitle(body)
        if not self.checkItemCopyable(courseWork['state'], newCourseId, Ent.COURSE_WORK, courseWorkId,
                                      body, self.individualStudentCourseWork, 'individualstudentcoursework', j, jcount):
          continue
        if self.copyMaterialsFiles:
          self.CopyMaterials(tdrive, newCourseId, body, Ent.COURSE_WORK, courseWorkId, teacherFolderId)
        topicId = body.pop('topicId', None)
        if self.copyTopics:
          if topicId:
            topicName = self.topicsById.get(topicId)
            if topicName:
              newTopicId = newTopicsByName.get(topicName)
              if newTopicId:
                body['topicId'] = newTopicId
        if not self.removeDueDate and not self.checkDueDate(body):
          body.pop('dueDate', None)
          body.pop('dueTime', None)
        try:
          result = callGAPI(self.tcroom.courses().courseWork(), 'create',
                            bailOnInternalError=True,
                            throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.FORBIDDEN,
                                          GAPI.BAD_REQUEST, GAPI.FAILED_PRECONDITION, GAPI.BACKEND_ERROR,
                                          GAPI.INTERNAL_ERROR, GAPI.INVALID_ARGUMENT, GAPI.SERVICE_NOT_AVAILABLE],
                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                            courseId=newCourseId, body=body, fields='id')
          entityModifierItemValueListActionPerformed([Ent.COURSE, newCourseId, Ent.COURSE_WORK_ID, result['id']], Act.MODIFIER_FROM,
                                                     [Ent.COURSE, self.courseId, Ent.COURSE_WORK, courseWorkId], j, jcount)
        except GAPI.notFound as e:
          entityActionFailedWarning([Ent.COURSE, newCourseId], str(e), i, count)
          return
        except (GAPI.badRequest, GAPI.failedPrecondition, GAPI.backendError, GAPI.internalError, GAPI.invalidArgument,
                GAPI.permissionDenied, GAPI.forbidden, GAPI.serviceNotAvailable) as e:
          entityModifierItemValueListActionFailedWarning([Ent.COURSE, newCourseId], Act.MODIFIER_FROM,
                                                         [Ent.COURSE, self.courseId, Ent.COURSE_WORK, courseWorkId], str(e), j, jcount)

  def CopyFromCourse(self, newCourse, i=0, count=0):
    action = Act.Get()
    Act.Set(Act.COPY)
    entityPerformActionModifierItemValueList([Ent.COURSE, newCourse['id']], Act.MODIFIER_FROM, [Ent.COURSE, self.courseId], i, count)
    Ind.Increment()
    if not self.removeDueDate:
      self.currDateTime = datetime.datetime.now(iso8601.UTC)
    self.CopyAttributes(newCourse, i, count)
    if self.csvPF:
      self.csvPF.writeCSVfile('Course Drive File IDs')
    Ind.Decrement()
    Act.Set(action)

# gam create course [id|alias <CourseAlias>] <CourseAttribute>*
#	 [copyfrom <CourseID>
#	    [announcementstates <CourseAnnouncementStateList>]
#		[individualstudentannouncements copy|delete|maptoall]
#	    [materialstates <CourseMaterialStateList>]
#		[individualstudentmaterials copy|delete|maptoall]
#	    [workstates <CourseWorkStateList>]
#		[individualstudentcoursework copy|delete|maptoall]
#		[removeduedate [<Boolean>]]
#		[mapsharemodestudentcopy edit|none|view]
#	    [individualstudentassignments copy|delete|maptoall]
#           [copymaterialsfiles [<Boolean>]]
#	    [copytopics [<Boolean>]]
#	    [markpublishedasdraft [<Boolean>]] [markdraftaspublished [<Boolean>]]
#	    [members none|all|students|teachers]]
#	    [logdrivefileids [<Boolean>]]
def doCreateCourse():
  croom = buildGAPIObject(API.CLASSROOM)
  courseAttributes = CourseAttributes(croom, False)
  if not courseAttributes.GetAttributes():
    return
  try:
    result = callGAPI(croom.courses(), 'create',
                      throwReasons=[GAPI.ALREADY_EXISTS, GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED,
                                    GAPI.FAILED_PRECONDITION, GAPI.FORBIDDEN, GAPI.BAD_REQUEST, GAPI.SERVICE_NOT_AVAILABLE],
                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                      body=courseAttributes.body, fields='id,name,ownerId,courseState,teacherFolder(id)')
    entityActionPerformed([Ent.COURSE_NAME, result['name'], Ent.COURSE, result['id']])
    if courseAttributes.courseId:
      courseAttributes.CopyFromCourse(result)
  except (GAPI.alreadyExists, GAPI.notFound, GAPI.permissionDenied, GAPI.failedPrecondition, GAPI.forbidden, GAPI.badRequest, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning([Ent.COURSE_NAME, courseAttributes.body['name'], Ent.TEACHER, courseAttributes.body['ownerId']], str(e))

def _doUpdateCourses(entityList):
  croom = buildGAPIObject(API.CLASSROOM)
  courseAttributes = CourseAttributes(croom, True)
  if not courseAttributes.GetAttributes():
    return
  i = 0
  count = len(entityList)
  for course in entityList:
    i += 1
    courseId = addCourseIdScope(course)
    body = courseAttributes.body.copy()
    newOwner = body.get('ownerId')
    modifier = Act.MODIFIER_WITH_COTEACHER_OWNER
    complete = False
    while not complete:
      complete = True
      try:
        if body:
          result = callGAPI(croom.courses(), 'patch',
                            throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION,
                                          GAPI.FORBIDDEN, GAPI.BAD_REQUEST, GAPI.INVALID_ARGUMENT,
                                          GAPI.INTERNAL_ERROR, GAPI.SERVICE_NOT_AVAILABLE],
                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                            id=courseId, body=body, updateMask=','.join(list(body)), fields='id,name,ownerId,courseState,teacherFolder(id)')
        else:
          result = callGAPI(croom.courses(), 'get',
                            throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                            id=courseId, fields='id,name,ownerId,courseState,teacherFolder(id)')
        if courseAttributes.body:
          if not newOwner:
            entityActionPerformed([Ent.COURSE_NAME, result['name'], Ent.COURSE, result['id']], i, count)
          else:
            entityModifierNewValueActionPerformed([Ent.COURSE_NAME, result['name'], Ent.COURSE, result['id']],
                                                  modifier, newOwner, i, count)
        if courseAttributes.courseId:
          courseAttributes.CopyFromCourse(result, i, count)
      except (GAPI.notFound, GAPI.permissionDenied,
              GAPI.forbidden, GAPI.badRequest, GAPI.invalidArgument,
              GAPI.internalError, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.COURSE, removeCourseIdScope(courseId)], str(e), i, count)
      except GAPI.failedPrecondition as e:
        errMsg = str(e)
        if newOwner and '@UserAlreadyOwner Cannot transfer course to the user who is already the owner' in errMsg:
## Handle trying to update current owner to owner, we're done if nothing else was being updated
          body.pop('ownerId')
          modifier = Act.MODIFIER_WITH_CURRENT_OWNER
          complete = False
        elif newOwner and '@IneligibleOwner Only a co-teacher can be invited as owner of the course' in errMsg:
## Add new owner as teacher, then go back and do update
          action = Act.Get()
          Act.Set(Act.ADD)
          try:
            callGAPI(croom.courses().teachers(), 'create',
                     throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.BACKEND_ERROR,
                                   GAPI.ALREADY_EXISTS, GAPI.FAILED_PRECONDITION,
                                   GAPI.QUOTA_EXCEEDED, GAPI.SERVICE_NOT_AVAILABLE],
                     retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                     courseId=courseId, body={'userId': newOwner}, fields='')
            modifier = Act.MODIFIER_WITH_NEW_TEACHER_OWNER
            time.sleep(10)
            complete = False
          except (GAPI.notFound, GAPI.backendError, GAPI.forbidden):
            entityActionFailedWarning([Ent.COURSE, removeCourseIdScope(courseId), Ent.TEACHER, newOwner], getPhraseDNEorSNA(newOwner), i, count)
          except GAPI.alreadyExists:
            entityActionFailedWarning([Ent.COURSE, removeCourseIdScope(courseId), Ent.TEACHER, newOwner], Msg.DUPLICATE, i, count)
          except GAPI.failedPrecondition:
            entityActionFailedWarning([Ent.COURSE, removeCourseIdScope(courseId), Ent.TEACHER, newOwner], Msg.NOT_ALLOWED, i, count)
          except (GAPI.quotaExceeded, GAPI.serviceNotAvailable) as ei:
            entityActionFailedWarning([Ent.COURSE, removeCourseIdScope(courseId), Ent.TEACHER, newOwner], str(ei), i, count)
          Act.Set(action)
        else:
          entityActionFailedWarning([Ent.COURSE, removeCourseIdScope(courseId)], str(e), i, count)

# gam update courses <CourseEntity> <CourseAttribute>+
#	 [copyfrom <CourseID>
#	    [announcementstates <CourseAnnouncementStateList>]
#		[individualstudentannouncements copy|delete|maptoall]
#	    [materialstates <CourseMaterialStateList>]
#		[individualstudentmaterials copy|delete|maptoall]
#	    [workstates <CourseWorkStateList>]
#		[individualstudentcoursework copy|delete|maptoall]
#		[removeduedate [<Boolean>]]
#		[mapsharemodestudentcopy edit|none|view]
#	    [individualstudentassignments copy|delete|maptoall]
#           [copymaterialsfiles [<Boolean>]]
#	    [copytopics [<Boolean>]]
#	    [markpublishedasdraft [<Boolean>]] [markdraftaspublished [<Boolean>]]
#	    [members none|all|students|teachers]]
#	    [logdrivefileids [<Boolean>]]
def doUpdateCourses():
  _doUpdateCourses(getEntityList(Cmd.OB_COURSE_ENTITY, shlexSplit=True))

# gam update course <CourseID> <CourseAttribute>+
#	 [copyfrom <CourseID>
#	    [announcementstates <CourseAnnouncementStateList>]
#		[individualstudentannouncements copy|delete|maptoall]
#	    [materialstates <CourseMaterialStateList>]
#		[individualstudentmaterials copy|delete|maptoall]
#	    [workstates <CourseWorkStateList>]
#		[individualstudentcoursework copy|delete|maptoall]
#		[removeduedate [<Boolean>]]
#		[mapsharemodestudentcopy edit|none|view]
#	    [individualstudentassignments copy|delete|maptoall]
#           [copymaterialsfiles [<Boolean>]]
#	    [copytopics [<Boolean>]]
#	    [markpublishedasdraft [<Boolean>]] [markdraftaspublished [<Boolean>]]
#	    [members none|all|students|teachers]]
def doUpdateCourse():
  _doUpdateCourses(getStringReturnInList(Cmd.OB_COURSE_ID))

def _doDeleteCourses(entityList):
  croom = buildGAPIObject(API.CLASSROOM)
  body = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'archive', 'archived'}:
      body['courseState'] = 'ARCHIVED'
      updateMask = 'courseState'
    else:
      unknownArgumentExit()
  i = 0
  count = len(entityList)
  for course in entityList:
    i += 1
    courseId = addCourseIdScope(course)
    try:
      if body:
        callGAPI(croom.courses(), 'patch',
                 throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION,
                               GAPI.FORBIDDEN, GAPI.BAD_REQUEST, GAPI.INVALID_ARGUMENT,
                               GAPI.INTERNAL_ERROR, GAPI.SERVICE_NOT_AVAILABLE],
                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                 id=courseId, body=body, updateMask=updateMask, fields='')
      callGAPI(croom.courses(), 'delete',
               throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION, GAPI.INTERNAL_ERROR],
               id=courseId)
      entityActionPerformed([Ent.COURSE, removeCourseIdScope(courseId)], i, count)
    except (GAPI.notFound, GAPI.permissionDenied, GAPI.failedPrecondition,
            GAPI.forbidden, GAPI.badRequest, GAPI.invalidArgument,
            GAPI.internalError, GAPI.serviceNotAvailable) as e:
      entityActionFailedWarning([Ent.COURSE, removeCourseIdScope(courseId)], str(e), i, count)

# gam delete courses <CourseEntity> [archive|archived]
def doDeleteCourses():
  _doDeleteCourses(getEntityList(Cmd.OB_COURSE_ENTITY, shlexSplit=True))

# gam delete course <CourseID> [archive|archived]
def doDeleteCourse():
  _doDeleteCourses(getStringReturnInList(Cmd.OB_COURSE_ID))

COURSE_FIELDS_CHOICE_MAP = {
  'alternatelink': 'alternateLink',
  'calendarid': 'calendarId',
  'coursegroupemail': 'courseGroupEmail',
  'coursematerialsets': 'courseMaterialSets',
  'coursestate': 'courseState',
  'creationtime': 'creationTime',
  'description': 'description',
  'descriptionheading': 'descriptionHeading',
  'enrollmentcode': 'enrollmentCode',
  'gradebooksettings': 'gradebookSettings',
  'guardiansenabled': 'guardiansEnabled',
  'heading': 'descriptionHeading',
  'id': 'id',
  'name': 'name',
  'owneremail': 'ownerId',
  'ownerid': 'ownerId',
  'room': 'room',
  'section': 'section',
  'teacherfolder': 'teacherFolder',
  'teachergroupemail': 'teacherGroupEmail',
  'updatetime': 'updateTime',
  }
COURSE_TIME_OBJECTS = {'creationTime', 'updateTime'}
COURSE_NOLEN_OBJECTS = {'materials'}
COURSE_PROPERTY_PRINT_ORDER = [
  'id',
  'name',
  'Aliases',
  'courseState',
  'descriptionHeading',
  'description',
  'section',
  'room',
  'enrollmentCode',
  'guardiansEnabled',
  'alternateLink',
  'ownerEmail',
  'ownerId',
  'creationTime',
  'updateTime',
  'calendarId',
  'courseGroupEmail',
  'teacherGroupEmail',
  'teacherFolder.id',
  'teacherFolder.title',
  'teacherFolder.alternateLink',
  ]

def _initCourseShowProperties(fields=None):
  return {'aliases': False, 'aliasesInColumns': False, 'ownerEmail': False, 'ownerEmailMatchPattern': None, 'members': 'none', 'countsOnly': False,
          'fields': fields if fields is not None else [], 'skips': []}

def _getCourseShowProperties(myarg, courseShowProperties):
  if myarg in {'alias', 'aliases'}:
    courseShowProperties['aliases'] = True
    courseShowProperties['aliasesInColumns'] = False
  elif myarg == 'aliasesincolumns':
    courseShowProperties['aliases'] = True
    courseShowProperties['aliasesInColumns'] = True
  elif myarg == 'owneremail':
    courseShowProperties['ownerEmail'] = True
  elif myarg == 'owneremailmatchpattern':
    courseShowProperties['ownerEmail'] = True
    courseShowProperties['ownerEmailMatchPattern'] = getREPattern(re.IGNORECASE)
  elif myarg == 'show':
    courseShowProperties['members'] = getChoice(COURSE_MEMBER_ARGUMENTS)
  elif myarg == 'countsonly':
    courseShowProperties['countsOnly'] = True
  elif myarg == 'fields':
    for field in _getFieldsList():
      if field in {'alias', 'aliases'}:
        courseShowProperties['aliases'] = True
        courseShowProperties['aliasesInColumns'] = False
      elif field == 'aliasesincolumns':
        courseShowProperties['aliases'] = True
        courseShowProperties['aliasesInColumns'] = True
      elif field == 'owneremail':
        courseShowProperties['ownerEmail'] = True
        courseShowProperties['fields'].append(COURSE_FIELDS_CHOICE_MAP[field])
      elif field == 'teachers':
        if courseShowProperties['members'] == 'none':
          courseShowProperties['members'] = field
        elif courseShowProperties['members'] == 'students':
          courseShowProperties['members'] = 'all'
      elif field == 'students':
        if courseShowProperties['members'] == 'none':
          courseShowProperties['members'] = field
        elif courseShowProperties['members'] == 'teachers':
          courseShowProperties['members'] = 'all'
      elif field in COURSE_FIELDS_CHOICE_MAP:
        courseShowProperties['fields'].append(COURSE_FIELDS_CHOICE_MAP[field])
      else:
        invalidChoiceExit(field, COURSE_FIELDS_CHOICE_MAP, True)
  elif myarg == 'skipfields':
    for field in _getFieldsList():
      if field in {'alias', 'aliases'}:
        courseShowProperties['aliases'] = False
      elif field == 'teachers':
        if courseShowProperties['members'] == 'all':
          courseShowProperties['members'] = 'students'
        elif courseShowProperties['members'] == field:
          courseShowProperties['members'] = 'none'
      elif field == 'students':
        if courseShowProperties['members'] == 'all':
          courseShowProperties['members'] = 'teachers'
        elif courseShowProperties['members'] == field:
          courseShowProperties['members'] = 'none'
      elif field in COURSE_FIELDS_CHOICE_MAP:
        if field != 'id':
          courseShowProperties['skips'].append(COURSE_FIELDS_CHOICE_MAP[field])
      else:
        invalidChoiceExit(field, COURSE_FIELDS_CHOICE_MAP, True)
  else:
    return False
  return True

def _setCourseFields(courseShowProperties, pagesMode, getOwnerId=False):
  if not courseShowProperties['fields']:
    return None
  courseShowProperties['fields'].append('id')
  if courseShowProperties['ownerEmail'] or getOwnerId:
    courseShowProperties['fields'].append('ownerId')
  if not pagesMode:
    return ','.join(set(courseShowProperties['fields']))
  return f'nextPageToken,courses({",".join(set(courseShowProperties["fields"]))})'

def _convertCourseUserIdToEmail(croom, userId, emails, entityValueList, i, count):
  userEmail = emails.get(userId)
  if userEmail is None:
    try:
      userEmail = callGAPI(croom.userProfiles(), 'get',
                           throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.SERVICE_NOT_AVAILABLE],
                           retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                           userId=userId, fields='emailAddress').get('emailAddress')
    except (GAPI.notFound, GAPI.permissionDenied, GAPI.badRequest, GAPI.forbidden, GAPI.serviceNotAvailable):
      pass
    if userEmail is None:
      entityDoesNotHaveItemWarning(entityValueList, i, count)
      userEmail = 'Unknown user'
    emails[userId] = userEmail
  return userEmail

def _getCoursesOwnerInfo(croom, courseIds, useOwnerAccess, addCIIdScope=True):
  coursesInfo = {}
  for courseId in courseIds:
    ciCourseId = courseId
    courseId = addCourseIdScope(courseId)
    if addCIIdScope:
      ciCourseId = courseId
    if courseId not in coursesInfo:
      try:
        course = callGAPI(croom.courses(), 'get',
                          throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                          retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                          id=courseId, fields='name,ownerId')
        if useOwnerAccess:
          _, ocroom = buildGAPIServiceObject(API.CLASSROOM, f'uid:{course["ownerId"]}')
        else:
          ocroom = croom
        if ocroom is not None:
          coursesInfo[ciCourseId] = {'name': course['name'], 'croom': ocroom}
      except GAPI.notFound:
        entityDoesNotExistWarning(Ent.COURSE, courseId)
      except (GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.COURSE, courseId], str(e))
      except GAPI.forbidden:
        ClientAPIAccessDeniedExit()
  return 0, len(coursesInfo), coursesInfo

def _getCourseAliasesMembers(croom, ocroom, courseId, courseShowProperties, teachersFields, studentsFields, showGettings=False, i=0, count=0):
  aliases = []
  teachers = []
  students = []
  if not showGettings:
    pageMessage = None
  if courseShowProperties.get('aliases'):
    if showGettings:
      printGettingEntityItemForWhom(Ent.ALIAS, formatKeyValueList('', [Ent.Singular(Ent.COURSE), courseId], currentCount(i, count)))
      pageMessage = getPageMessage()
    try:
      aliases = callGAPIpages(croom.courses().aliases(), 'list', 'aliases',
                              pageMessage=pageMessage,
                              throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.SERVICE_NOT_AVAILABLE, GAPI.NOT_IMPLEMENTED],
                              retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                              courseId=courseId, pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS])
    except (GAPI.notFound, GAPI.serviceNotAvailable, GAPI.notImplemented):
      pass
    except GAPI.forbidden:
      ClientAPIAccessDeniedExit()
  if courseShowProperties['members'] != 'none':
    if courseShowProperties['members'] != 'students':
      if showGettings:
        printGettingEntityItemForWhom(Ent.TEACHER, formatKeyValueList('', [Ent.Singular(Ent.COURSE), courseId], currentCount(i, count)))
        pageMessage = getPageMessage()
      try:
        teachers = callGAPIpages(ocroom.courses().teachers(), 'list', 'teachers',
                                 pageMessage=pageMessage,
                                 throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.SERVICE_NOT_AVAILABLE],
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 courseId=courseId, fields=teachersFields, pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS])
      except (GAPI.notFound, GAPI.serviceNotAvailable):
        pass
      except GAPI.forbidden:
        ClientAPIAccessDeniedExit()
    if courseShowProperties['members'] != 'teachers':
      if showGettings:
        printGettingEntityItemForWhom(Ent.STUDENT, formatKeyValueList('', [Ent.Singular(Ent.COURSE), courseId], currentCount(i, count)))
        pageMessage = getPageMessage()
      try:
        students = callGAPIpages(ocroom.courses().students(), 'list', 'students',
                                 pageMessage=pageMessage,
                                 throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.SERVICE_NOT_AVAILABLE],
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 courseId=courseId, fields=studentsFields, pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS])
      except (GAPI.notFound, GAPI.serviceNotAvailable):
        pass
      except GAPI.forbidden:
        ClientAPIAccessDeniedExit()
  return (aliases, teachers, students)

def _doInfoCourses(courseIdList):
  croom = buildGAPIObject(API.CLASSROOM)
  courseShowProperties = _initCourseShowProperties()
  courseShowProperties['ownerEmail'] = True
  ownerEmails = {}
  useOwnerAccess = GC.Values[GC.USE_COURSE_OWNER_ACCESS]
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if _getCourseShowProperties(myarg, courseShowProperties):
      pass
    elif myarg in OWNER_ACCESS_OPTIONS:
      useOwnerAccess = True
    else:
      FJQC.GetFormatJSON(myarg)
  fields = _setCourseFields(courseShowProperties, False)
  if courseShowProperties['members'] != 'none':
    if courseShowProperties['countsOnly']:
      teachersFields = 'nextPageToken,teachers(profile(id))'
      studentsFields = 'nextPageToken,students(profile(id))'
    else:
      teachersFields = 'nextPageToken,teachers(profile)'
      studentsFields = 'nextPageToken,students(profile)'
  else:
    teachersFields = studentsFields = None
  i, count, coursesInfo = _getCoursesOwnerInfo(croom, courseIdList, useOwnerAccess)
  for courseId, courseInfo in coursesInfo.items():
    i += 1
    try:
      course = callGAPI(croom.courses(), 'get',
                        throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                        retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                        id=courseId, fields=fields)
      if courseShowProperties['ownerEmail']:
        course['ownerEmail'] = _convertCourseUserIdToEmail(croom, course['ownerId'], ownerEmails,
                                                           [Ent.COURSE, course['id'], Ent.OWNER_ID, course['ownerId']], i, count)
      aliases, teachers, students = _getCourseAliasesMembers(croom, courseInfo['croom'], courseId, courseShowProperties, teachersFields, studentsFields)
      if FJQC.formatJSON:
        if courseShowProperties['aliases']:
          course.update({'aliases': list(aliases)})
        if courseShowProperties['members'] != 'none':
          if courseShowProperties['members'] != 'students':
            if not courseShowProperties['countsOnly']:
              course.update({'teachers': list(teachers)})
            else:
              course.update({'teachers': len(teachers)})
          if courseShowProperties['members'] != 'teachers':
            if not courseShowProperties['countsOnly']:
              course.update({'students': list(students)})
            else:
              course.update({'students': len(students)})
        printLine(json.dumps(cleanJSON(course, skipObjects=courseShowProperties['skips'],
                                       timeObjects=COURSE_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
        continue
      printEntity([Ent.COURSE, course['id']], i, count)
      Ind.Increment()
      showJSON(None, course, courseShowProperties['skips'], COURSE_TIME_OBJECTS)
      if courseShowProperties['aliases']:
        printKeyValueList(['Aliases', len(aliases)])
        Ind.Increment()
        for alias in aliases:
          printKeyValueList([removeCourseAliasScope(alias['alias'])])
        Ind.Decrement()
      if courseShowProperties['members'] != 'none':
        printKeyValueList(['Participants', None])
        Ind.Increment()
        if courseShowProperties['members'] != 'students':
          if teachers:
            printKeyValueList(['Teachers', len(teachers)])
            if not courseShowProperties['countsOnly']:
              Ind.Increment()
              for teacher in teachers:
                if 'emailAddress' in teacher['profile']:
                  printKeyValueList([f'{teacher["profile"]["name"]["fullName"]} - {teacher["profile"]["emailAddress"]}'])
                else:
                  printKeyValueList([teacher['profile']['name']['fullName']])
              Ind.Decrement()
        if courseShowProperties['members'] != 'teachers':
          if students:
            printKeyValueList(['Students', len(students)])
            if not courseShowProperties['countsOnly']:
              Ind.Increment()
              for student in students:
                if 'emailAddress' in student['profile']:
                  printKeyValueList([f'{student["profile"]["name"]["fullName"]} - {student["profile"]["emailAddress"]}'])
                else:
                  printKeyValueList([student['profile']['name']['fullName']])
              Ind.Decrement()
          Ind.Decrement()
        Ind.Decrement()
      Ind.Decrement()
    except GAPI.notFound:
      entityActionFailedWarning([Ent.COURSE, removeCourseIdScope(courseId)], Msg.DOES_NOT_EXIST, i, count)
    except (GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
      entityActionFailedWarning([Ent.COURSE, removeCourseIdScope(courseId)], str(e), i, count)
    except GAPI.forbidden:
      ClientAPIAccessDeniedExit()

# gam info courses <CourseEntity> [owneraccess]
#	[owneremail] [alias|aliases] [show none|all|students|teachers] [countsonly]
#	[fields <CourseFieldNameList>] [skipfields <CourseFieldNameList>] [formatjson]
def doInfoCourses():
  _doInfoCourses(getEntityList(Cmd.OB_COURSE_ENTITY, shlexSplit=True))

# gam info course <CourseID> [owneraccess]
#	[owneremail] [alias|aliases] [show none|all|students|teachers] [countsonly]
#	[fields <CourseFieldNameList>] [skipfields <CourseFieldNameList>] [formatjson]
def doInfoCourse():
  _doInfoCourses(getStringReturnInList(Cmd.OB_COURSE_ID))

def _initCourseSelectionParameters():
  return {'courseIds': [], 'teacherId': None, 'studentId': None, 'courseStates': []}

def _getCourseSelectionParameters(myarg, courseSelectionParameters):
  if myarg in {'course', 'courses', 'class', 'classes'}:
    courseSelectionParameters['courseIds'].extend(getEntityList(Cmd.OB_COURSE_ENTITY, shlexSplit=True))
  elif myarg == 'teacher':
    courseSelectionParameters['teacherId'] = getEmailAddress()
  elif myarg == 'student':
    courseSelectionParameters['studentId'] = getEmailAddress()
  elif myarg in {'state', 'states', 'status'}:
    _getCourseStates(Cmd.OB_COURSE_STATE_LIST, courseSelectionParameters['courseStates'])
  else:
    return False
  return True

COURSE_CU_FILTER_FIELDS_MAP = {'creationtime': 'creationTime', 'updatetime': 'updateTime'}
COURSE_CUS_FILTER_FIELDS_MAP = {'creationtime': 'creationTime', 'updatetime': 'updateTime', 'scheduledtime': 'scheduledTime'}
COURSE_U_FILTER_FIELDS_MAP = {'updatetime': 'updateTime'}
COURSE_START_ARGUMENTS = ['start', 'startdate', 'oldestdate']
COURSE_END_ARGUMENTS = ['end', 'enddate']

def _initCourseItemFilter():
  return {'timefilter': None, 'startTime': None, 'endTime': None}

def _getCourseItemFilter(myarg, courseItemFilter, courseFilterFields):
  if myarg == 'timefilter':
    courseItemFilter['timefilter'] = getChoice(courseFilterFields, mapChoice=True)
  elif myarg in COURSE_START_ARGUMENTS:
    courseItemFilter['startTime'], _, _ = getTimeOrDeltaFromNow(True)
  elif myarg in COURSE_END_ARGUMENTS:
    courseItemFilter['endTime'], _, _ = getTimeOrDeltaFromNow(True)
  else:
    return False
  return True

def _setApplyCourseItemFilter(courseItemFilter, fieldsList):
  if courseItemFilter['timefilter'] and (courseItemFilter['startTime'] or courseItemFilter['endTime']):
    if fieldsList:
      fieldsList.append(courseItemFilter['timefilter'])
    return True
  return False

def _courseItemPassesFilter(item, courseItemFilter):
  timeStr = item.get(courseItemFilter['timefilter'])
  if not timeStr:
    return False
  startTime = courseItemFilter['startTime']
  endTime = courseItemFilter['endTime']
  timeValue, _ = iso8601.parse_date(timeStr)
  return ((startTime is None) or (timeValue >= startTime)) and ((endTime is None) or (timeValue <= endTime))

def _gettingCoursesQuery(courseSelectionParameters):
  query = ''
  if courseSelectionParameters['teacherId']:
    query += f'{Ent.Singular(Ent.TEACHER)}: {courseSelectionParameters["teacherId"]}, '
  if courseSelectionParameters['studentId']:
    query += f'{Ent.Singular(Ent.STUDENT)}: {courseSelectionParameters["studentId"]}, '
  if courseSelectionParameters['courseStates']:
    query += f'{Ent.Choose(Ent.COURSE_STATE, len(courseSelectionParameters["courseStates"]))}: {",".join(courseSelectionParameters["courseStates"])}, '
  if query:
    query = query[:-2]
  return query

def _getCoursesInfo(croom, courseSelectionParameters, courseShowProperties, getOwnerId=False):
  if not courseSelectionParameters['courseIds']:
    fields = _setCourseFields(courseShowProperties, True, getOwnerId)
    printGettingAllAccountEntities(Ent.COURSE, _gettingCoursesQuery(courseSelectionParameters))
    try:
      return callGAPIpages(croom.courses(), 'list', 'courses',
                           pageMessage=getPageMessage(),
                           throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST, GAPI.INVALID, GAPI.SERVICE_NOT_AVAILABLE],
                           retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                           teacherId=courseSelectionParameters['teacherId'],
                           studentId=courseSelectionParameters['studentId'],
                           courseStates=courseSelectionParameters['courseStates'],
                           fields=fields, pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS])
    except (GAPI.invalid, GAPI.notFound):
      if (not courseSelectionParameters['studentId']) and courseSelectionParameters['teacherId']:
        entityUnknownWarning(Ent.TEACHER, courseSelectionParameters['teacherId'])
      elif (not courseSelectionParameters['teacherId']) and courseSelectionParameters['studentId']:
        entityUnknownWarning(Ent.STUDENT, courseSelectionParameters['studentId'])
      elif courseSelectionParameters['studentId'] and courseSelectionParameters['teacherId']:
        entityOrEntityUnknownWarning(Ent.TEACHER, courseSelectionParameters['teacherId'], Ent.STUDENT, courseSelectionParameters['studentId'])
    except (GAPI.insufficientPermissions, GAPI.permissionDenied, GAPI.invalidArgument, GAPI.badRequest, GAPI.serviceNotAvailable) as e:
      entityActionFailedWarning([Ent.COURSE, None], str(e))
    return None
  fields = _setCourseFields(courseShowProperties, False, getOwnerId)
  coursesInfo = []
  for courseId in courseSelectionParameters['courseIds']:
    courseId = addCourseIdScope(courseId)
    try:
      info = callGAPI(croom.courses(), 'get',
                      throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.SERVICE_NOT_AVAILABLE],
                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                      id=courseId, fields=fields)
      coursesInfo.append(info)
    except GAPI.notFound:
      entityDoesNotExistWarning(Ent.COURSE, courseId)
    except GAPI.serviceNotAvailable as e:
      entityActionFailedWarning([Ent.COURSE, courseId], str(e))
    except GAPI.forbidden:
      ClientAPIAccessDeniedExit()
  return coursesInfo

# gam print courses [todrive <ToDriveAttribute>*] (course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] [states <CourseStateList>])
#	[owneremail] [owneremailmatchpattern <REMatchPattern>]
#	[alias|aliases|aliasesincolumns [delimiter <Character>]]
#	[show none|all|students|teachers] [countsonly]
#	[fields <CourseFieldNameList>] [skipfields <CourseFieldNameList>]
#	[timefilter creationtime|updatetime] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
#	[showitemcountonly] [formatjson [quotechar <Character>]]
def doPrintCourses():
  def _saveParticipants(course, participants, role, rtitles):
    jcount = len(participants)
    course[role] = jcount
    if courseShowProperties['countsOnly']:
      return
    j = 0
    for member in participants:
      memberTitles = []
      prefix = f'{role}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{j}.'
      profile = member['profile']
      emailAddress = profile.get('emailAddress')
      if emailAddress:
        memberTitle = prefix+'emailAddress'
        course[memberTitle] = emailAddress
        memberTitles.append(memberTitle)
      memberId = profile.get('id')
      if memberId:
        memberTitle = prefix+'id'
        course[memberTitle] = memberId
        memberTitles.append(memberTitle)
      fullName = profile.get('name', {}).get('fullName')
      if fullName:
        memberTitle = prefix+'name.fullName'
        course[memberTitle] = fullName
        memberTitles.append(memberTitle)
      for title in memberTitles:
        if title not in rtitles['set']:
          rtitles['set'].add(title)
          rtitles['list'].append(title)
      j += 1

  croom = buildGAPIObject(API.CLASSROOM)
  csvPF = CSVPrintFile('id')
  FJQC = FormatJSONQuoteChar(csvPF)
  courseSelectionParameters = _initCourseSelectionParameters()
  courseItemFilter = _initCourseItemFilter()
  courseShowProperties = _initCourseShowProperties()
  ownerEmails = {}
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  showItemCountOnly = False
  useOwnerAccess = GC.Values[GC.USE_COURSE_OWNER_ACCESS]
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif _getCourseSelectionParameters(myarg, courseSelectionParameters):
      pass
    elif _getCourseItemFilter(myarg, courseItemFilter, COURSE_CU_FILTER_FIELDS_MAP):
      pass
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    elif _getCourseShowProperties(myarg, courseShowProperties):
      pass
    elif myarg == 'showitemcountonly':
      showItemCountOnly = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  applyCourseItemFilter = _setApplyCourseItemFilter(courseItemFilter, None)
  if applyCourseItemFilter:
    if courseShowProperties['fields']:
      courseShowProperties['fields'].append(courseItemFilter['timefilter'])
  coursesInfo = _getCoursesInfo(croom, courseSelectionParameters, courseShowProperties, useOwnerAccess)
  if coursesInfo is None:
    if showItemCountOnly:
      writeStdout('0\n')
    return
  if courseShowProperties['aliases']:
    if FJQC.formatJSON:
      csvPF.AddJSONTitles('JSON-aliases')
  if courseShowProperties['members'] != 'none':
    ttitles = {'set': set(), 'list': []}
    stitles = {'set': set(), 'list': []}
    if courseShowProperties['members'] != 'students':
      ttitles['set'].add('teachers')
      ttitles['list'].append('teachers')
      if FJQC.formatJSON:
        csvPF.AddJSONTitles('JSON-teachers')
    if courseShowProperties['members'] != 'teachers':
      stitles['set'].add('students')
      stitles['list'].append('students')
      if FJQC.formatJSON:
        csvPF.AddJSONTitles('JSON-students')
    if courseShowProperties['countsOnly']:
      teachersFields = 'nextPageToken,teachers(profile(id))'
      studentsFields = 'nextPageToken,students(profile(id))'
    else:
      teachersFields = 'nextPageToken,teachers(profile)'
      studentsFields = 'nextPageToken,students(profile)'
  else:
    teachersFields = studentsFields = None
  itemCount = 0
  count = len(coursesInfo)
  i = 0
  for course in coursesInfo:
    i += 1
    if applyCourseItemFilter and not _courseItemPassesFilter(course, courseItemFilter):
      continue
    for field in courseShowProperties['skips']:
      course.pop(field, None)
    courseId = course['id']
    if useOwnerAccess:
      _, ocroom = buildGAPIServiceObject(API.CLASSROOM, f'uid:{course["ownerId"]}')
      if not ocroom:
        continue
    else:
      ocroom = croom
    if courseShowProperties['ownerEmail']:
      course['ownerEmail'] = _convertCourseUserIdToEmail(croom, course['ownerId'], ownerEmails,
                                                         [Ent.COURSE, courseId, Ent.OWNER_ID, course['ownerId']], i, count)
      if courseShowProperties['ownerEmailMatchPattern'] and not courseShowProperties['ownerEmailMatchPattern'].match(course['ownerEmail']):
        continue
    if showItemCountOnly:
      itemCount += 1
      continue
    aliases, teachers, students = _getCourseAliasesMembers(croom, ocroom, courseId, courseShowProperties, teachersFields, studentsFields, True, i, count)
    if courseShowProperties['aliases']:
      if not courseShowProperties['aliasesInColumns']:
        course['Aliases'] = delimiter.join([removeCourseAliasScope(alias['alias']) for alias in aliases])
      else:
        course['Aliases'] = [removeCourseAliasScope(alias['alias']) for alias in aliases]
    if courseShowProperties['members'] != 'none':
      if courseShowProperties['members'] != 'students':
        _saveParticipants(course, teachers, 'teachers', ttitles)
      if courseShowProperties['members'] != 'teachers':
        _saveParticipants(course, students, 'students', stitles)
    row = flattenJSON(course, timeObjects=COURSE_TIME_OBJECTS, noLenObjects=COURSE_NOLEN_OBJECTS)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      row = {'id': courseId, 'JSON': json.dumps(cleanJSON(course, timeObjects=COURSE_TIME_OBJECTS),
                                                ensure_ascii=False, sort_keys=True)}
      if courseShowProperties['aliases']:
        row['JSON-aliases'] = json.dumps(list(aliases))
      if courseShowProperties['members'] != 'none':
        if courseShowProperties['members'] != 'students':
          if not courseShowProperties['countsOnly']:
            row['JSON-teachers'] = json.dumps(list(teachers))
          else:
            row['JSON-teachers'] = json.dumps(len(teachers))
        if courseShowProperties['members'] != 'teachers':
          if not courseShowProperties['countsOnly']:
            row['JSON-students'] = json.dumps(list(students))
          else:
            row['JSON-students'] = json.dumps(len(students))
      csvPF.WriteRowNoFilter(row)
  if showItemCountOnly:
    writeStdout(f'{itemCount}\n')
    return
  if not FJQC.formatJSON:
    if courseShowProperties['aliases']:
      csvPF.AddTitles('Aliases')
    csvPF.SetSortTitles(COURSE_PROPERTY_PRINT_ORDER)
    if courseShowProperties['aliases'] and courseShowProperties['aliasesInColumns']:
      csvPF.FixCourseAliasesTitles()
    csvPF.SortTitles()
    csvPF.SetSortTitles([])
    if courseShowProperties['members'] != 'none':
      csvPF.RearrangeCourseTitles(ttitles, stitles)
  csvPF.writeCSVfile('Courses')

def _printCourseItemCount(course, results, title, applyCourseItemFilter, courseItemFilter, csvPF):
  if applyCourseItemFilter:
    numItems = 0
    for item in results:
      if _courseItemPassesFilter(item, courseItemFilter):
        numItems += 1
  else:
    numItems = len(results)
  if csvPF:
    csvPF.WriteRowTitles({'courseId': course['id'], 'courseName': course['name'], title: numItems})
  return numItems

COURSE_ANNOUNCEMENTS_FIELDS_CHOICE_MAP = {
  'alternatelink': 'alternateLink',
  'announcementid': 'id',
  'assigneemode': 'assigneeMode',
  'courseid': 'courseId',
  'courseannouncementid': 'id',
  'creationtime': 'creationTime',
  'creator': 'creatorUserId',
  'creatoruserid': 'creatorUserId',
  'id': 'id',
  'individualstudentsoptions': 'individualStudentsOptions',
  'materials': 'materials',
  'scheduledtime': 'scheduledTime',
  'state': 'state',
  'text': 'text',
  'updatetime': 'updateTime',
  }
COURSE_ANNOUNCEMENTS_ORDERBY_CHOICE_MAP = {
  'updatetime': 'updateTime',
  'updatedate': 'updateTime',
  }
COURSE_ANNOUNCEMENTS_TIME_OBJECTS = {'creationTime', 'scheduledTime', 'updateTime'}
COURSE_ANNOUNCEMENTS_SORT_TITLES = ['courseId', 'courseName', 'id', 'text', 'state']
COURSE_ANNOUNCEMENTS_INDEXED_TITLES = ['materials']

# gam print course-announcements [todrive <ToDriveAttribute>*]
#	(course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] states <CourseStateList>])
#	(announcementids <CourseAnnouncementIDEntity>)|((announcementstates <CourseAnnouncementStateList>)*
#	(orderby <CourseAnnouncementOrderByFieldName> [ascending|descending])*)
#	[showcreatoremails|creatoremail] [fields <CourseAnnouncementFieldNameList>]
#	[timefilter creationtime|updatetime|scheduledtime] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
#	[countsonly] [formatjson [quotechar <Character>]]
def doPrintCourseAnnouncements():
  def _printCourseAnnouncement(course, courseAnnouncement, i, count):
    if applyCourseItemFilter and not _courseItemPassesFilter(courseAnnouncement, courseItemFilter):
      return
    if showCreatorEmail:
      courseAnnouncement['creatorUserEmail'] = _convertCourseUserIdToEmail(croom, courseAnnouncement['creatorUserId'], creatorEmails,
                                                                           [Ent.COURSE, course['id'], Ent.COURSE_ANNOUNCEMENT_ID, courseAnnouncement['id'],
                                                                            Ent.CREATOR_ID, courseAnnouncement['creatorUserId']], i, count)
    row = flattenJSON(courseAnnouncement, flattened={'courseId': course['id'], 'courseName': course['name']}, timeObjects=COURSE_ANNOUNCEMENTS_TIME_OBJECTS)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'courseId': course['id'], 'courseName': course['name'],
                              'JSON': json.dumps(cleanJSON(courseAnnouncement, timeObjects=COURSE_ANNOUNCEMENTS_TIME_OBJECTS),
                                                 ensure_ascii=False, sort_keys=True)})

  croom = buildGAPIObject(API.CLASSROOM)
  csvPF = CSVPrintFile(['courseId', 'courseName'], COURSE_ANNOUNCEMENTS_SORT_TITLES, COURSE_ANNOUNCEMENTS_INDEXED_TITLES)
  FJQC = FormatJSONQuoteChar(csvPF)
  fieldsList = []
  courseSelectionParameters = _initCourseSelectionParameters()
  courseItemFilter = _initCourseItemFilter()
  courseShowProperties = _initCourseShowProperties(['name'])
  courseAnnouncementIds = []
  courseAnnouncementStates = []
  OBY = OrderBy(COURSE_ANNOUNCEMENTS_ORDERBY_CHOICE_MAP)
  creatorEmails = {}
  countsOnly = showCreatorEmail = False
  items = 'courseAnnouncements'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif _getCourseSelectionParameters(myarg, courseSelectionParameters):
      pass
    elif _getCourseItemFilter(myarg, courseItemFilter, COURSE_CUS_FILTER_FIELDS_MAP):
      pass
    elif myarg in {'announcementid', 'announcementids'}:
      courseAnnouncementIds = getEntityList(Cmd.OB_COURSE_ANNOUNCEMENT_ID_ENTITY)
    elif myarg in {'announcementstate', 'announcementstates'}:
      _getCourseStates(Cmd.OB_COURSE_ANNOUNCEMENT_STATE_LIST, courseAnnouncementStates)
    elif myarg == 'orderby':
      OBY.GetChoice()
    elif myarg in {'showcreatoremails', 'creatoremail'}:
      showCreatorEmail = True
    elif getFieldsList(myarg, COURSE_ANNOUNCEMENTS_FIELDS_CHOICE_MAP, fieldsList, initialField='id'):
      pass
    elif myarg == 'countsonly':
      countsOnly = True
      csvPF.AddTitles(items)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  coursesInfo = _getCoursesInfo(croom, courseSelectionParameters, courseShowProperties)
  if coursesInfo is None:
    return
  applyCourseItemFilter = _setApplyCourseItemFilter(courseItemFilter, fieldsList)
  if showCreatorEmail and fieldsList:
    fieldsList.append('creatorUserId')
  courseAnnouncementIdsLists = courseAnnouncementIds if isinstance(courseAnnouncementIds, dict) else None
  i = 0
  count = len(coursesInfo)
  for course in coursesInfo:
    i += 1
    courseId = course['id']
    if courseAnnouncementIdsLists:
      courseAnnouncementIds = courseAnnouncementIdsLists[courseId]
    if not courseAnnouncementIds:
      fields = getItemFieldsFromFieldsList('announcements', fieldsList)
      printGettingAllEntityItemsForWhom(Ent.COURSE_ANNOUNCEMENT_ID, Ent.TypeName(Ent.COURSE, courseId), i, count,
                                        _gettingCourseEntityQuery(Ent.COURSE_ANNOUNCEMENT_STATE, courseAnnouncementStates))
      try:
        results = callGAPIpages(croom.courses().announcements(), 'list', 'announcements',
                                pageMessage=getPageMessageForWhom(),
                                throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE],
                                retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                courseId=courseId, announcementStates=courseAnnouncementStates, orderBy=OBY.orderBy,
                                fields=fields, pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS])
        if not countsOnly:
          for courseAnnouncement in results:
            _printCourseAnnouncement(course, courseAnnouncement, i, count)
        else:
          _printCourseItemCount(course, results, items, applyCourseItemFilter, courseItemFilter, csvPF)
      except (GAPI.notFound, GAPI.insufficientPermissions, GAPI.permissionDenied, GAPI.forbidden, GAPI.invalidArgument, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.COURSE, removeCourseIdScope(courseId)], str(e), i, count)
    else:
      jcount = len(courseAnnouncementIds)
      if jcount == 0:
        continue
      fields = f'{",".join(set(fieldsList))}' if fieldsList else None
      j = 0
      for courseAnnouncementId in courseAnnouncementIds:
        j += 1
        try:
          courseAnnouncement = callGAPI(croom.courses().announcements(), 'get',
                                        throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE],
                                        retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                        courseId=courseId, id=courseAnnouncementId, fields=fields)
          _printCourseAnnouncement(course, courseAnnouncement, i, count)
        except GAPI.notFound:
          entityDoesNotHaveItemWarning([Ent.COURSE_NAME, course['name'], Ent.COURSE_ANNOUNCEMENT_ID, courseAnnouncementId], j, jcount)
        except (GAPI.insufficientPermissions, GAPI.permissionDenied, GAPI.forbidden, GAPI.invalidArgument, GAPI.serviceNotAvailable) as e:
          entityActionFailedWarning([Ent.COURSE_NAME, course['name'], Ent.COURSE_ANNOUNCEMENT_ID, courseAnnouncementId], str(e), j, jcount)
  csvPF.writeCSVfile('Course Announcements')

COURSE_TOPICS_TIME_OBJECTS = {'updateTime'}
COURSE_TOPICS_SORT_TITLES = ['courseId', 'courseName', 'topicId', 'name', 'updateTime']

# gam print course-topics [todrive <ToDriveAttribute>*]
#	(course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] states <CourseStateList>])
#	[topicids <CourseTopicIDEntity>]
#	[timefilter updatetime] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
#	[countsonly] [formatjson [quotechar <Character>]]
def doPrintCourseTopics():
  def _printCourseTopic(course, courseTopic):
    if applyCourseItemFilter and not _courseItemPassesFilter(courseTopic, courseItemFilter):
      return
    row = flattenJSON(courseTopic, flattened={'courseId': course['id'], 'courseName': course['name']}, timeObjects=COURSE_TOPICS_TIME_OBJECTS)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'courseId': course['id'], 'courseName': course['name'],
                              'JSON': json.dumps(cleanJSON(courseTopic, timeObjects=COURSE_TOPICS_TIME_OBJECTS),
                                                 ensure_ascii=False, sort_keys=True)})

  croom = buildGAPIObject(API.CLASSROOM)
  csvPF = CSVPrintFile(['courseId', 'courseName'], COURSE_TOPICS_SORT_TITLES)
  FJQC = FormatJSONQuoteChar(csvPF)
  fieldsList = ['topicId', 'name', 'updateTime']
  courseSelectionParameters = _initCourseSelectionParameters()
  courseItemFilter = _initCourseItemFilter()
  courseShowProperties = _initCourseShowProperties(['name'])
  courseTopicIds = []
  countsOnly = False
  items = 'courseTopics'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif _getCourseSelectionParameters(myarg, courseSelectionParameters):
      pass
    elif _getCourseItemFilter(myarg, courseItemFilter, COURSE_U_FILTER_FIELDS_MAP):
      pass
    elif myarg in {'topicid', 'topicids'}:
      courseTopicIds = getEntityList(Cmd.OB_COURSE_TOPIC_ID_ENTITY)
    elif myarg == 'countsonly':
      countsOnly = True
      csvPF.AddTitles(items)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  coursesInfo = _getCoursesInfo(croom, courseSelectionParameters, courseShowProperties)
  if coursesInfo is None:
    return
  applyCourseItemFilter = _setApplyCourseItemFilter(courseItemFilter, fieldsList)
  courseTopicIdsLists = courseTopicIds if isinstance(courseTopicIds, dict) else None
  i = 0
  count = len(coursesInfo)
  for course in coursesInfo:
    i += 1
    courseId = course['id']
    if courseTopicIdsLists:
      courseTopicIds = courseTopicIdsLists[courseId]
    if not courseTopicIds:
      fields = getItemFieldsFromFieldsList('topic', fieldsList)
      printGettingAllEntityItemsForWhom(Ent.COURSE_TOPIC, Ent.TypeName(Ent.COURSE, courseId), i, count)
      try:
        results = callGAPIpages(croom.courses().topics(), 'list', 'topic',
                                pageMessage=getPageMessageForWhom(),
                                throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE],
                                retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                courseId=courseId,
                                fields=fields, pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS])
        if not countsOnly:
          for courseTopic in results:
            _printCourseTopic(course, courseTopic)
        else:
          _printCourseItemCount(course, results, items, applyCourseItemFilter, courseItemFilter, csvPF)
      except (GAPI.notFound, GAPI.insufficientPermissions, GAPI.permissionDenied, GAPI.forbidden, GAPI.invalidArgument, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.COURSE, removeCourseIdScope(courseId)], str(e), i, count)
    else:
      jcount = len(courseTopicIds)
      if jcount == 0:
        continue
      fields = getFieldsFromFieldsList(fieldsList)
      j = 0
      for courseTopicId in courseTopicIds:
        j += 1
        try:
          courseTopic = callGAPI(croom.courses().topics(), 'get',
                                 throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE],
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 courseId=courseId, id=courseTopicId, fields=fields)
          _printCourseTopic(course, courseTopic)
        except GAPI.notFound:
          entityDoesNotHaveItemWarning([Ent.COURSE_NAME, course['name'], Ent.COURSE_TOPIC_ID, courseTopicId], j, jcount)
        except (GAPI.insufficientPermissions, GAPI.permissionDenied, GAPI.forbidden, GAPI.invalidArgument, GAPI.serviceNotAvailable) as e:
          entityActionFailedWarning([Ent.COURSE_NAME, course['name'], Ent.COURSE_TOPIC_ID, courseTopicId], str(e), j, jcount)
  csvPF.writeCSVfile('Course Topics')

def _initCourseWMSelectionParameters():
  return {'courseWMIds': [], 'courseWMStates': []}

def _getCourseWMSelectionParameters(myarg, courseWMSelectionParameters,
                                    IDArguments, OBIDEntity,
                                    StateArguments, OBStateList):
  if myarg in IDArguments:
    courseWMSelectionParameters['courseWMIds'] = getEntityList(OBIDEntity)
  elif myarg in StateArguments:
    _getCourseStates(OBStateList, courseWMSelectionParameters['courseWMStates'])
  else:
    return False
  return True

COURSE_MATERIAL_ID_ARGUMENTS = {'materialid', 'materialids', 'coursematerialid', 'coursematerialids'}
COURSE_MATERIAL_STATE_ARGUMENTS = {'materialstate', 'materialstates', 'coursematerialstate', 'coursematerialstates'}
COURSE_MATERIAL_FIELDS_CHOICE_MAP = {
  'alternatelink': 'alternateLink',
  'assigneemode': 'assigneeMode',
  'courseid': 'courseId',
  'coursematerialid': 'id',
  'creationtime': 'creationTime',
  'creator': 'creatorUserId',
  'creatoruserid': 'creatorUserId',
  'description': 'description',
  'id': 'id',
  'individualstudentsoptions': 'individualStudentsOptions',
  'materialid': 'id',
  'materials': 'materials',
  'scheduledtime': 'scheduledTime',
  'state': 'state',
  'title': 'title',
  'topicid': 'topicId',
  'updatetime': 'updateTime',
  }
COURSE_MATERIAL_ORDERBY_CHOICE_MAP = {'updatetime': 'updateTime', 'updatedate': 'updateTime'}
COURSE_MATERIAL_TIME_OBJECTS = {'creationTime', 'scheduledTime', 'updateTime'}
COURSE_MATERIAL_SORT_TITLES = ['courseId', 'courseName', 'id', 'title', 'description', 'state']
COURSE_MATERIAL_INDEXED_TITLES = ['materials']

COURSE_WORK_ID_ARGUMENTS = {'workid', 'workids', 'courseworkid', 'courseworkids'}
COURSE_WORK_STATE_ARGUMENTS = {'workstate', 'workstates', 'courseworkstate', 'courseworkstates'}
COURSE_WORK_FIELDS_CHOICE_MAP = {
  'alternatelink': 'alternateLink',
  'assigneemode': 'assigneeMode',
  'courseid': 'courseId',
  'courseworkid': 'id',
  'courseworktype': 'workType',
  'creationtime': 'creationTime',
  'creator': 'creatorUserId',
  'creatoruserid': 'creatorUserId',
  'description': 'description',
  'duedate': 'dueDate',
  'duetime': 'dueTime',
  'id': 'id',
  'individualstudentsoptions': 'individualStudentsOptions',
  'materials': 'materials',
  'maxpoints': 'maxPoints',
  'scheduledtime': 'scheduledTime',
  'state': 'state',
  'submissionmodificationmode': 'submissionModificationMode',
  'title': 'title',
  'topicid': 'topicId',
  'updatetime': 'updateTime',
  'workid': 'id',
  'worktype': 'workType',
  }
COURSE_WORK_ORDERBY_CHOICE_MAP = {'duedate': 'dueDate', 'updatetime': 'updateTime', 'updatedate': 'updateTime'}
COURSE_WORK_TIME_OBJECTS = {'creationTime', 'scheduledTime', 'updateTime'}
COURSE_WORK_SORT_TITLES = ['courseId', 'courseName', 'id', 'title', 'description', 'state']
COURSE_WORK_INDEXED_TITLES = ['materials']

def doPrintCourseWM(entityIDType, entityStateType):
  def _getTopicNames(croom, courseId):
    topicNames = {}
    try:
      results = callGAPIpages(croom.courses().topics(), 'list', 'topic',
                              throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE],
                              retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                              courseId=courseId,
                              fields='nextPageToken,topic(topicId,name)', pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS])
      for courseTopic in results:
        topicNames[courseTopic['topicId']] = courseTopic['name']
    except (GAPI.notFound, GAPI.insufficientPermissions, GAPI.permissionDenied, GAPI.forbidden, GAPI.invalidArgument, GAPI.serviceNotAvailable):
      pass
    return topicNames

  def _printCourseWM(course, courseWM, i, count):
    if applyCourseItemFilter and not _courseItemPassesFilter(courseWM, courseItemFilter):
      return
    if showCreatorEmail:
      courseWM['creatorUserEmail'] = _convertCourseUserIdToEmail(croom, courseWM['creatorUserId'], creatorEmails,
                                                                 [Ent.COURSE, course['id'], entityIDType, courseWM['id'],
                                                                  Ent.CREATOR_ID, courseWM['creatorUserId']], i, count)
    if showTopicNames:
      topicId = courseWM.get('topicId')
      if topicId:
        courseWM['topicName'] = topicNames.get(topicId, topicId)
    row = flattenJSON(courseWM, flattened={'courseId': course['id'], 'courseName': course['name']}, timeObjects=TimeObjects,
                      simpleLists=['studentIds'] if showStudentsAsList else None, delimiter=delimiter)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'courseId': course['id'], 'courseName': course['name'],
                              'JSON': json.dumps(cleanJSON(courseWM, timeObjects=TimeObjects),
                                                 ensure_ascii=False, sort_keys=True)})

  croom = buildGAPIObject(API.CLASSROOM)
  if entityIDType == Ent.COURSE_WORK_ID:
    SortTitles = COURSE_WORK_SORT_TITLES
    IndexedTitles = COURSE_WORK_INDEXED_TITLES
    TimeObjects = COURSE_WORK_TIME_OBJECTS
    OrderbyChoiceMap = COURSE_WORK_ORDERBY_CHOICE_MAP
    FieldsChoiceMap = COURSE_WORK_FIELDS_CHOICE_MAP
    IDArguments = COURSE_WORK_ID_ARGUMENTS
    OBIDEntity = Cmd.OB_COURSE_WORK_ID_ENTITY
    StateArguments = COURSE_WORK_STATE_ARGUMENTS
    OBStateList = Cmd.OB_COURSE_WORK_STATE_LIST
    CSVTitle = 'Course Work'
    service = croom.courses().courseWork()
    items = 'courseWork'
  else:
    SortTitles = COURSE_MATERIAL_SORT_TITLES
    IndexedTitles = COURSE_MATERIAL_INDEXED_TITLES
    TimeObjects = COURSE_MATERIAL_TIME_OBJECTS
    OrderbyChoiceMap = COURSE_MATERIAL_ORDERBY_CHOICE_MAP
    FieldsChoiceMap = COURSE_MATERIAL_FIELDS_CHOICE_MAP
    IDArguments = COURSE_MATERIAL_ID_ARGUMENTS
    OBIDEntity = Cmd.OB_COURSE_MATERIAL_ID_ENTITY
    StateArguments = COURSE_MATERIAL_STATE_ARGUMENTS
    OBStateList = Cmd.OB_COURSE_MATERIAL_STATE_LIST
    CSVTitle = 'Course Work Material'
    service = croom.courses().courseWorkMaterials()
    items = 'courseWorkMaterial'
  csvPF = CSVPrintFile(['courseId', 'courseName'], SortTitles, IndexedTitles)
  FJQC = FormatJSONQuoteChar(csvPF)
  fieldsList = []
  courseSelectionParameters = _initCourseSelectionParameters()
  courseWMSelectionParameters = _initCourseWMSelectionParameters()
  courseItemFilter = _initCourseItemFilter()
  courseShowProperties = _initCourseShowProperties(['name'])
  OBY = OrderBy(OrderbyChoiceMap)
  creatorEmails = {}
  showCreatorEmail = showTopicNames = False
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  countsOnly = showStudentsAsList = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif _getCourseSelectionParameters(myarg, courseSelectionParameters):
      pass
    elif _getCourseWMSelectionParameters(myarg, courseWMSelectionParameters,
                                         IDArguments, OBIDEntity,
                                         StateArguments, OBStateList):
      pass
    elif _getCourseItemFilter(myarg, courseItemFilter, COURSE_CUS_FILTER_FIELDS_MAP):
      pass
    elif myarg == 'orderby':
      OBY.GetChoice()
    elif myarg in {'showcreatoremails', 'creatoremail'}:
      showCreatorEmail = True
    elif myarg == 'showtopicnames':
      showTopicNames = True
    elif getFieldsList(myarg, FieldsChoiceMap, fieldsList, initialField='id'):
      pass
    elif myarg == 'showstudentsaslist':
      showStudentsAsList = getBoolean()
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    elif myarg == 'countsonly':
      countsOnly = True
      csvPF.AddTitles(items)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if showCreatorEmail and fieldsList:
    fieldsList.append('creatorUserId')
  if showTopicNames and fieldsList:
    fieldsList.append('topicId')
  if entityIDType == Ent.COURSE_WORK_ID:
    kwargs = {'courseWorkStates': courseWMSelectionParameters['courseWMStates']}
  else:
    kwargs = {'courseWorkMaterialStates': courseWMSelectionParameters['courseWMStates']}
  coursesInfo = _getCoursesInfo(croom, courseSelectionParameters, courseShowProperties)
  if coursesInfo is None:
    return
  applyCourseItemFilter = _setApplyCourseItemFilter(courseItemFilter, fieldsList)
  courseWMIds = courseWMSelectionParameters['courseWMIds']
  courseWMIdsLists = courseWMIds if isinstance(courseWMIds, dict) else {}
  i = 0
  count = len(coursesInfo)
  for course in coursesInfo:
    i += 1
    courseId = course['id']
    if showTopicNames:
      topicNames = _getTopicNames(croom, courseId)
    if courseWMIdsLists:
      courseWMIds = courseWMIdsLists[courseId]
    if not courseWMIds:
      fields = getItemFieldsFromFieldsList(items, fieldsList)
      printGettingAllEntityItemsForWhom(entityIDType, Ent.TypeName(Ent.COURSE, courseId), i, count,
                                        _gettingCourseEntityQuery(entityStateType, courseWMSelectionParameters['courseWMStates']))
      try:
        results = callGAPIpages(service, 'list', items,
                                pageMessage=getPageMessageForWhom(),
                                throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS,
                                courseId=courseId, orderBy=OBY.orderBy,
                                fields=fields, pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS], **kwargs)
        if not countsOnly:
          for courseWM in results:
            _printCourseWM(course, courseWM, i, count)
        else:
          _printCourseItemCount(course, results, items, applyCourseItemFilter, courseItemFilter, csvPF)
      except (GAPI.notFound, GAPI.insufficientPermissions, GAPI.permissionDenied, GAPI.forbidden, GAPI.invalidArgument) as e:
        entityActionFailedWarning([Ent.COURSE, removeCourseIdScope(courseId)], str(e), i, count)
    else:
      jcount = len(courseWMIds)
      if jcount == 0:
        continue
      fields = getFieldsFromFieldsList(fieldsList)
      j = 0
      for courseWMId in courseWMIds:
        j += 1
        try:
          courseWM = callGAPI(service, 'get',
                              throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS,
                              courseId=courseId, id=courseWMId, fields=fields)
          _printCourseWM(course, courseWM, i, count)
        except GAPI.notFound:
          entityDoesNotHaveItemWarning([Ent.COURSE_NAME, course['name'], entityIDType, courseWMId], j, jcount)
        except (GAPI.insufficientPermissions, GAPI.permissionDenied, GAPI.forbidden, GAPI.invalidArgument) as e:
          entityActionFailedWarning([Ent.COURSE_NAME, course['name'], entityIDType, courseWMId], str(e), j, jcount)
  csvPF.writeCSVfile(CSVTitle)

# gam print course-materials [todrive <ToDriveAttribute>*]
#	(course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] states <CourseStateList>])
#	(materialids <CourseMaterialIDEntity>)|((materialstates <CourseMaterialStateList>)*
#	(orderby <CourseMaterialsOrderByFieldName> [ascending|descending])*)
#	[showcreatoremails|creatoremail] [showtopicnames] [fields <CourseMaterialFieldNameList>]
#	[timefilter creationtime|updatetime|scheduledtime] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
#	[countsonly] [formatjson [quotechar <Character>]]
def doPrintCourseMaterials():
  doPrintCourseWM(Ent.COURSE_MATERIAL_ID, Ent.COURSE_MATERIAL_STATE)

# gam print course-work [todrive <ToDriveAttribute>*]
#	(course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] states <CourseStateList>])
#	(workids <CourseWorkIDEntity>)|((workstates <CourseWorkStateList>)*
#	(orderby <CourseWorkOrderByFieldName> [ascending|descending])*)
#	[showcreatoremails|creatoremail] [showtopicnames] [fields <CourseWorkFieldNameList>]
#	[showstudentsaslist [<Boolean>]] [delimiter <Character>]
#	[timefilter creationtime|updatetime] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
#	[countsonly] [formatjson [quotechar <Character>]]
def doPrintCourseWork():
  doPrintCourseWM(Ent.COURSE_WORK_ID, Ent.COURSE_WORK_STATE)

COURSE_SUBMISSION_FIELDS_CHOICE_MAP = {
  'alternatelink': 'alternateLink',
  'assignedgrade': 'assignedGrade',
  'courseid': 'courseId',
  'coursesubmissionid': 'id',
  'courseworkid': 'courseWorkId',
  'courseworktype': 'courseWorkType',
  'creationtime': 'creationTime',
  'draftgrade': 'draftGrade',
  'id': 'id',
  'late': 'late',
  'state': 'state',
  'submissionhistory': 'submissionHistory',
  'submissionid': 'id',
  'updatetime': 'updateTime',
  'userid': 'userId',
  'workid': 'courseWorkId',
  'worktype': 'courseWorkType',
  }
COURSE_SUBMISSION_TIME_OBJECTS = {'creationTime', 'updateTime', 'gradeTimestamp', 'stateTimestamp'}

def _gettingCourseSubmissionQuery(courseSubmissionStates, late, userId):
  query = ''
  if courseSubmissionStates:
    query += f'{Ent.Choose(Ent.COURSE_SUBMISSION_STATE, len(courseSubmissionStates))}: {",".join(courseSubmissionStates)}, '
  if late:
    query += f'{late}, '
  if userId:
    query += f'{Ent.Singular(Ent.USER)}: {userId}, '
  if query:
    query = query[:-2]
  return query

# gam print course-submissions [todrive <ToDriveAttribute>*]
#	(course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] states <CourseStateList>])
#	(workids <CourseWorkIDEntity>)|((workstates <CourseWorkStateList>)*
#	(orderby <CourseWorkOrderByFieldName> [ascending|descending])*)
#	(submissionids <CourseSubmissionIDEntity>)|((submissionstates <CourseSubmissionStateList>)*) [late|notlate]
#	[fields <CourseSubmissionFieldNameList>] [showuserprofile]
#	[timefilter creationtime|updatetime] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
#	[countsonly] [formatjson [quotechar <Character>]]
def doPrintCourseSubmissions():
  def _printCourseSubmission(course, courseSubmission):
    if applyCourseItemFilter and not _courseItemPassesFilter(courseSubmission, courseItemFilter):
      return
    if showUserProfile:
      userId = courseSubmission.get('userId')
      if userId:
        if userId not in userProfiles:
          try:
            userProfile = callGAPI(tcroom.userProfiles(), 'get',
                                   throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                                   retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                   userId=userId, fields='emailAddress,name')
            userProfiles[userId] = {'profile': {'emailAddress': userProfile.get('emailAddress', ''), 'name': userProfile['name']}}
          except (GAPI.notFound, GAPI.permissionDenied, GAPI.serviceNotAvailable):
            userProfiles[userId] = {'profile': {'emailAddress': '', 'name': {'givenName': '', 'familyName': '', 'fullName': ''}}}
        courseSubmission.update(userProfiles[userId])
    row = flattenJSON(courseSubmission, flattened={'courseId': course['id'], 'courseName': course['name']}, timeObjects=COURSE_SUBMISSION_TIME_OBJECTS)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'courseId': course['id'], 'courseName': course['name'],
                              'JSON': json.dumps(cleanJSON(courseSubmission, timeObjects=COURSE_SUBMISSION_TIME_OBJECTS),
                                                 ensure_ascii=False, sort_keys=True)})

  croom = buildGAPIObject(API.CLASSROOM)
  csvPF = CSVPrintFile(['courseId', 'courseName'],
                       ['courseId', 'courseName', 'courseWorkId', 'id', 'userId',
                        f'profile{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}emailAddress',
                        f'profile{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}name{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}givenName',
                        f'profile{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}name{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}familyName',
                        f'profile{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}name{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}fullName',
                        'state'],
                       ['submissionHistory'])
  FJQC = FormatJSONQuoteChar(csvPF)
  fieldsList = []
  courseSelectionParameters = _initCourseSelectionParameters()
  courseWMSelectionParameters = _initCourseWMSelectionParameters()
  courseItemFilter = _initCourseItemFilter()
  courseShowProperties = _initCourseShowProperties(['name'])
  courseSubmissionStates = []
  courseSubmissionIds = []
  OBY = OrderBy(COURSE_WORK_ORDERBY_CHOICE_MAP)
  late = None
  userProfiles = {}
  countsOnly = showUserProfile = False
  items = 'courseSubmissions'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif _getCourseSelectionParameters(myarg, courseSelectionParameters):
      pass
    elif _getCourseWMSelectionParameters(myarg, courseWMSelectionParameters,
                                         COURSE_WORK_ID_ARGUMENTS, Cmd.OB_COURSE_WORK_ID_ENTITY,
                                         COURSE_WORK_STATE_ARGUMENTS, Cmd.OB_COURSE_WORK_STATE_LIST):
      pass
    elif _getCourseItemFilter(myarg, courseItemFilter, COURSE_CU_FILTER_FIELDS_MAP):
      pass
    elif myarg == 'orderby':
      OBY.GetChoice()
    elif myarg in {'submissionid', 'submissionids', 'coursesubmissionid', 'coursesubmissionids'}:
      courseSubmissionIds = getEntityList(Cmd.OB_COURSE_SUBMISSION_ID_ENTITY)
    elif myarg in {'submissionstate', 'submissionstates', 'coursesubmissionstate', 'coursesubmissionstates'}:
      _getCourseStates(Cmd.OB_COURSE_SUBMISSION_STATE_LIST, courseSubmissionStates)
    elif myarg == 'late':
      late = 'LATE_ONLY'
    elif myarg == 'notlate':
      late = 'NOT_LATE_ONLY'
    elif myarg == 'showuserprofile':
      showUserProfile = True
    elif getFieldsList(myarg, COURSE_SUBMISSION_FIELDS_CHOICE_MAP, fieldsList, initialField='id'):
      pass
    elif myarg == 'countsonly':
      countsOnly = True
      csvPF.AddTitles(items)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  coursesInfo = _getCoursesInfo(croom, courseSelectionParameters, courseShowProperties, getOwnerId=True)
  if coursesInfo is None:
    return
  applyCourseItemFilter = _setApplyCourseItemFilter(courseItemFilter, fieldsList)
  courseWorkIds = courseWMSelectionParameters['courseWMIds']
  courseWorkIdsLists = courseWorkIds if isinstance(courseWorkIds, dict) else {}
  courseSubmissionIdsLists = courseSubmissionIds if isinstance(courseSubmissionIds, dict) else {}
  i = 0
  count = len(coursesInfo)
  for course in coursesInfo:
    i += 1
    _, tcroom = buildGAPIServiceObject(API.CLASSROOM, f"uid:{course['ownerId']}")
    if tcroom is None:
      continue
    submissionsCount = 0
    courseId = course['id']
    if courseWorkIdsLists:
      courseWorkIds = courseWorkIdsLists[courseId]
    if not courseWorkIds:
      printGettingAllEntityItemsForWhom(Ent.COURSE_WORK_ID, Ent.TypeName(Ent.COURSE, courseId), i, count,
                                        _gettingCourseEntityQuery(Ent.COURSE_WORK_STATE, courseWMSelectionParameters['courseWMStates']))
      try:
        results = callGAPIpages(croom.courses().courseWork(), 'list', 'courseWork',
                                pageMessage=getPageMessageForWhom(),
                                throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE],
                                retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                courseId=courseId, courseWorkStates=courseWMSelectionParameters['courseWMStates'], orderBy=OBY.orderBy,
                                fields='nextPageToken,courseWork(id)', pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS])
        courseWorkIdsForCourse = [courseWork['id'] for courseWork in results]
      except GAPI.notFound:
        continue
      except (GAPI.insufficientPermissions, GAPI.permissionDenied, GAPI.forbidden, GAPI.invalidArgument, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.COURSE, removeCourseIdScope(courseId)], str(e), i, count)
        continue
    else:
      courseWorkIdsForCourse = courseWorkIds
    jcount = len(courseWorkIdsForCourse)
    if jcount == 0:
      continue
    j = 0
    for courseWorkId in courseWorkIdsForCourse:
      j += 1
      if not courseSubmissionIds:
        fields = getItemFieldsFromFieldsList('studentSubmissions', fieldsList)
        printGettingAllEntityItemsForWhom(Ent.COURSE_SUBMISSION_ID, Ent.TypeName(Ent.COURSE_WORK_ID, courseWorkId), j, jcount,
                                          _gettingCourseSubmissionQuery(courseSubmissionStates, late, courseSelectionParameters['studentId']))
        try:
          results = callGAPIpages(croom.courses().courseWork().studentSubmissions(), 'list', 'studentSubmissions',
                                  pageMessage=getPageMessageForWhom(),
                                  throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE],
                                  retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                  courseId=courseId, courseWorkId=courseWorkId, states=courseSubmissionStates, late=late, userId=courseSelectionParameters['studentId'],
                                  fields=fields, pageSize=GC.Values[GC.CLASSROOM_MAX_RESULTS])
          if not countsOnly:
            for submission in results:
              _printCourseSubmission(course, submission)
          else:
            submissionsCount += _printCourseItemCount(course, results, items, applyCourseItemFilter, courseItemFilter, None)
        except GAPI.notFound:
          entityDoesNotHaveItemWarning([Ent.COURSE_NAME, course['name'], Ent.COURSE_WORK_ID, courseWorkId], j, jcount)
        except (GAPI.insufficientPermissions, GAPI.permissionDenied, GAPI.forbidden, GAPI.invalidArgument, GAPI.serviceNotAvailable) as e:
          entityActionFailedWarning([Ent.COURSE_NAME, course['name'], Ent.COURSE_WORK_ID, courseWorkId], str(e), j, jcount)
      else:
        if courseSubmissionIdsLists:
          if not GM.Globals[GM.CSV_SUBKEY_FIELD]:
            courseSubmissionIds = courseSubmissionIdsLists[courseWorkId]
          else:
            courseSubmissionIds = courseSubmissionIdsLists[courseId][courseWorkId]
        kcount = len(courseSubmissionIds)
        if kcount == 0:
          continue
        fields = f'{",".join(set(fieldsList))}' if fieldsList else None
        k = 0
        for courseSubmissionId in courseSubmissionIds:
          k += 1
          try:
            submission = callGAPI(croom.courses().courseWork().studentSubmissions(), 'get',
                                  throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE],
                                  retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                  courseId=courseId, courseWorkId=courseWorkId, id=courseSubmissionId,
                                  fields=fields)
            _printCourseSubmission(course, submission)
          except GAPI.notFound:
            entityDoesNotHaveItemWarning([Ent.COURSE_NAME, course['name'], Ent.COURSE_WORK_ID, courseWorkId, Ent.COURSE_SUBMISSION_ID, courseSubmissionId], k, kcount)
          except (GAPI.insufficientPermissions, GAPI.permissionDenied, GAPI.forbidden, GAPI.invalidArgument, GAPI.serviceNotAvailable) as e:
            entityActionFailedWarning([Ent.COURSE_NAME, course['name'], Ent.COURSE_WORK_ID, courseWorkId, Ent.COURSE_SUBMISSION_ID, courseSubmissionId], str(e), k, kcount)
    if countsOnly:
      csvPF.WriteRowTitles({'courseId': course['id'], 'courseName': course['name'], items: submissionsCount})
  csvPF.writeCSVfile('Course Submissions')

COURSE_PARTICIPANTS_SORT_TITLES = ['courseId', 'courseName', 'userRole', 'userId']

# gam print course-participants [todrive <ToDriveAttribute>*]
#	(course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] [states <CourseStateList>])
#	[show all|students|teachers]
#	[showitemcountonly] [formatjson [quotechar <Character>]]
def doPrintCourseParticipants():
  croom = buildGAPIObject(API.CLASSROOM)
  csvPF = CSVPrintFile(['courseId', 'courseName'])
  FJQC = FormatJSONQuoteChar(csvPF)
  courseSelectionParameters = _initCourseSelectionParameters()
  courseShowProperties = _initCourseShowProperties(['name'])
  courseShowProperties['members'] = 'all'
  showItemCountOnly = False
  useOwnerAccess = GC.Values[GC.USE_COURSE_OWNER_ACCESS]
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif _getCourseSelectionParameters(myarg, courseSelectionParameters):
      pass
    elif myarg == 'show':
      courseShowProperties['members'] = getChoice(COURSE_MEMBER_ARGUMENTS)
    elif myarg == 'showitemcountonly':
      showItemCountOnly = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  coursesInfo = _getCoursesInfo(croom, courseSelectionParameters, courseShowProperties, useOwnerAccess)
  if coursesInfo is None:
    if showItemCountOnly:
      writeStdout('0\n')
    return
  if courseShowProperties['members'] != 'none':
    if courseShowProperties['members'] != 'students':
      if FJQC.formatJSON:
        csvPF.AddJSONTitles('JSON-teachers')
    if courseShowProperties['members'] != 'teachers':
      if FJQC.formatJSON:
        csvPF.AddJSONTitles('JSON-students')
    teachersFields = 'nextPageToken,teachers(userId,profile)'
    studentsFields = 'nextPageToken,students(userId,profile)'
  else:
    teachersFields = studentsFields = None
  itemCount = 0
  count = len(coursesInfo)
  i = 0
  for course in coursesInfo:
    i += 1
    courseId = course['id']
    if useOwnerAccess:
      _, ocroom = buildGAPIServiceObject(API.CLASSROOM, f'uid:{course["ownerId"]}')
      if not ocroom:
        continue
    else:
      ocroom = croom
    _, teachers, students = _getCourseAliasesMembers(croom, ocroom, courseId, courseShowProperties, teachersFields, studentsFields, True, i, count)
    if showItemCountOnly:
      if courseShowProperties['members'] != 'students':
        itemCount += len(teachers)
      if courseShowProperties['members'] != 'teachers':
        itemCount += len(students)
      continue
    if not FJQC.formatJSON:
      if courseShowProperties['members'] != 'none':
        if courseShowProperties['members'] != 'students':
          for member in teachers:
            csvPF.WriteRowTitles(flattenJSON(member, flattened={'courseId': courseId, 'courseName': course['name'], 'userRole': 'TEACHER'}))
        if courseShowProperties['members'] != 'teachers':
          for member in students:
            csvPF.WriteRowTitles(flattenJSON(member, flattened={'courseId': courseId, 'courseName': course['name'], 'userRole': 'STUDENT'}))
    else:
      row = {'courseId': courseId, 'courseName': course['name']}
      if courseShowProperties['members'] != 'none':
        if courseShowProperties['members'] != 'students':
          row['JSON-teachers'] = json.dumps(list(teachers))
        if courseShowProperties['members'] != 'teachers':
          row['JSON-students'] = json.dumps(list(students))
      csvPF.WriteRowNoFilter(row)
  if showItemCountOnly:
    writeStdout(f'{itemCount}\n')
    return
  if not FJQC.formatJSON:
    csvPF.SetSortTitles(COURSE_PARTICIPANTS_SORT_TITLES)
  csvPF.writeCSVfile('Course Participants')

def _batchAddItemsToCourse(croom, courseId, i, count, addParticipants, role):
  _ADD_PART_REASON_TO_MESSAGE_MAP = {GAPI.NOT_FOUND: Msg.DOES_NOT_EXIST,
                                     GAPI.ALREADY_EXISTS: Msg.DUPLICATE,
                                     GAPI.FAILED_PRECONDITION: Msg.NOT_ALLOWED}
  def _callbackAddItemsToCourse(request_id, _, exception):
    ri = request_id.splitlines()
    if exception is None:
      entityActionPerformed([Ent.COURSE, ri[RI_ENTITY], ri[RI_ROLE], ri[RI_ITEM]], int(ri[RI_J]), int(ri[RI_JCOUNT]))
    else:
      http_status, reason, message = checkGAPIError(exception)
      if (reason not in {GAPI.QUOTA_EXCEEDED, GAPI.SERVICE_NOT_AVAILABLE}) and ((reason != GAPI.NOT_FOUND) or (ri[RI_ROLE] == Ent.COURSE_ALIAS)):
        if reason in [GAPI.FORBIDDEN, GAPI.BACKEND_ERROR]:
          errMsg = getPhraseDNEorSNA(ri[RI_ITEM])
        else:
          errMsg = getHTTPError(_ADD_PART_REASON_TO_MESSAGE_MAP, http_status, reason, message)
          if (reason == GAPI.PERMISSION_DENIED) and (ri[RI_ROLE] in {Ent.STUDENT, Ent.TEACHER}) and ('CannotDirectAddUser' in errMsg):
            errMsg += f' Add external user with: gam user {ri[RI_ITEM]} create classroominvitation courses {ri[RI_ENTITY]} role {Ent.Singular(ri[RI_ROLE])}'
        entityActionFailedWarning([Ent.COURSE, ri[RI_ENTITY], ri[RI_ROLE], ri[RI_ITEM]], errMsg, int(ri[RI_J]), int(ri[RI_JCOUNT]))
        return
      waitOnFailure(1, 10, reason, message)
      try:
        callGAPI(service, 'create',
                 throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.BACKEND_ERROR,
                               GAPI.ALREADY_EXISTS, GAPI.FAILED_PRECONDITION,
                               GAPI.QUOTA_EXCEEDED, GAPI.SERVICE_NOT_AVAILABLE],
                 retryReasons=[GAPI.NOT_FOUND, GAPI.SERVICE_NOT_AVAILABLE], triesLimit=0 if reason != GAPI.NOT_FOUND else 3,
                 courseId=addCourseIdScope(ri[RI_ENTITY]),
                 body={attribute: ri[RI_ITEM] if ri[RI_ROLE] != Ent.COURSE_ALIAS else addCourseAliasScope(ri[RI_ITEM])},
                 fields='')
      except (GAPI.notFound, GAPI.backendError, GAPI.forbidden):
        entityActionFailedWarning([Ent.COURSE, ri[RI_ENTITY], ri[RI_ROLE], ri[RI_ITEM]], getPhraseDNEorSNA(ri[RI_ITEM]), int(ri[RI_J]), int(ri[RI_JCOUNT]))
      except GAPI.alreadyExists:
        entityActionFailedWarning([Ent.COURSE, ri[RI_ENTITY], ri[RI_ROLE], ri[RI_ITEM]], Msg.DUPLICATE, int(ri[RI_J]), int(ri[RI_JCOUNT]))
      except GAPI.failedPrecondition:
        entityActionFailedWarning([Ent.COURSE, ri[RI_ENTITY], ri[RI_ROLE], ri[RI_ITEM]], Msg.NOT_ALLOWED, int(ri[RI_J]), int(ri[RI_JCOUNT]))
      except (GAPI.quotaExceeded, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.COURSE, ri[RI_ENTITY], ri[RI_ROLE], ri[RI_ITEM]], str(e), int(ri[RI_J]), int(ri[RI_JCOUNT]))

  if role == Ent.STUDENT:
    service = croom.courses().students()
    attribute = 'userId'
  elif role == Ent.TEACHER:
    service = croom.courses().teachers()
    attribute = 'userId'
  elif role == Ent.COURSE_ALIAS:
    service = croom.courses().aliases()
    attribute = 'alias'
  else: # role == Ent.COURSE_TOPIC:
    service = croom.courses().topics()
    attribute = 'name'
  method = getattr(service, 'create')
  Act.Set(Act.ADD)
  jcount = len(addParticipants)
  noScopeCourseId = removeCourseIdScope(courseId)
  entityPerformActionNumItems([Ent.COURSE, noScopeCourseId], jcount, role, i, count)
  Ind.Increment()
  svcargs = dict([('courseId', courseId), ('body', {attribute: None}), ('fields', '')]+GM.Globals[GM.EXTRA_ARGS_LIST])
  dbatch = croom.new_batch_http_request(callback=_callbackAddItemsToCourse)
  bcount = 0
  j = 0
  for participant in addParticipants:
    j += 1
    svcparms = svcargs.copy()
    if role in {Ent.STUDENT, Ent.TEACHER}:
      svcparms['body'][attribute] = cleanItem = normalizeEmailAddressOrUID(participant)
    elif role == Ent.COURSE_ALIAS:
      svcparms['body'][attribute] = addCourseAliasScope(participant)
      cleanItem = removeCourseAliasScope(svcparms['body'][attribute])
    else: # role == Ent.COURSE_TOPIC:
      svcparms['body'][attribute] = cleanItem = participant
    dbatch.add(method(**svcparms), request_id=batchRequestID(noScopeCourseId, 0, 0, j, jcount, cleanItem, role))
    bcount += 1
    if bcount >= GC.Values[GC.BATCH_SIZE]:
      executeBatch(dbatch)
      dbatch = croom.new_batch_http_request(callback=_callbackAddItemsToCourse)
      bcount = 0
  if bcount > 0:
    dbatch.execute()
  Ind.Decrement()

def _batchRemoveItemsFromCourse(croom, courseId, i, count, removeParticipants, role):
  _REMOVE_PART_REASON_TO_MESSAGE_MAP = {GAPI.NOT_FOUND: Msg.DOES_NOT_EXIST,
                                        GAPI.FORBIDDEN: Msg.FORBIDDEN,
                                        GAPI.PERMISSION_DENIED: Msg.PERMISSION_DENIED}
  def _callbackRemoveItemsFromCourse(request_id, _, exception):
    ri = request_id.splitlines()
    if exception is None:
      entityActionPerformed([Ent.COURSE, ri[RI_ENTITY], ri[RI_ROLE], ri[RI_ITEM]], int(ri[RI_J]), int(ri[RI_JCOUNT]))
    else:
      http_status, reason, message = checkGAPIError(exception)
      if reason not in {GAPI.QUOTA_EXCEEDED, GAPI.SERVICE_NOT_AVAILABLE}:
        if reason == GAPI.NOT_FOUND and ri[RI_ROLE] != Ent.COURSE_ALIAS:
          errMsg = f'{Msg.NOT_A} {Ent.Singular(ri[RI_ROLE])}'
        else:
          errMsg = getHTTPError(_REMOVE_PART_REASON_TO_MESSAGE_MAP, http_status, reason, message)
        entityActionFailedWarning([Ent.COURSE, ri[RI_ENTITY], ri[RI_ROLE], ri[RI_ITEM]], errMsg, int(ri[RI_J]), int(ri[RI_JCOUNT]))
        return
      waitOnFailure(1, 10, reason, message)
      try:
        callGAPI(service, 'delete',
                 throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED,
                               GAPI.QUOTA_EXCEEDED, GAPI.SERVICE_NOT_AVAILABLE],
                 retryReasons=[GAPI.NOT_FOUND, GAPI.SERVICE_NOT_AVAILABLE], triesLimit=0 if reason != GAPI.NOT_FOUND else 3,
                 courseId=addCourseIdScope(ri[RI_ENTITY]),
                 body={attribute: ri[RI_ITEM] if ri[RI_ROLE] != Ent.COURSE_ALIAS else addCourseAliasScope(ri[RI_ITEM])},
                 fields='')
      except GAPI.notFound:
        entityActionFailedWarning([Ent.COURSE, ri[RI_ENTITY], ri[RI_ROLE], ri[RI_ITEM]], Msg.DOES_NOT_EXIST, int(ri[RI_J]), int(ri[RI_JCOUNT]))
      except GAPI.forbidden:
        entityActionFailedWarning([Ent.COURSE, ri[RI_ENTITY], ri[RI_ROLE], ri[RI_ITEM]], Msg.FORBIDDEN, int(ri[RI_J]), int(ri[RI_JCOUNT]))
      except GAPI.permissionDenied:
        entityActionFailedWarning([Ent.COURSE, ri[RI_ENTITY], ri[RI_ROLE], ri[RI_ITEM]], Msg.PERMISSION_DENIED, int(ri[RI_J]), int(ri[RI_JCOUNT]))
      except (GAPI.quotaExceeded, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.COURSE, ri[RI_ENTITY], ri[RI_ROLE], ri[RI_ITEM]], str(e), int(ri[RI_J]), int(ri[RI_JCOUNT]))

  if role == Ent.STUDENT:
    service = croom.courses().students()
    attribute = 'userId'
  elif role == Ent.TEACHER:
    service = croom.courses().teachers()
    attribute = 'userId'
  elif role == Ent.COURSE_ALIAS:
    service = croom.courses().aliases()
    attribute = 'alias'
  else: # role == Ent.COURSE_TOPIC:
    service = croom.courses().topics()
    attribute = 'id'
  method = getattr(service, 'delete')
  Act.Set(Act.REMOVE)
  jcount = len(removeParticipants)
  noScopeCourseId = removeCourseIdScope(courseId)
  entityPerformActionNumItems([Ent.COURSE, noScopeCourseId], jcount, role, i, count)
  Ind.Increment()
  svcargs = dict([('courseId', courseId), ('fields', ''), (attribute, None)]+GM.Globals[GM.EXTRA_ARGS_LIST])
  dbatch = croom.new_batch_http_request(callback=_callbackRemoveItemsFromCourse)
  bcount = 0
  j = 0
  for participant in removeParticipants:
    j += 1
    svcparms = svcargs.copy()
    if role in {Ent.STUDENT, Ent.TEACHER}:
      svcparms[attribute] = cleanItem = normalizeEmailAddressOrUID(participant)
    elif role == Ent.COURSE_ALIAS:
      svcparms[attribute] = addCourseAliasScope(participant)
      cleanItem = removeCourseAliasScope(svcparms[attribute])
    else: # role == Ent.COURSE_TOPIC:
      svcparms[attribute] = cleanItem = participant
    dbatch.add(method(**svcparms), request_id=batchRequestID(noScopeCourseId, 0, 0, j, jcount, cleanItem, role))
    bcount += 1
    if bcount >= GC.Values[GC.BATCH_SIZE]:
      executeBatch(dbatch)
      dbatch = croom.new_batch_http_request(callback=_callbackRemoveItemsFromCourse)
      bcount = 0
  if bcount > 0:
    dbatch.execute()
  Ind.Decrement()

def _updateCourseOwner(croom, courseId, owner, i, count):
  action = Act.Get()
  Act.Set(Act.UPDATE_OWNER)
  try:
    callGAPI(croom.courses(), 'patch',
             throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION,
                           GAPI.FORBIDDEN, GAPI.BAD_REQUEST, GAPI.INVALID_ARGUMENT,
                           GAPI.INTERNAL_ERROR, GAPI.SERVICE_NOT_AVAILABLE],
             id=courseId, body={'ownerId': owner}, updateMask='ownerId', fields='ownerId')
    entityActionPerformed([Ent.COURSE, removeCourseIdScope(courseId), Ent.TEACHER, owner], i, count)
  except (GAPI.notFound, GAPI.permissionDenied, GAPI.failedPrecondition,
          GAPI.forbidden, GAPI.badRequest, GAPI.invalidArgument,
          GAPI.internalError, GAPI.serviceNotAvailable) as e:
    errMsg = str(e)
    if '@UserAlreadyOwner' not in errMsg:
      entityActionFailedWarning([Ent.COURSE, removeCourseIdScope(courseId), Ent.TEACHER, owner], errMsg, i, count)
    else:
      entityActionPerformedMessage([Ent.COURSE, removeCourseIdScope(courseId), Ent.TEACHER, owner], Msg.ALREADY_WAS_OWNER, i, count)
  Act.Set(action)

ADD_REMOVE_PARTICIPANT_TYPES_MAP = {
  'alias': Ent.COURSE_ALIAS,
  'aliases': Ent.COURSE_ALIAS,
  'student': Ent.STUDENT,
  'students': Ent.STUDENT,
  'teacher': Ent.TEACHER,
  'teachers': Ent.TEACHER,
  'topic': Ent.COURSE_TOPIC,
  'topics': Ent.COURSE_TOPIC,
  }
CLEAR_SYNC_PARTICIPANT_TYPES_MAP = {
  'student': Ent.STUDENT,
  'students': Ent.STUDENT,
  'teacher': Ent.TEACHER,
  'teachers': Ent.TEACHER,
  }
PARTICIPANT_EN_MAP = {
  Ent.STUDENT: Cmd.ENTITY_STUDENTS,
  Ent.TEACHER: Cmd.ENTITY_TEACHERS,
  }

# gam courses <CourseEntity> create alias <CourseAliasEntity>
# gam course <CourseID> create alias <CourseAlias>
# gam courses <CourseEntity> create topic <CourseTopicEntity>
# gam course <CourseID> create topic <CourseTopic>
# gam courses <CourseEntity> create students <UserTypeEntity>
# gam course <CourseID> create student <EmailAddress>
# gam courses <CourseEntity> create teachers [makefirstteacherowner] <UserTypeEntity>
# gam course <CourseID> create teacher [makefirstteacherowner] <EmailAddress>
def doCourseAddItems(courseIdList, getEntityListArg):
  croom = buildGAPIObject(API.CLASSROOM)
  role = getChoice(ADD_REMOVE_PARTICIPANT_TYPES_MAP, mapChoice=True)
  if role == Ent.TEACHER:
    makeFirstTeacherOwner = checkArgumentPresent(['makefirstteacherowner'])
  else:
    makeFirstTeacherOwner = False
  if not getEntityListArg:
    if role in {Ent.STUDENT, Ent.TEACHER}:
      addItems = getStringReturnInList(Cmd.OB_EMAIL_ADDRESS)
    elif role == Ent.COURSE_ALIAS:
      addItems = getStringReturnInList(Cmd.OB_COURSE_ALIAS)
    else: # role == Ent.COURSE_TOPIC:
      addItems = getStringReturnInList(Cmd.OB_COURSE_TOPIC)
    courseParticipantLists = None
  else:
    if role in {Ent.STUDENT, Ent.TEACHER}:
      _, addItems = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS,
                                      typeMap={Cmd.ENTITY_COURSEPARTICIPANTS: PARTICIPANT_EN_MAP[role]},
                                      isSuspended=False, isArchived=False)
    elif role == Ent.COURSE_ALIAS:
      addItems = getEntityList(Cmd.OB_COURSE_ALIAS_ENTITY, shlexSplit=True)
    else: # role == Ent.COURSE_TOPIC:
      addItems = getEntityList(Cmd.OB_COURSE_TOPIC_ENTITY, shlexSplit=True)
    courseParticipantLists = addItems if isinstance(addItems, dict) else None
  if courseParticipantLists is None:
    firstTeacher = None
    if makeFirstTeacherOwner and addItems:
      firstTeacher = normalizeEmailAddressOrUID(addItems[0])
  checkForExtraneousArguments()
  i, count, coursesInfo = _getCoursesOwnerInfo(croom, courseIdList, role == Ent.COURSE_TOPIC,
                                               addCIIdScope=courseParticipantLists is None)
  for courseId, courseInfo in coursesInfo.items():
    i += 1
    if courseParticipantLists:
      addItems = courseParticipantLists[courseId]
      firstTeacher = None
      if makeFirstTeacherOwner and addItems:
        firstTeacher = normalizeEmailAddressOrUID(addItems[0])
      courseId = addCourseIdScope(courseId)
    _batchAddItemsToCourse(courseInfo['croom'], courseId, i, count, addItems, role)
    if makeFirstTeacherOwner and firstTeacher:
      _updateCourseOwner(courseInfo['croom'], courseId, firstTeacher, i, count)

# gam courses <CourseEntity> remove alias <CourseAliasEntity>
# gam course <CourseID> remove alias <CourseAlias>
# gam courses <CourseEntity> remove topic <CourseTopicIDEntity>
# gam course <CourseID> remove topic <CourseTopicID>
# gam courses <CourseEntity> remove teachers|students [owneracccess] <UserTypeEntity>
# gam course <CourseID> remove teacher|student [owneracccess] <EmailAddress>
def doCourseRemoveItems(courseIdList, getEntityListArg):
  croom = buildGAPIObject(API.CLASSROOM)
  role = getChoice(ADD_REMOVE_PARTICIPANT_TYPES_MAP, mapChoice=True)
  if not getEntityListArg:
    if role in {Ent.STUDENT, Ent.TEACHER}:
      useOwnerAccess = GC.Values[GC.USE_COURSE_OWNER_ACCESS]
      if checkArgumentPresent(OWNER_ACCESS_OPTIONS):
        useOwnerAccess = True
      removeItems = getStringReturnInList(Cmd.OB_EMAIL_ADDRESS)
    elif role == Ent.COURSE_ALIAS:
      useOwnerAccess = False
      removeItems = getStringReturnInList(Cmd.OB_COURSE_ALIAS)
    else: # role == Ent.COURSE_TOPIC:
      useOwnerAccess = True
      removeItems = getStringReturnInList(Cmd.OB_COURSE_TOPIC_ID)
    courseParticipantLists = None
  else:
    if role in {Ent.STUDENT, Ent.TEACHER}:
      useOwnerAccess = checkArgumentPresent(OWNER_ACCESS_OPTIONS)
      _, removeItems = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS,
                                         typeMap={Cmd.ENTITY_COURSEPARTICIPANTS: PARTICIPANT_EN_MAP[role]})
    elif role == Ent.COURSE_ALIAS:
      useOwnerAccess = False
      removeItems = getEntityList(Cmd.OB_COURSE_ALIAS_ENTITY, shlexSplit=True)
    else: # role == Ent.COURSE_TOPIC:
      useOwnerAccess = True
      removeItems = getEntityList(Cmd.OB_COURSE_TOPIC_ID_ENTITY, shlexSplit=True)
    courseParticipantLists = removeItems if isinstance(removeItems, dict) else None
  checkForExtraneousArguments()
  i, count, coursesInfo = _getCoursesOwnerInfo(croom, courseIdList, useOwnerAccess,
                                               addCIIdScope=courseParticipantLists is None)
  for courseId, courseInfo in coursesInfo.items():
    i += 1
    if courseParticipantLists:
      removeItems = courseParticipantLists[courseId]
      courseId = addCourseIdScope(courseId)
    _batchRemoveItemsFromCourse(courseInfo['croom'], courseId, i, count, removeItems, role)

# gam courses <CourseEntity> clear teachers|students
# gam course <CourseID> clear teacher|student
def doCourseClearParticipants(courseIdList, _):
  croom = buildGAPIObject(API.CLASSROOM)
  role = getChoice(CLEAR_SYNC_PARTICIPANT_TYPES_MAP, mapChoice=True)
  checkForExtraneousArguments()
  i, count, coursesInfo = _getCoursesOwnerInfo(croom, courseIdList, GC.Values[GC.USE_COURSE_OWNER_ACCESS])
  for courseId, courseInfo in coursesInfo.items():
    i += 1
    removeParticipants = getItemsToModify(PARTICIPANT_EN_MAP[role], courseId, noListConversion=True)
    if GM.Globals[GM.CLASSROOM_SERVICE_NOT_AVAILABLE]:
      continue
    _batchRemoveItemsFromCourse(courseInfo['croom'], courseId, i, count, removeParticipants, role)

# gam courses <CourseEntity> sync students [addonly|removeonly] <UserTypeEntity>
# gam course <CourseID> sync students [addonly|removeonly] <UserTypeEntity>
# gam courses <CourseEntity> sync teachers [addonly|removeonly] [makefirstteacherowner] <UserTypeEntity>
# gam course <CourseID> sync teachers [addonly|removeonly] [makefirstteacherowner] <UserTypeEntity>
def doCourseSyncParticipants(courseIdList, _):
  croom = buildGAPIObject(API.CLASSROOM)
  role = getChoice(CLEAR_SYNC_PARTICIPANT_TYPES_MAP, mapChoice=True)
  if role == Ent.TEACHER:
    makeFirstTeacherOwner = checkArgumentPresent(['makefirstteacherowner'])
  else:
    makeFirstTeacherOwner = False
  syncOperation = getSyncOperation()
  _, syncParticipants = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS,
                                          typeMap={Cmd.ENTITY_COURSEPARTICIPANTS: PARTICIPANT_EN_MAP[role]},
                                          isSuspended=False, isArchived=False)
  checkForExtraneousArguments()
  courseParticipantLists = syncParticipants if isinstance(syncParticipants, dict) else None
  if courseParticipantLists is None:
    syncParticipantsSet = set()
    firstTeacher = None
    if syncParticipants:
      for user in syncParticipants:
        syncParticipantsSet.add(normalizeEmailAddressOrUID(user))
      if makeFirstTeacherOwner:
        firstTeacher = normalizeEmailAddressOrUID(syncParticipants[0])
  i, count, coursesInfo = _getCoursesOwnerInfo(croom, courseIdList, GC.Values[GC.USE_COURSE_OWNER_ACCESS],
                                               addCIIdScope=courseParticipantLists is None)
  for courseId, courseInfo in coursesInfo.items():
    i += 1
    if courseParticipantLists:
      syncParticipantsSet = set()
      firstTeacher = None
      if courseParticipantLists[courseId]:
        for user in courseParticipantLists[courseId]:
          syncParticipantsSet.add(normalizeEmailAddressOrUID(user))
        if makeFirstTeacherOwner:
          firstTeacher = normalizeEmailAddressOrUID(courseParticipantLists[courseId][0])
        courseId = addCourseIdScope(courseId)
    currentParticipantsSet = set()
    currentParticipants = getItemsToModify(PARTICIPANT_EN_MAP[role], courseId, noListConversion=True)
    if GM.Globals[GM.CLASSROOM_SERVICE_NOT_AVAILABLE]:
      continue
    for user in currentParticipants:
      currentParticipantsSet.add(normalizeEmailAddressOrUID(user))
    if syncOperation != 'removeonly':
      _batchAddItemsToCourse(croom, courseId, i, count, list(syncParticipantsSet-currentParticipantsSet), role)
    if makeFirstTeacherOwner and firstTeacher:
      _updateCourseOwner(croom, courseId, firstTeacher, i, count)
    if syncOperation != 'addonly':
      _batchRemoveItemsFromCourse(courseInfo['croom'], courseId, i, count, list(currentParticipantsSet-syncParticipantsSet), role)

def studentUnknownWarning(studentId, errMsg, i, count):
  setSysExitRC(SERVICE_NOT_APPLICABLE_RC)
  writeStderr(formatKeyValueList(Ind.Spaces(),
                                 [Ent.Singular(Ent.STUDENT), studentId, f'{Msg.SERVICE_NOT_APPLICABLE}: {errMsg}'],
                                 currentCountNL(i, count)))

def getGuardianEntity():
  guardians = getEntityList(Cmd.OB_GUARDIAN_ENTITY)
  if isinstance(guardians, dict):
    return (guardians, guardians)
  return ([normalizeEmailAddressOrUID(guardian) for guardian in guardians], None)

def getGuardianEmails(user, guardianEntity, guardianEntityList):
  studentId = normalizeStudentGuardianEmailAddressOrUID(user)
  if guardianEntityList:
    guardianEmails = [normalizeEmailAddressOrUID(guardian) for guardian in guardianEntityList[user]]
  else:
    guardianEmails = guardianEntity
  return (studentId, guardianEmails, len(guardianEmails))

def getGuardianInvitationEntity():
  invitations = getEntityList(Cmd.OB_GUARDIAN_INVITATION_ID_ENTITY)
  if isinstance(invitations, dict):
    return (invitations, invitations)
  return (invitations, None)

def getGuardianInvitationIds(user, invitationEntity, invitationEntityList):
  studentId = normalizeStudentGuardianEmailAddressOrUID(user)
  if invitationEntityList:
    invitationIds = invitationEntityList[user]
  else:
    invitationIds = invitationEntity
  return (studentId, invitationIds, len(invitationIds))

GUARDIAN_CLASS_UNDEFINED = 0
GUARDIAN_CLASS_ACCEPTED = 1
GUARDIAN_CLASS_INVITATIONS = 2
GUARDIAN_CLASS_ALL = 3

GUARDIAN_CLASS_MAP = {
  'all': GUARDIAN_CLASS_ALL,
  'accepted': GUARDIAN_CLASS_ACCEPTED,
  'invitation': GUARDIAN_CLASS_INVITATIONS,
  'invitations': GUARDIAN_CLASS_INVITATIONS,
  }
GUARDIAN_CLASS_ENTITY = {
  GUARDIAN_CLASS_ALL: Ent.GUARDIAN_AND_INVITATION,
  GUARDIAN_CLASS_ACCEPTED: Ent.GUARDIAN,
  GUARDIAN_CLASS_INVITATIONS: Ent.GUARDIAN_INVITATION,
  }

def _inviteGuardian(croom, studentId, guardianEmail, i=0, count=0, j=0, jcount=0):
  body = {'invitedEmailAddress': guardianEmail}
  try:
    result = callGAPI(croom.userProfiles().guardianInvitations(), 'create',
                      throwReasons=[GAPI.NOT_FOUND, GAPI.ALREADY_EXISTS,
                                    GAPI.INVALID_ARGUMENT, GAPI.BAD_REQUEST, GAPI.FORBIDDEN,
                                    GAPI.PERMISSION_DENIED, GAPI.RESOURCE_EXHAUSTED, GAPI.SERVICE_NOT_AVAILABLE],
                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                      studentId=studentId, body=body, fields='invitationId')
    entityActionPerformed([Ent.STUDENT, studentId, Ent.GUARDIAN, body['invitedEmailAddress'], Ent.GUARDIAN_INVITATION, result['invitationId']], j, jcount)
    return 1
  except GAPI.notFound:
    entityUnknownWarning(Ent.STUDENT, studentId, i, count)
    return -1
  except GAPI.alreadyExists:
    entityActionFailedWarning([Ent.STUDENT, studentId, Ent.GUARDIAN, body['invitedEmailAddress']], Msg.DUPLICATE, j, jcount)
    return 0
  except GAPI.resourceExhausted as e:
    entityActionFailedWarning([Ent.STUDENT, studentId, Ent.GUARDIAN, body['invitedEmailAddress']], str(e), j, jcount)
    return -1
  except (GAPI.invalidArgument, GAPI.badRequest, GAPI.forbidden, GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
    studentUnknownWarning(studentId, str(e), i, count)
    return -1

# gam create guardian|guardianinvite|inviteguardian <EmailAddress> <StudentItem>
def doInviteGuardian():
  croom = buildGAPIObject(API.CLASSROOM)
  guardianEmail = getEmailAddress()
  studentId = normalizeStudentGuardianEmailAddressOrUID(getString(Cmd.OB_STUDENT_ITEM))
  checkForExtraneousArguments()
  _inviteGuardian(croom, studentId, guardianEmail)

# gam <UserTypeEntity> create guardian|guardianinvite|inviteguardian <GuardianEntity>
def inviteGuardians(users):
  croom = buildGAPIObject(API.CLASSROOM)
  guardianEntity, guardianEntityList = getGuardianEntity()
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    studentId, guardianEmails, jcount = getGuardianEmails(user, guardianEntity, guardianEntityList)
    entityPerformActionNumItems([Ent.STUDENT, studentId], jcount, Ent.GUARDIAN_INVITATION, i, count)
    Ind.Increment()
    j = 0
    for guardianEmail in guardianEmails:
      j += 1
      if _inviteGuardian(croom, studentId, guardianEmail, i, count, j, jcount) < 0:
        break
    Ind.Decrement()

def _cancelGuardianInvitation(croom, studentId, invitationId, i=0, count=0, j=0, jcount=0):
  try:
    result = callGAPI(croom.userProfiles().guardianInvitations(), 'patch',
                      throwReasons=[GAPI.NOT_FOUND, GAPI.FAILED_PRECONDITION,
                                    GAPI.INVALID_ARGUMENT, GAPI.BAD_REQUEST, GAPI.FORBIDDEN,
                                    GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                      studentId=studentId, invitationId=invitationId, updateMask='state', body={'state': 'COMPLETE'}, fields='invitedEmailAddress')
    entityActionPerformed([Ent.STUDENT, studentId, Ent.GUARDIAN_INVITATION, result['invitedEmailAddress']], j, jcount)
    return 1
  except GAPI.notFound:
    entityActionFailedWarning([Ent.STUDENT, studentId, Ent.GUARDIAN_INVITATION, invitationId], Msg.NOT_FOUND, j, jcount)
    return 0
  except GAPI.failedPrecondition:
    entityActionFailedWarning([Ent.STUDENT, studentId, Ent.GUARDIAN_INVITATION, invitationId], Msg.GUARDIAN_INVITATION_STATUS_NOT_PENDING, j, jcount)
    return 1
  except (GAPI.invalidArgument, GAPI.badRequest, GAPI.serviceNotAvailable) as e:
    entityActionFailedWarning([Ent.STUDENT, studentId, Ent.GUARDIAN_INVITATION, invitationId], str(e), j, jcount)
    return -1
  except (GAPI.forbidden, GAPI.permissionDenied) as e:
    studentUnknownWarning(studentId, str(e), i, count)
    return -1

# gam cancel guardianinvitation|guardianinvitations <GuardianInvitationID> <StudentItem>
def doCancelGuardianInvitation():
  croom = buildGAPIObject(API.CLASSROOM)
  invitationId = getString(Cmd.OB_GUARDIAN_INVITATION_ID)
  studentId = normalizeStudentGuardianEmailAddressOrUID(getString(Cmd.OB_STUDENT_ITEM))
  checkForExtraneousArguments()
  _cancelGuardianInvitation(croom, studentId, invitationId)

# gam <UserTypeEntity> cancel guardianinvitation|guardianinvitations <GuardianInvitationIDEntity>
def cancelGuardianInvitations(users):
  croom = buildGAPIObject(API.CLASSROOM)
  invitationEntity, invitationEntityList = getGuardianInvitationEntity()
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    studentId, invitationIds, jcount = getGuardianInvitationIds(user, invitationEntity, invitationEntityList)
    entityPerformActionNumItems([Ent.STUDENT, studentId], jcount, Ent.GUARDIAN_INVITATION, i, count)
    Ind.Increment()
    j = 0
    for invitationId in invitationIds:
      j += 1
      if _cancelGuardianInvitation(croom, studentId, invitationId, i, count, j, jcount) == -1:
        break
    Ind.Decrement()

def _deleteGuardian(croom, studentId, guardianId, guardianEmail, i, count, j, jcount):
  try:
    callGAPI(croom.userProfiles().guardians(), 'delete',
             throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
             retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
             studentId=studentId, guardianId=guardianId)
    entityActionPerformed([Ent.STUDENT, studentId, Ent.GUARDIAN, guardianEmail], j, jcount)
    return 1
  except GAPI.notFound:
    if guardianId == guardianEmail:
      entityActionFailedWarning([Ent.STUDENT, studentId, Ent.GUARDIAN, guardianEmail], Msg.NOT_FOUND, j, jcount)
    return 0
  except GAPI.serviceNotAvailable as e:
    entityActionFailedWarning([Ent.STUDENT, studentId, Ent.GUARDIAN, guardianEmail], str(e), j, jcount)
    return -1
  except (GAPI.forbidden, GAPI.permissionDenied) as e:
    studentUnknownWarning(studentId, str(e), i, count)
    return -1

def _doDeleteGuardian(croom, studentId, guardianId, guardianClass, i=0, count=0, j=0, jcount=0):
  guardianIdIsEmail = guardianId.find('@') != -1
  guardianFound = False
  try:
    if guardianClass != GUARDIAN_CLASS_ACCEPTED:
      Act.Set(Act.CANCEL)
      if guardianIdIsEmail:
        invitations = callGAPIpages(croom.userProfiles().guardianInvitations(), 'list', 'guardianInvitations',
                                    throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.BAD_REQUEST,
                                                  GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                                    retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                    studentId=studentId, invitedEmailAddress=guardianId, states=['PENDING'],
                                    fields='nextPageToken,guardianInvitations(studentId,invitationId)')
        for invitation in invitations:
          result = _cancelGuardianInvitation(croom, invitation['studentId'], invitation['invitationId'], i, count, j, jcount)
          if result < 0:
            return result
          if result > 0:
            guardianFound = True
      else:
        result = _cancelGuardianInvitation(croom, studentId, guardianId, i, count, j, jcount)
        if result != 0:
          return result
    if guardianClass != GUARDIAN_CLASS_INVITATIONS:
      Act.Set(Act.DELETE)
      if guardianIdIsEmail:
        guardians = callGAPIpages(croom.userProfiles().guardians(), 'list', 'guardians',
                                  throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.BAD_REQUEST,
                                                GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                                  retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                  studentId=studentId, invitedEmailAddress=guardianId,
                                  fields='nextPageToken,guardians(studentId,guardianId)')
        for guardian in guardians:
          result = _deleteGuardian(croom, guardian['studentId'], guardian['guardianId'], guardianId, i, count, j, jcount)
          if result < 0:
            return result
          if result > 0:
            guardianFound = True
      else:
        result = _deleteGuardian(croom, studentId, guardianId, guardianId, i, count, j, jcount)
        if result != 0:
          return result
  except GAPI.notFound:
    entityUnknownWarning(Ent.STUDENT, studentId, i, count)
    return -1
  except (GAPI.invalidArgument, GAPI.badRequest, GAPI.forbidden, GAPI.permissionDenied) as e:
    studentUnknownWarning(studentId, str(e), i, count)
    return -1
  except GAPI.serviceNotAvailable as e:
    entityActionFailedWarning([Ent.STUDENT, studentId, GUARDIAN_CLASS_ENTITY[guardianClass], guardianId], str(e))
    return -1
  if not guardianFound:
    Act.Set(Act.DELETE)
    entityActionFailedWarning([Ent.STUDENT, studentId, GUARDIAN_CLASS_ENTITY[guardianClass], guardianId], Msg.NOT_FOUND)
    return 0
  return 1

# gam delete guardian|guardians <GuardianItem> <StudentItem> [accepted|invitations|all]
def doDeleteGuardian():
  croom = buildGAPIObject(API.CLASSROOM)
  guardianId = normalizeStudentGuardianEmailAddressOrUID(getString(Cmd.OB_GUARDIAN_ITEM))
  studentId = normalizeStudentGuardianEmailAddressOrUID(getString(Cmd.OB_STUDENT_ITEM), allowDash=True)
  guardianClass = getChoice(GUARDIAN_CLASS_MAP, mapChoice=True, defaultChoice=GUARDIAN_CLASS_ALL)
  checkForExtraneousArguments()
  _doDeleteGuardian(croom, studentId, guardianId, guardianClass)

# gam <UserTypeEntity> delete guardian|guardians <GuardianEntity> [accepted|invitations|all]
def deleteGuardians(users):
  croom = buildGAPIObject(API.CLASSROOM)
  guardianEntity, guardianEntityList = getGuardianEntity()
  guardianClass = getChoice(GUARDIAN_CLASS_MAP, mapChoice=True, defaultChoice=GUARDIAN_CLASS_ALL)
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    studentId, guardianEmails, jcount = getGuardianEmails(user, guardianEntity, guardianEntityList)
    entityPerformActionNumItems([Ent.STUDENT, studentId], jcount, GUARDIAN_CLASS_ENTITY[guardianClass], i, count)
    Ind.Increment()
    j = 0
    for guardianEmail in guardianEmails:
      j += 1
      if _doDeleteGuardian(croom, studentId, guardianEmail, guardianClass, i, count, j, jcount) < 0:
        break
    Ind.Decrement()

# gam <UserTypeEntity> clear guardian|guardians [accepted|invitations|all]
def clearGuardians(users):
  croom = buildGAPIObject(API.CLASSROOM)
  guardianClass = getChoice(GUARDIAN_CLASS_MAP, mapChoice=True, defaultChoice=GUARDIAN_CLASS_ALL)
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    studentId = normalizeStudentGuardianEmailAddressOrUID(user)
    try:
      if guardianClass != GUARDIAN_CLASS_ACCEPTED:
        invitations = callGAPIpages(croom.userProfiles().guardianInvitations(), 'list', 'guardianInvitations',
                                    throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.BAD_REQUEST,
                                                  GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                                    retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                    studentId=studentId, states=['PENDING'], fields='nextPageToken,guardianInvitations(invitationId)')
        Act.Set(Act.CANCEL)
        jcount = len(invitations)
        entityPerformActionNumItems([Ent.STUDENT, studentId], jcount, Ent.GUARDIAN_INVITATION, i, count)
        Ind.Increment()
        j = 0
        for invitation in invitations:
          j += 1
          _cancelGuardianInvitation(croom, studentId, invitation['invitationId'], i, count, j, jcount)
        Ind.Decrement()
      if guardianClass != GUARDIAN_CLASS_INVITATIONS:
        guardians = callGAPIpages(croom.userProfiles().guardians(), 'list', 'guardians',
                                  throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED],
                                  studentId=studentId, fields='nextPageToken,guardians(guardianId,invitedEmailAddress)')
        Act.Set(Act.DELETE)
        jcount = len(guardians)
        entityPerformActionNumItems([Ent.STUDENT, studentId], jcount, Ent.GUARDIAN, i, count)
        Ind.Increment()
        j = 0
        for guardian in guardians:
          j += 1
          _deleteGuardian(croom, studentId, guardian['guardianId'], guardian['invitedEmailAddress'], i, count, j, jcount)
        Ind.Decrement()
    except GAPI.notFound:
      entityUnknownWarning(Ent.STUDENT, studentId, i, count)
    except GAPI.serviceNotAvailable as e:
      entityActionFailedWarning([Ent.STUDENT, studentId], str(e))
    except (GAPI.invalidArgument, GAPI.badRequest, GAPI.forbidden, GAPI.permissionDenied) as e:
      studentUnknownWarning(studentId, str(e), i, count)

# gam <UserTypeEntity> sync guardian|guardians <GuardianEntity>
def syncGuardians(users):
  croom = buildGAPIObject(API.CLASSROOM)
  guardianEntity, guardianEntityList = getGuardianEntity()
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    Act.Set(Act.SYNC)
    studentId, guardianEmails, jcount = getGuardianEmails(user, guardianEntity, guardianEntityList)
    entityPerformActionNumItems([Ent.STUDENT, studentId], jcount, Ent.GUARDIAN, i, count)
    try:
      invitations = callGAPIpages(croom.userProfiles().guardianInvitations(), 'list', 'guardianInvitations',
                                  throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.BAD_REQUEST,
                                                GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                                  retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                  studentId=studentId, states=['PENDING'], fields='nextPageToken,guardianInvitations(invitationId,invitedEmailAddress)')
      guardians = callGAPIpages(croom.userProfiles().guardians(), 'list', 'guardians',
                                throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.BAD_REQUEST,
                                              GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                                retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                studentId=studentId, fields='nextPageToken,guardians(guardianId,invitedEmailAddress)')
    except GAPI.notFound:
      entityUnknownWarning(Ent.STUDENT, studentId, i, count)
      continue
    except GAPI.serviceNotAvailable as e:
      entityActionFailedWarning([Ent.STUDENT, studentId], str(e), i, count)
    except (GAPI.invalidArgument, GAPI.badRequest, GAPI.forbidden, GAPI.permissionDenied) as e:
      studentUnknownWarning(studentId, str(e), i, count)
      continue
    Ind.Increment()
    Act.Set(Act.CANCEL)
    jcount = len(invitations)
    j = 0
    for invitation in invitations:
      j += 1
      if invitation['invitedEmailAddress'] not in guardianEmails:
        _cancelGuardianInvitation(croom, studentId, invitation['invitationId'], i, count, j, jcount)
    Act.Set(Act.DELETE)
    jcount = len(guardians)
    j = 0
    for guardian in guardians:
      j += 1
      if guardian['invitedEmailAddress'] not in guardianEmails:
        _deleteGuardian(croom, studentId, guardian['guardianId'], guardian['invitedEmailAddress'], i, count, j, jcount)
    Act.Set(Act.CREATE)
    for guardianEmail in guardianEmails:
      for guardian in guardians:
        if guardianEmail == guardian['invitedEmailAddress']:
          break
      else:
        for invitation in invitations:
          if guardianEmail == invitation['invitedEmailAddress']:
            break
        else:
          _inviteGuardian(croom, studentId, guardianEmail, i, count)
    Ind.Decrement()

def _getCourseName(croom, courseNames, courseId):
  courseName = courseNames.get(courseId)
  if courseName is None:
    try:
      courseName = callGAPI(croom.courses(), 'get',
                            throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.BAD_REQUEST,
                                          GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                            retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                            id=courseId, fields='name')['name']
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.badRequest, GAPI.forbidden, GAPI.permissionDenied, GAPI.serviceNotAvailable):
      pass
    if courseName is None:
      courseName = courseId
    courseNames[courseId] = courseName
  return courseName

def _getClassroomEmail(croom, classroomEmails, userId, user):
  if user.find('@') != -1:
    return user
  userEmail = classroomEmails.get(userId)
  if userEmail is None:
    try:
      userEmail = callGAPI(croom.userProfiles(), 'get',
                           throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.BAD_REQUEST, GAPI.FORBIDDEN,
                                         GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                           retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                           userId=userId, fields='emailAddress').get('emailAddress')
    except (GAPI.notFound, GAPI.invalidArgument, GAPI.badRequest, GAPI.forbidden, GAPI.permissionDenied, GAPI.serviceNotAvailable):
      pass
    if userEmail is None:
      userEmail = userId
    classroomEmails[userId] = userEmail
  return userEmail

GUARDIAN_TIME_OBJECTS = {'creationTime'}
GUARDIAN_STATES = ['complete', 'pending']

def _printShowGuardians(entityList=None):
  croom = buildGAPIObject(API.CLASSROOM)
  if entityList is None:
    studentIds = ['-']
    allStudents = True
  else:
    studentIds = entityList
    allStudents = False
  showStudentEmails = False
  classroomEmails = {}
  invitedEmailAddress = None
  states = []
  guardianClass = GUARDIAN_CLASS_ACCEPTED
  csvPF = CSVPrintFile() if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'invitedguardian':
      invitedEmailAddress = getEmailAddress()
    elif myarg in {'invitation', 'invitations'}:
      guardianClass = GUARDIAN_CLASS_INVITATIONS
      if not states:
        states = [state.upper() for state in GUARDIAN_STATES]
    elif myarg == 'accepted':
      guardianClass = GUARDIAN_CLASS_ACCEPTED
    elif myarg == 'all':
      guardianClass = GUARDIAN_CLASS_ALL
      if not states:
        states.append('PENDING')
    elif myarg in {'state', 'states', 'status'}:
      statesList = getString(Cmd.OB_GUARDIAN_STATE_LIST).lower().split(',')
      states = []
      for state in statesList:
        if state in GUARDIAN_STATES:
          states.append(state.upper())
        else:
          invalidChoiceExit(state, GUARDIAN_STATES, True)
    elif myarg == 'showstudentemails':
      showStudentEmails = True
    elif entityList is None and myarg == 'student':
      studentIds = [getString(Cmd.OB_STUDENT_ITEM)]
      allStudents = studentIds[0] == '-'
    elif FJQC.GetFormatJSONQuoteChar(myarg, False, True):
      pass
    elif entityList is None:
      Cmd.Backup()
      _, studentIds = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)
      allStudents = False
    else:
      unknownArgumentExit()
  if csvPF:
    if not FJQC.formatJSON:
      sortTitles = ['studentEmail', 'studentId', 'invitedEmailAddress']
      if guardianClass != GUARDIAN_CLASS_ACCEPTED:
        sortTitles.extend(['invitationId', 'creationTime', 'state'])
      if guardianClass != GUARDIAN_CLASS_INVITATIONS:
        sortTitles.append('guardianId')
      csvPF.SetTitles(sortTitles)
      csvPF.SetSortAllTitles()
    else:
      csvPF.SetJSONTitles(['studentEmail', 'studentId', 'JSON'])
  i, count, studentIds = getEntityArgument(studentIds)
  for studentId in studentIds:
    i += 1
    if not allStudents:
      studentId = normalizeStudentGuardianEmailAddressOrUID(studentId)
      if showStudentEmails:
        studentId = _getClassroomEmail(croom, classroomEmails, studentId, studentId)
    try:
      if guardianClass != GUARDIAN_CLASS_ACCEPTED:
        if csvPF:
          if states:
            qualifier = f' ({",".join(states)})'
          else:
            qualifier = ''
          if not allStudents:
            printGettingAllEntityItemsForWhom(Ent.GUARDIAN_INVITATION, studentId, i, count, qualifier=qualifier)
          else:
            printGettingAllEntityItemsForWhom(Ent.GUARDIAN_INVITATION, 'All students', qualifier=qualifier)
          pageMessage = getPageMessageForWhom()
        else:
          pageMessage = None
        invitations = callGAPIpages(croom.userProfiles().guardianInvitations(), 'list', 'guardianInvitations',
                                    pageMessage=pageMessage,
                                    throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.BAD_REQUEST,
                                                  GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                                    retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                    studentId=studentId, invitedEmailAddress=invitedEmailAddress, states=states)
        jcount = len(invitations)
        if not csvPF:
          if not FJQC.formatJSON:
            entityPerformActionNumItems([Ent.STUDENT, studentId if not allStudents else 'All'], jcount, Ent.GUARDIAN_INVITATION, i, count)
            Ind.Increment()
            j = 0
            for invitation in invitations:
              j += 1
              printKeyValueListWithCount(['invitedEmailAddress', invitation['invitedEmailAddress']], j, jcount)
              Ind.Increment()
              if showStudentEmails:
                invitation['studentEmail'] = _getClassroomEmail(croom, classroomEmails, invitation['studentId'], studentId)
              showJSON(None, invitation, ['invitedEmailAddress'], GUARDIAN_TIME_OBJECTS)
              Ind.Decrement()
            Ind.Decrement()
          else:
            printLine(json.dumps(cleanJSON(invitations, timeObjects=GUARDIAN_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
        else:
          if not FJQC.formatJSON:
            for invitation in invitations:
              if showStudentEmails:
                invitation['studentEmail'] = _getClassroomEmail(croom, classroomEmails, invitation['studentId'], studentId)
              else:
                invitation['studentEmail'] = studentId
              csvPF.WriteRowTitles(flattenJSON(invitation, timeObjects=GUARDIAN_TIME_OBJECTS))
          else:
            csvPF.WriteRow({'studentId': studentId, 'studentEmail': _getClassroomEmail(croom, classroomEmails, studentId, studentId),
                            'JSON': json.dumps(cleanJSON(invitations, timeObjects=GUARDIAN_TIME_OBJECTS), ensure_ascii=False, sort_keys=True)})
      if guardianClass != GUARDIAN_CLASS_INVITATIONS:
        if csvPF:
          if not allStudents:
            printGettingAllEntityItemsForWhom(Ent.GUARDIAN, studentId, i, count)
          else:
            printGettingAllEntityItemsForWhom(Ent.GUARDIAN, 'All students')
          pageMessage = getPageMessageForWhom()
        else:
          pageMessage = None
        guardians = callGAPIpages(croom.userProfiles().guardians(), 'list', 'guardians',
                                  pageMessage=pageMessage,
                                  throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.BAD_REQUEST,
                                                GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                                  retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                  studentId=studentId, invitedEmailAddress=invitedEmailAddress)
        jcount = len(guardians)
        if not csvPF:
          if not FJQC.formatJSON:
            entityPerformActionNumItems([Ent.STUDENT, studentId if not allStudents else 'All'], jcount, Ent.GUARDIAN, i, count)
            Ind.Increment()
            j = 0
            for guardian in guardians:
              j += 1
              printKeyValueListWithCount(['invitedEmailAddress', guardian['invitedEmailAddress']], j, jcount)
              Ind.Increment()
              if showStudentEmails:
                guardian['studentEmail'] = _getClassroomEmail(croom, classroomEmails, guardian['studentId'], studentId)
              showJSON(None, guardian, ['invitedEmailAddress'])
              Ind.Decrement()
            Ind.Decrement()
          else:
            printLine(json.dumps(cleanJSON(guardians), ensure_ascii=False, sort_keys=True))
        else:
          if not FJQC.formatJSON:
            for guardian in guardians:
              if showStudentEmails:
                guardian['studentEmail'] = _getClassroomEmail(croom, classroomEmails, guardian['studentId'], studentId)
              else:
                guardian['studentEmail'] = studentId
              csvPF.WriteRowTitles(flattenJSON(guardian))
          else:
            csvPF.WriteRowNoFilter({'studentId': studentId, 'studentEmail': _getClassroomEmail(croom, classroomEmails, studentId, studentId),
                                    'JSON': json.dumps(cleanJSON(guardians), ensure_ascii=False, sort_keys=True)})
    except GAPI.notFound:
      entityUnknownWarning(Ent.STUDENT, studentId, i, count)
    except (GAPI.invalidArgument, GAPI.badRequest, GAPI.forbidden, GAPI.permissionDenied) as e:
      studentUnknownWarning(studentId, str(e), i, count)
    except GAPI.serviceNotAvailable as e:
      entityActionFailedWarning([Ent.STUDENT, studentId], str(e), i, count)
  if csvPF:
    csvPF.writeCSVfile('Guardians')

# gam show guardian|guardians [accepted|invitations|all] [states <GuardianInvitationStateList>] [invitedguardian <EmailAddress>]
#	[student <StudentItem>] [<UserTypeEntity>]
#	[showstudentemails] [formatjson]
# gam print guardian|guardians [todrive <ToDriveAttribute>*] [accepted|invitations|all] [states <GuardianInvitationStateList>] [invitedguardian <EmailAddress>]
#	[student <StudentItem>] [<UserTypeEntity>]
#	[showstudentemails] [formatjson [quotechar <Character>]]
def doPrintShowGuardians():
  _printShowGuardians()

# gam <UserTypeEntity> show guardian|guardians [accepted|invitations|all] [states <GuardianInvitationStateList>] [invitedguardian <EmailAddress>]
#	[showstudentemails] [formatjson]
# gam <UserTypeEntity> print guardian|guardians [todrive <ToDriveAttribute>*] [accepted|invitations|all] [states <GuardianInvitationStateList>] [invitedguardian <EmailAddress>]
#	[showstudentemails] [formatjson [quotechar <Character>]]
def printShowGuardians(users):
  _printShowGuardians(users)

CLASSROOM_ROLE_ALL = 'ALL'
CLASSROOM_ROLE_OWNER = 'OWNER'
CLASSROOM_ROLE_STUDENT = 'STUDENT'
CLASSROOM_ROLE_TEACHER = 'TEACHER'

def _getClassroomInvitations(croom, userId, courseId, role, i, count, j=0, jcount=0):
  try:
    invitations = callGAPIpages(croom.invitations(), 'list', 'invitations',
                                throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.BAD_REQUEST,
                                              GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
                                retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                userId=userId, courseId=courseId)
  except GAPI.notFound:
    if userId is not None:
      entityUnknownWarning(Ent.USER, userId, i, count)
      return (-1, None)
    entityUnknownWarning(Ent.COURSE, courseId, j, jcount)
    return (0, [])
  except (GAPI.invalidArgument, GAPI.badRequest, GAPI.forbidden, GAPI.permissionDenied, GAPI.serviceNotAvailable) as e:
    if userId is not None:
      entityActionFailedWarning([Ent.USER, userId], str(e), i, count)
      return (-1, None)
    entityActionFailedWarning([Ent.COURSE, courseId], str(e), j, jcount)
    return (0, [])
  if role == CLASSROOM_ROLE_ALL:
    return (1, invitations)
  return (1, [invitation for invitation in invitations if invitation['role'] == role])

def _getClassroomInvitationIds(croom, userId, courseIds, role, i, count):
  invitationIds = []
  if courseIds is not None:
    jcount = len(courseIds)
    j = 0
    for courseId in courseIds:
      j += 1
      courseId = addCourseIdScope(courseId)
      status, invitations = _getClassroomInvitations(croom, userId, courseId, role, i, count, j, jcount)
      if status < 0:
        return (status, None)
      if status > 0:
        invitationIds.extend([invitation['id'] for invitation in invitations])
  else:
    status, invitations = _getClassroomInvitations(croom, userId, None, role, i, count)
    if status < 0:
      return (status, None)
    if status > 0:
      invitationIds.extend([invitation['id'] for invitation in invitations])
  return (1, invitationIds)

CLASSROOM_CREATE_ROLE_MAP = {
  'owner': CLASSROOM_ROLE_OWNER,
  'student': CLASSROOM_ROLE_STUDENT,
  'teacher': CLASSROOM_ROLE_TEACHER,
  }

CLASSROOM_ROLE_MAP = {
  'all': CLASSROOM_ROLE_ALL,
  'owner': CLASSROOM_ROLE_OWNER,
  'student': CLASSROOM_ROLE_STUDENT,
  'teacher': CLASSROOM_ROLE_TEACHER,
  }

CLASSROOM_ROLE_ENTITY_MAP = {
  CLASSROOM_ROLE_ALL: Ent.CLASSROOM_INVITATION,
  CLASSROOM_ROLE_OWNER: Ent.CLASSROOM_INVITATION_OWNER,
  CLASSROOM_ROLE_STUDENT: Ent.CLASSROOM_INVITATION_STUDENT,
  CLASSROOM_ROLE_TEACHER: Ent.CLASSROOM_INVITATION_TEACHER,
  }

# gam <UserTypeEntity> create classroominvitation courses <CourseEntity> [role owner|student|teacher]
#	[asadmin] [csv|csvformat] [todrive <ToDriveAttribute>*] [formatjson [quotechar <Character>]]
def createClassroomInvitations(users):
  croom = buildGAPIObject(API.CLASSROOM)
  classroomEmails = {}
  courseIds = None
  role = CLASSROOM_ROLE_STUDENT
  useOwnerAccess = True
  csvPF = None
  FJQC = FormatJSONQuoteChar(csvPF)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'course', 'courses', 'class', 'classes'}:
      courseIds = getEntityList(Cmd.OB_COURSE_ENTITY, shlexSplit=True)
    elif myarg == 'role':
      role = getChoice(CLASSROOM_CREATE_ROLE_MAP, mapChoice=True)
    elif myarg in {'csv',  'csvformat'}:
      csvPF = CSVPrintFile()
      FJQC.SetCsvPF(csvPF)
    elif csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in ADMIN_ACCESS_OPTIONS:
      useOwnerAccess = False
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  if courseIds is None:
    missingArgumentExit('courses <CourseEntity>')
  if csvPF:
    if FJQC.formatJSON:
      csvPF.SetJSONTitles(['userEmail', 'JSON'])
    else:
      csvPF.SetTitles(['userEmail', 'courseId', 'courseName', 'id', 'role'])
      csvPF.SetSortAllTitles()
  courseIdsLists = courseIds if isinstance(courseIds, dict) else None
  if courseIdsLists is None:
    j, jcount, coursesInfo =  _getCoursesOwnerInfo(croom, courseIds, useOwnerAccess)
  entityType = CLASSROOM_ROLE_ENTITY_MAP[role]
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    userId = normalizeEmailAddressOrUID(user)
    userEmail = _getClassroomEmail(croom, classroomEmails, userId, userId)
    if courseIdsLists:
      j, jcount, coursesInfo = _getCoursesOwnerInfo(croom, courseIdsLists[user], useOwnerAccess)
    if csvPF or not FJQC.formatJSON:
      entityPerformActionNumItems([Ent.USER, userId], jcount, entityType, i, count)
    if jcount == 0:
      continue
    for courseId, courseInfo in coursesInfo.items():
      j += 1
      courseNameId = f'{courseInfo["name"]} ({courseId})'
      try:
        invitation = callGAPI(courseInfo['croom'].invitations(), 'create',
                              throwReasons=[GAPI.NOT_FOUND, GAPI.FAILED_PRECONDITION, GAPI.ALREADY_EXISTS, GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED],
                              body={'userId': userId, 'courseId': courseId, 'role': role})
        invitation['courseName'] = courseInfo['name']
        invitation['userEmail'] = userEmail
        if not csvPF:
          if not FJQC.formatJSON:
            Ind.Increment()
            entityActionPerformed([Ent.USER, userEmail, Ent.COURSE, courseNameId, entityType, invitation['id']], j, jcount)
            Ind.Decrement()
          else:
            printLine(json.dumps(cleanJSON(invitation), ensure_ascii=False, sort_keys=True))
        else:
          if not FJQC.formatJSON:
            csvPF.WriteRow(invitation)
          else:
            csvPF.WriteRowNoFilter({'userEmail': userEmail,
                                    'JSON': json.dumps(cleanJSON(invitation), ensure_ascii=False, sort_keys=True)})
      except GAPI.permissionDenied as e:
        entityActionFailedWarning([Ent.USER, userId, Ent.COURSE, courseNameId, entityType, None], str(e), j, jcount)
        break
      except GAPI.notFound:
        entityUnknownWarning(Ent.COURSE, courseNameId, j, jcount)
      except (GAPI.failedPrecondition, GAPI.alreadyExists, GAPI.forbidden) as e:
        entityActionFailedWarning([Ent.USER, userId, Ent.COURSE, courseNameId, entityType, None], str(e), j, jcount)
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('ClassroomInvitations')

def acceptDeleteClassroomInvitations(users, function):
  croom = buildGAPIObject(API.CLASSROOM)
  if function == 'delete':
    ucroom = croom
  courseIds = invitationIds = None
  role = CLASSROOM_ROLE_ALL
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'id', 'ids'}:
      invitationIds = getEntityList(Cmd.OB_CLASSROOM_INVITATION_ID_ENTITY)
      courseIds = None
    elif myarg in {'course', 'courses', 'class', 'classes'}:
      courseIds = getEntityList(Cmd.OB_COURSE_ENTITY, shlexSplit=True)
      invitationIds = None
    elif myarg == 'role':
      role = getChoice(CLASSROOM_ROLE_MAP, mapChoice=True)
    else:
      unknownArgumentExit()
  courseIdsLists = courseIds if isinstance(courseIds, dict) else None
  invitationIdsLists = invitationIds if isinstance(invitationIds, dict) else None
  entityType = CLASSROOM_ROLE_ENTITY_MAP[role]
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    if function == 'delete':
      userId = normalizeEmailAddressOrUID(user)
    else:
      userId, ucroom = buildGAPIServiceObject(API.CLASSROOM, user, i, count)
      if not ucroom:
        continue
    if invitationIdsLists:
      userInvitationIds = invitationIdsLists[user]
    elif invitationIds is not None:
      userInvitationIds = invitationIds
    else:
      if courseIdsLists:
        courseIds = courseIdsLists[user]
      status, userInvitationIds = _getClassroomInvitationIds(croom, userId, courseIds, role, i, count)
      if status < 0:
        continue
    jcount = len(userInvitationIds)
    entityPerformActionNumItems([Ent.USER, userId], jcount, entityType, i, count)
    Ind.Increment()
    j = 0
    for invitationId in userInvitationIds:
      j += 1
      try:
        callGAPI(ucroom.invitations(), function,
                 throwReasons=[GAPI.NOT_FOUND, GAPI.FAILED_PRECONDITION, GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED],
                 id=invitationId)
        entityActionPerformed([Ent.USER, userId, entityType, invitationId], j, jcount)
      except (GAPI.notFound, GAPI.failedPrecondition, GAPI.forbidden, GAPI.permissionDenied) as e:
        entityActionFailedWarning([Ent.USER, userId, entityType, invitationId], str(e), j, jcount)
    Ind.Decrement()

# gam <UserTypeEntity> accept classroominvitation (ids <ClassroomInvitationIDEntity>)|([courses <CourseEntity>] [role all|owner|student|teacher])
def acceptClassroomInvitations(users):
  acceptDeleteClassroomInvitations(users, 'accept')

# gam <UserTypeEntity> delete classroominvitation (ids <ClassroomInvitationIDEntity>)|([courses <CourseEntity>] [role all|owner|student|teacher])
def deleteClassroomInvitations(users):
  acceptDeleteClassroomInvitations(users, 'delete')

# gam <UserTypeEntity> show classroominvitations [role all|owner|student|teacher]
#	[formatjson]
# gam <UserTypeEntity> print classroominvitations [todrive <ToDriveAttribute>*] [role all|owner|student|teacher]
#	[formatjson [quotechar <Character>]]
def printShowClassroomInvitations(users):
  croom = buildGAPIObject(API.CLASSROOM)
  classroomEmails = {}
  courseNames = {}
  role = CLASSROOM_ROLE_ALL
  csvPF = CSVPrintFile() if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'role':
      role = getChoice(CLASSROOM_ROLE_MAP, mapChoice=True)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  if csvPF:
    if FJQC.formatJSON:
      csvPF.SetJSONTitles(['userEmail', 'JSON'])
    else:
      csvPF.SetTitles(['userId', 'userEmail', 'courseId', 'courseName', 'id', 'role'])
      csvPF.SetSortAllTitles()
  entityType = CLASSROOM_ROLE_ENTITY_MAP[role]
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    userId = normalizeEmailAddressOrUID(user)
    userEmail = _getClassroomEmail(croom, classroomEmails, userId, userId)
    if csvPF:
      printGettingAllEntityItemsForWhom(entityType, userId, i, count)
    status, invitations = _getClassroomInvitations(croom, userId, None, role, i, count)
    if status > 0:
      jcount = len(invitations)
      if not FJQC.formatJSON:
        entityPerformActionNumItems([Ent.USER, userId], jcount, entityType, i, count)
      if not csvPF:
        if not FJQC.formatJSON:
          Ind.Increment()
          j = 0
          for invitation in invitations:
            j += 1
            courseId = invitation['courseId']
            courseName = _getCourseName(croom, courseNames, courseId)
            printKeyValueListWithCount([Ent.Singular(Ent.COURSE), f'{courseName} ({courseId})'], j, jcount)
            Ind.Increment()
            printKeyValueList(['id', invitation['id']])
            printKeyValueList(['role', invitation['role']])
            printKeyValueList(['userid', invitation['userId']])
            printKeyValueList(['userEmail', userEmail])
            Ind.Decrement()
          Ind.Decrement()
        else:
          printLine(json.dumps(cleanJSON(invitations), ensure_ascii=False, sort_keys=True))
      else:
        if not FJQC.formatJSON:
          for invitation in invitations:
            invitation['courseName'] = _getCourseName(croom, courseNames, invitation['courseId'])
            invitation['userEmail'] = userEmail
            csvPF.WriteRow(invitation)
        else:
          csvPF.WriteRowNoFilter({'userEmail': userEmail,
                                  'JSON': json.dumps(cleanJSON(invitations), ensure_ascii=False, sort_keys=True)})
  if csvPF:
    csvPF.writeCSVfile('ClassroomInvitations')

# gam delete classroominvitation courses <CourseEntity> (ids <ClassroomInvitationIDEntity>)|(role all|owner|student|teacher)
def doDeleteClassroomInvitations():
  croom = buildGAPIObject(API.CLASSROOM)
  courseIdList = invitationIds = None
  role = CLASSROOM_ROLE_ALL
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'course', 'courses', 'class', 'classes'}:
      courseIdList = getEntityList(Cmd.OB_COURSE_ENTITY, shlexSplit=True)
    elif myarg in {'id', 'ids'}:
      invitationIds = getEntityList(Cmd.OB_CLASSROOM_INVITATION_ID_ENTITY)
    elif myarg == 'role':
      role = getChoice(CLASSROOM_ROLE_MAP, mapChoice=True)
    else:
      unknownArgumentExit()
  if courseIdList is None:
    missingArgumentExit('courses <CourseEntity>')
  entityType = Ent.CLASSROOM_INVITATION
  i, count, coursesInfo = _getCoursesOwnerInfo(croom, courseIdList, True)
  for courseId, courseInfo in coursesInfo.items():
    i += 1
    courseNameId = f'{courseInfo["name"]} ({courseId})'
    if invitationIds is not None:
      userInvitationIds = invitationIds
    else:
      status, userInvitationIds = _getClassroomInvitationIds(courseInfo['croom'], None, [courseId], role, i, count)
      if status < 0:
        continue
    jcount = len(userInvitationIds)
    entityPerformActionNumItems([Ent.COURSE, courseNameId], jcount, entityType, i, count)
    Ind.Increment()
    j = 0
    for invitationId in userInvitationIds:
      j += 1
      try:
        callGAPI(courseInfo['croom'].invitations(), 'delete',
                 throwReasons=[GAPI.NOT_FOUND, GAPI.FAILED_PRECONDITION, GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED],
                 id=invitationId)
        entityActionPerformed([Ent.COURSE, courseNameId, entityType, invitationId], j, jcount)
      except (GAPI.notFound, GAPI.failedPrecondition, GAPI.forbidden, GAPI.permissionDenied) as e:
        entityActionFailedWarning([Ent.COURSE, courseNameId, entityType, invitationId], str(e), j, jcount)
    Ind.Decrement()

# gam show classroominvitations (course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] [states <CourseStateList>])
#	[role all|owner|student|teacher] [formatjson]
# gam print classroominvitations [todrive <ToDriveAttribute>*] (course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] [states <CourseStateList>])
#	[role all|owner|student|teacher] [formatjson [quotechar <Character>]]
def doPrintShowClassroomInvitations():
  croom = buildGAPIObject(API.CLASSROOM)
  classroomEmails = {}
  courseSelectionParameters = _initCourseSelectionParameters()
  courseShowProperties = _initCourseShowProperties(['id', 'name'])
  role = CLASSROOM_ROLE_ALL
  csvPF = CSVPrintFile(['courseId', 'courseName']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif _getCourseSelectionParameters(myarg, courseSelectionParameters):
      pass
    elif myarg == 'role':
      role = getChoice(CLASSROOM_ROLE_MAP, mapChoice=True)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  coursesInfo = _getCoursesInfo(croom, courseSelectionParameters, courseShowProperties)
  if coursesInfo is None:
    return
  if csvPF and not FJQC.formatJSON:
    csvPF.AddTitles(['id', 'role', 'userId', 'userEmail'])
    csvPF.SetSortAllTitles()
  entityType = CLASSROOM_ROLE_ENTITY_MAP[role]
  i = 0
  count = len(coursesInfo)
  for course in coursesInfo:
    i += 1
    courseId = course['id']
    courseName = course['name']
    status, invitations = _getClassroomInvitations(croom, None, courseId, role, i, count)
    if status > 0:
      jcount = len(invitations)
      if not FJQC.formatJSON:
        entityPerformActionNumItems([Ent.COURSE, f'{courseName} ({courseId})'], jcount, entityType, i, count)
      if not csvPF:
        if not FJQC.formatJSON:
          Ind.Increment()
          j = 0
          for invitation in invitations:
            j += 1
            printKeyValueListWithCount(['id', invitation['id']], j, jcount)
            Ind.Increment()
            printKeyValueList(['role', invitation['role']])
            userId = invitation.get('userId')
            if userId is not None:
              printKeyValueList(['userid', userId])
              printKeyValueList(['userEmail', _getClassroomEmail(croom, classroomEmails, userId, userId)])
            Ind.Decrement()
          Ind.Decrement()
        else:
          printLine(json.dumps(cleanJSON(invitations), ensure_ascii=False, sort_keys=True))
      else:
        if not FJQC.formatJSON:
          for invitation in invitations:
            invitation['courseName'] = courseName
            userId = invitation.get('userId')
            if userId is not None:
              invitation['userEmail'] = _getClassroomEmail(croom, classroomEmails, userId, userId)
            csvPF.WriteRowNoFilter(invitation)
        else:
          csvPF.WriteRowNoFilter({'courseId': courseId, 'courseName': courseName,
                                  'JSON': json.dumps(cleanJSON(invitations), ensure_ascii=False, sort_keys=True)})
  if csvPF:
    csvPF.writeCSVfile('ClassroomInvitations')

# gam <UserTypeEntity> print classroomprofile [todrive <ToDriveAttribute>*]
# gam <UserTypeEntity> show classroomprofile
def printShowClassroomProfile(users):
  croom = buildGAPIObject(API.CLASSROOM)
  csvPF = CSVPrintFile(['emailAddress', 'id',
                        f'name{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}givenName',
                        f'name{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}familyName',
                        f'name{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}fullName',
                        'photoUrl'], indexedTitles=['permissions']) if Act.csvFormat() else None
  getTodriveOnly(csvPF)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    userId = normalizeEmailAddressOrUID(user)
    if csvPF:
      printGettingEntityItemForWhom(Ent.CLASSROOM_USER_PROFILE, user, i, count)
    try:
      result = callGAPI(croom.userProfiles(), 'get',
                        throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.SERVICE_NOT_AVAILABLE],
                        retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                        userId=userId, fields='*')
      result.setdefault('verifiedTeacher', False)
      if not csvPF:
        printEntity([Ent.USER, userId], i, count)
        Ind.Increment()
        printKeyValueList(['email', result['emailAddress']])
        printKeyValueList([UProp.PROPERTIES['id'][UProp.TITLE], result['id']])
        for up in USER_NAME_PROPERTY_PRINT_ORDER:
          if up in result['name']:
            printKeyValueList([UProp.PROPERTIES[up][UProp.TITLE], result['name'][up]])
        printKeyValueList([UProp.PROPERTIES['thumbnailPhotoUrl'][UProp.TITLE], result['photoUrl']])
        printKeyValueList(['Permissions', ','.join([permission['permission'] for permission in result.get('permissions', [])])])
        printKeyValueList(['Verified Teacher', result['verifiedTeacher']])
        Ind.Decrement()
      else:
        csvPF.WriteRowTitles(flattenJSON(result))
    except (GAPI.notFound, GAPI.permissionDenied, GAPI.badRequest, GAPI.forbidden, GAPI.serviceNotAvailable) as e:
      entityActionFailedWarning([Ent.USER, userId], str(e))
  if csvPF:
    csvPF.writeCSVfile('Classroom User Profiles')

def _showASPs(user, asps, i=0, count=0):
  Act.Set(Act.SHOW)
  jcount = len(asps)
  entityPerformActionNumItems([Ent.USER, user], jcount, Ent.APPLICATION_SPECIFIC_PASSWORD, i, count)
  if jcount == 0:
    setSysExitRC(NO_ENTITIES_FOUND_RC)
    return
  Ind.Increment()
  for asp in asps:
    if asp['creationTime'] == '0':
      created_date = UNKNOWN
    else:
      created_date = formatLocalTimestamp(asp['creationTime'])
    if asp['lastTimeUsed'] == '0':
      used_date = GC.NEVER
    else:
      used_date = formatLocalTimestamp(asp['lastTimeUsed'])
    printKeyValueList(['ID', asp['codeId']])
    Ind.Increment()
    printKeyValueList(['Name', asp['name']])
    printKeyValueList(['Created', created_date])
    printKeyValueList(['Last Used', used_date])
    Ind.Decrement()
  Ind.Decrement()

# gam <UserTypeEntity> delete asps|applicationspecificpasswords all|<AspIDList>
def deleteASP(users):
  cd = buildGAPIObject(API.DIRECTORY)
  codeIdList = getString(Cmd.OB_ASP_ID_LIST).lower()
  if codeIdList == 'all':
    allCodeIds = True
  else:
    allCodeIds = False
    codeIds = codeIdList.replace(',', ' ').split()
    for codeId in codeIds:
      if not codeId.isdigit():
        Cmd.Backup()
        usageErrorExit(Msg.INVALID_ENTITY.format(Ent.Singular(Ent.APPLICATION_SPECIFIC_PASSWORD), Msg.MUST_BE_NUMERIC))
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    if allCodeIds:
      try:
        asps = callGAPIitems(cd.asps(), 'list', 'items',
                             throwReasons=[GAPI.USER_NOT_FOUND],
                             userKey=user, fields='items(codeId)')
        codeIds = [asp['codeId'] for asp in asps]
      except GAPI.userNotFound:
        entityUnknownWarning(Ent.USER, user, i, count)
        continue
    jcount = len(codeIds)
    entityPerformActionNumItems([Ent.USER, user], jcount, Ent.APPLICATION_SPECIFIC_PASSWORD, i, count)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      continue
    Ind.Increment()
    j = 0
    for codeId in codeIds:
      j += 1
      try:
        callGAPI(cd.asps(), 'delete',
                 throwReasons=[GAPI.USER_NOT_FOUND, GAPI.INVALID, GAPI.INVALID_PARAMETER, GAPI.FORBIDDEN],
                 userKey=user, codeId=codeId)
        entityActionPerformed([Ent.USER, user, Ent.APPLICATION_SPECIFIC_PASSWORD, codeId], j, jcount)
      except (GAPI.invalid, GAPI.invalidParameter, GAPI.forbidden) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.APPLICATION_SPECIFIC_PASSWORD, codeId], str(e), j, jcount)
      except GAPI.userNotFound:
        entityUnknownWarning(Ent.USER, user, i, count)
        break
    Ind.Decrement()

# gam <UserTypeEntity> print asps|applicationspecificpasswords [todrive <ToDriveAttribute>*]
#	[oneitemperrow]
# gam <UserTypeEntity> show asps|applicationspecificpasswords
def printShowASPs(users):
  cd = buildGAPIObject(API.DIRECTORY)
  csvPF = CSVPrintFile(['User']) if Act.csvFormat() else None
  oneItemPerRow = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif csvPF and myarg == 'oneitemperrow':
      oneItemPerRow = True
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    if csvPF:
      printGettingEntityItemForWhom(Ent.APPLICATION_SPECIFIC_PASSWORD, user, i, count)
    try:
      asps = callGAPIitems(cd.asps(), 'list', 'items',
                           throwReasons=[GAPI.USER_NOT_FOUND],
                           userKey=user)
      if not csvPF:
        _showASPs(user, asps, i, count)
      else:
        for asp in asps:
          asp.pop('userKey', None)
          if asp['creationTime'] == '0':
            asp['creationTime'] = UNKNOWN
          else:
            asp['creationTime'] = formatLocalTimestamp(asp['creationTime'])
          if asp['lastTimeUsed'] == '0':
            asp['lastTimeUsed'] = GC.NEVER
          else:
            asp['lastTimeUsed'] = formatLocalTimestamp(asp['lastTimeUsed'])
        if not oneItemPerRow:
          csvPF.WriteRowTitles(flattenJSON({'asps': asps}, flattened={'User': user}))
        else:
          for asp in asps:
            csvPF.WriteRowTitles(flattenJSON({'asp': asp}, flattened={'User': user}))
    except GAPI.userNotFound:
      entityUnknownWarning(Ent.USER, user, i, count)
  if csvPF:
    csvPF.writeCSVfile('Application Specific Passwords')

def _showBackupCodes(user, codes, i, count):
  Act.Set(Act.SHOW)
  jcount = 0
  for code in codes:
    if code.get('verificationCode'):
      jcount += 1
  entityPerformActionNumItems([Ent.USER, user], jcount, Ent.BACKUP_VERIFICATION_CODES, i, count)
  if jcount == 0:
    setSysExitRC(NO_ENTITIES_FOUND_RC)
    return
  Ind.Increment()
  j = 0
  for code in codes:
    j += 1
    printKeyValueList([f'{j:2}', code.get('verificationCode')])
  Ind.Decrement()

# gam <UserTypeEntity> update backupcodes|verificationcodes
def updateBackupCodes(users):
  cd = buildGAPIObject(API.DIRECTORY)
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    userSuspended = checkUserSuspended(cd, user, Ent.USER, i, count)
    if userSuspended is None:
      continue
    if not userSuspended:
      try:
        callGAPI(cd.verificationCodes(), 'generate',
                 throwReasons=[GAPI.USER_NOT_FOUND, GAPI.INVALID, GAPI.INVALID_INPUT],
                 userKey=user)
        codes = callGAPIitems(cd.verificationCodes(), 'list', 'items',
                              throwReasons=[GAPI.USER_NOT_FOUND],
                              userKey=user, fields='items(verificationCode)')
        _showBackupCodes(user, codes, i, count)
      except GAPI.userNotFound:
        entityUnknownWarning(Ent.USER, user, i, count)
      except (GAPI.invalid, GAPI.invalidInput) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.BACKUP_VERIFICATION_CODES, None], str(e), i, count)
    else:
      entityActionNotPerformedWarning([Ent.USER, user, Ent.BACKUP_VERIFICATION_CODES, None],
                                      Msg.IS_SUSPENDED_NO_BACKUPCODES, i, count)

# gam <UserTypeEntity> delete backupcodes|verificationcodes
def deleteBackupCodes(users):
  cd = buildGAPIObject(API.DIRECTORY)
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    userSuspended = checkUserSuspended(cd, user, Ent.USER, i, count)
    if userSuspended is None:
      continue
    if not userSuspended:
      try:
        callGAPI(cd.verificationCodes(), 'invalidate',
                 throwReasons=[GAPI.USER_NOT_FOUND, GAPI.INVALID, GAPI.INVALID_INPUT],
                 userKey=user)
        printEntityKVList([Ent.USER, user], [Ent.Plural(Ent.BACKUP_VERIFICATION_CODES), '', 'Invalidated'], i, count)
      except GAPI.userNotFound:
        entityUnknownWarning(Ent.USER, user, i, count)
      except (GAPI.invalid, GAPI.invalidInput) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.BACKUP_VERIFICATION_CODES, None], str(e), i, count)
    else:
      entityActionNotPerformedWarning([Ent.USER, user, Ent.BACKUP_VERIFICATION_CODES, None],
                                      Msg.IS_SUSPENDED_NO_BACKUPCODES, i, count)

# gam <UserTypeEntity> print backupcodes|verificationcodes [todrive <ToDriveAttribute>*]
#	[delimiter <Character>] [countsonly]
# gam <UserTypeEntity> show backupcodes|verificationcodes
def printShowBackupCodes(users):
  cd = buildGAPIObject(API.DIRECTORY)
  csvPF = CSVPrintFile(['User', 'verificationCodesCount', 'verificationCodes']) if Act.csvFormat() else None
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  counts_only = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    elif myarg == 'countsonly':
      counts_only = True
    else:
      unknownArgumentExit()
  # if we're only getting counts, we don't want actual codes pulled down
  if counts_only:
    csvPF.RemoveTitles('verificationCodes')
    fields = 'items(etag)'
  else:
    fields = 'items(verificationCode)'
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    if csvPF:
      printGettingEntityItemForWhom(Ent.BACKUP_VERIFICATION_CODES, user, i, count)
    try:
      codes = callGAPIitems(cd.verificationCodes(), 'list', 'items',
                            throwReasons=[GAPI.USER_NOT_FOUND],
                            userKey=user, fields=fields)
      if not csvPF:
        _showBackupCodes(user, codes, i, count)
      elif counts_only:
        csvPF.WriteRow({'User': user, 'verificationCodesCount': len(codes)})
      else:
        csvPF.WriteRow({'User': user,
                        'verificationCodesCount': len(codes),
                        'verificationCodes': delimiter.join([code['verificationCode'] for code in codes if 'verificationCode' in code])})
    except GAPI.userNotFound:
      entityUnknownWarning(Ent.USER, user, i, count)
  if csvPF:
    csvPF.writeCSVfile('Backup Verification Codes')

def _getCalendarSelectProperty(myarg, kwargs):
  if myarg == 'minaccessrole':
    kwargs['minAccessRole'] = getChoice(CALENDAR_ACL_ROLES_MAP, mapChoice=True)
  elif myarg == 'showdeleted':
    kwargs['showDeleted'] = True
  elif myarg == 'showhidden':
    kwargs['showHidden'] = True
  else:
    return False
  return True

def initUserCalendarEntity():
  return {'list': [], 'kwargs': {}, 'dict': None, 'all': False, 'primary': False, 'resourceIds': []}

def getUserCalendarEntity(default='primary', noSelectionKwargs=None):

  def _initCourseCalendarSelectionParameters():
    return {'courseIds': [], 'teacherId': None, 'myCoursesAsTeacher': False,
            'studentId': None, 'myCoursesAsStudent': False, 'courseStates': []}

  def _getCourseCalendarSelectionParameters(myarg):
    if myarg in {'course', 'courses', 'class', 'classes'}:
      courseSelectionParameters['courseIds'].extend(getEntityList(Cmd.OB_COURSE_ENTITY, shlexSplit=True))
    elif myarg == 'courseswithteacher':
      courseSelectionParameters['teacherId'] = getEmailAddress()
      courseSelectionParameters['myCoursesAsTeacher'] = False
    elif myarg == 'mycoursesasteacher':
      courseSelectionParameters['myCoursesAsTeacher'] = True
      courseSelectionParameters['teacherId'] = None
    elif myarg == 'courseswithstudent':
      courseSelectionParameters['studentId'] = getEmailAddress()
      courseSelectionParameters['myCoursesAsStudent'] = False
    elif myarg == 'mycoursesasstudent':
      courseSelectionParameters['myCoursesAsStudent'] = True
      courseSelectionParameters['studentId'] = None
    elif myarg in {'coursestate', 'coursestates', 'coursestatus'}:
      _getCourseStates(Cmd.OB_COURSE_STATE_LIST, courseSelectionParameters['courseStates'])
    else:
      return False
    return True

  def _noSelectionMade():
    return (not calendarEntity['list'] and not calendarEntity['kwargs'] and calendarEntity['dict'] is None and
            not calendarEntity['all'] and not calendarEntity['primary'] and not calendarEntity['resourceIds'] and
            not courseCalendarSelected)

  calendarEntity = initUserCalendarEntity()
  courseSelectionParameters = _initCourseCalendarSelectionParameters()
  courseCalendarSelected = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'calendar', 'calendars'}:
      entitySelector = getEntitySelector()
      if entitySelector:
        entityList = getEntitySelection(entitySelector, False)
        if isinstance(entityList, dict):
          calendarEntity['dict'] = entityList
        else:
          calendarEntity['list'] = entityList
      else:
        calendarEntity['list'].extend(convertEntityToList(getString(Cmd.OB_EMAIL_ADDRESS_LIST)))
    elif myarg == 'allcalendars':
      calendarEntity['all'] = True
    elif myarg == 'primary':
      calendarEntity['primary'] = True
    elif _getCalendarSelectProperty(myarg, calendarEntity['kwargs']):
      pass
    elif myarg == 'resource':
      calendarEntity['resourceIds'].append(getString(Cmd.OB_RESOURCE_ID))
    elif myarg == 'resources':
      calendarEntity['resourceIds'].extend(convertEntityToList(getString(Cmd.OB_RESOURCE_ID, minLen=0), shlexSplit=True))
    elif _getCourseCalendarSelectionParameters(myarg):
      courseCalendarSelected = True
    elif _noSelectionMade() and (myarg.find('@') != -1 or myarg.find('id:') != -1):
      Cmd.Backup()
      calendarEntity['list'].append(getEmailAddress())
    else:
      Cmd.Backup()
      break
  if _noSelectionMade():
    if not noSelectionKwargs:
      calendarEntity[default] = True
    else:
      calendarEntity['all'] = True
      calendarEntity['kwargs'].update(noSelectionKwargs)
  elif (courseCalendarSelected and
        (courseSelectionParameters['courseIds'] or
         courseSelectionParameters['teacherId'] or courseSelectionParameters['myCoursesAsTeacher'] or
         courseSelectionParameters['studentId'] or courseSelectionParameters['myCoursesAsStudent'])):
    calendarEntity['courseSelectionParameters'] = courseSelectionParameters
    calendarEntity['courseShowProperties'] = _initCourseShowProperties(['calendarId'])
    calendarEntity['croom'] = buildGAPIObject(API.CLASSROOM)
  return calendarEntity

def _validateUserGetCalendarIds(user, i, count, calendarEntity,
                                itemType=None, modifier=None, showAction=True, setRC=True, newCalId=None, secondaryCalendarsOnly=False):
  if user and calendarEntity['dict']:
    calIds = calendarEntity['dict'][user][:]
  else:
    calIds = calendarEntity['list'][:]
  user, cal = validateCalendar(user, i, count, noClientAccess=True)
  if not cal:
    return (user, None, None, 0)
  if calendarEntity['resourceIds']:
    cd = buildGAPIObject(API.DIRECTORY)
    for resourceId in calendarEntity['resourceIds']:
      try:
        calIds.append(callGAPI(cd.resources().calendars(), 'get',
                               throwReasons=[GAPI.BAD_REQUEST, GAPI.RESOURCE_NOT_FOUND, GAPI.FORBIDDEN],
                               customer=GC.Values[GC.CUSTOMER_ID], calendarResourceId=resourceId,
                               fields='resourceEmail')['resourceEmail'])
      except (GAPI.badRequest, GAPI.resourceNotFound, GAPI.forbidden):
        checkEntityAFDNEorAccessErrorExit(cd, Ent.RESOURCE_CALENDAR, resourceId, i, count)
        return (user, None, None, 0)
  courseSelectionParameters = calendarEntity.get('courseSelectionParameters')
  if courseSelectionParameters is not None:
    if courseSelectionParameters['myCoursesAsTeacher']:
      courseSelectionParameters['teacherId'] = user
    if courseSelectionParameters['myCoursesAsStudent']:
      courseSelectionParameters['studentId'] = user
    coursesInfo = _getCoursesInfo(calendarEntity['croom'], courseSelectionParameters,
                                  calendarEntity['courseShowProperties'])
    if coursesInfo is None:
      return (user, None, None, 0)
    calIds.extend([course['calendarId'] for course in coursesInfo if 'calendarId' in course])
  if calendarEntity['primary']:
    calIds.append(user)
  try:
    if calendarEntity['kwargs'] or calendarEntity['all']:
      result = callGAPIpages(cal.calendarList(), 'list', 'items',
                             throwReasons=GAPI.CALENDAR_THROW_REASONS,
                             fields='nextPageToken,items/id', **calendarEntity['kwargs'])
      calIds.extend([calId['id'] for calId in result if not secondaryCalendarsOnly or calId['id'].find('@group.calendar.google.com') != -1])
    else:
      callGAPI(cal.calendars(), 'get',
               throwReasons=GAPI.CALENDAR_THROW_REASONS,
               calendarId='primary', fields='')
  except GAPI.notACalendarUser:
    userCalServiceNotEnabledWarning(user, i, count)
    return (user, None, None, 0)
  if newCalId:
    newcal = buildGAPIObject(API.CALENDAR)
    if not checkCalendarExists(newcal, newCalId, i, count):
      entityActionFailedWarning([Ent.USER, user, Ent.CALENDAR, newCalId], Msg.DOES_NOT_EXIST, i, count)
      return (user, None, None, 0)
  jcount = len(calIds)
  if setRC and jcount == 0:
    setSysExitRC(NO_ENTITIES_FOUND_RC)
  if showAction:
    if not itemType:
      entityPerformActionNumItems([Ent.USER, user], jcount, Ent.CALENDAR, i, count)
    elif not newCalId:
      entityPerformActionSubItemModifierNumItems([Ent.USER, user], itemType, modifier, jcount, Ent.CALENDAR, i, count)
    else:
      entityPerformActionSubItemModifierNumItemsModifierNewValue([Ent.USER, user], itemType, modifier, jcount, Ent.CALENDAR, Act.MODIFIER_TO, newCalId, i, count)
  return (user, cal, calIds, jcount)

CALENDAR_NOTIFICATION_METHODS = ['email']
CALENDAR_NOTIFICATION_TYPES_MAP = {
  'eventcreation': 'eventCreation',
  'eventchange': 'eventChange',
  'eventcancellation': 'eventCancellation',
  'eventresponse': 'eventResponse',
  'agenda': 'agenda',
  }

def _getCalendarAttributes(body, returnOnUnknownArgument=False):
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'selected':
      body['selected'] = getBoolean()
    elif myarg == 'hidden':
      body['hidden'] = getBoolean()
    elif myarg == 'summary':
      body['summaryOverride'] = getString(Cmd.OB_STRING)
    elif myarg in {'color', 'colour'}:
      body['colorId'] = getChoice(CALENDAR_COLOR_MAP, mapChoice=True)
    elif myarg in {'colorindex', 'colorid', 'colourindex', 'colourid'}:
      body['colorId'] = getInteger(minVal=CALENDAR_MIN_COLOR_INDEX, maxVal=CALENDAR_MAX_COLOR_INDEX)
    elif myarg in {'backgroundcolor', 'backgroundcolour'}:
      body['backgroundColor'] = getColor()
      body.setdefault('foregroundColor', '#000000')
    elif myarg in {'foregroundcolor', 'foregroundcolour'}:
      body['foregroundColor'] = getColor()
    elif myarg == 'reminder':
      body.setdefault('defaultReminders', [])
      if not checkArgumentPresent(Cmd.CLEAR_NONE_ARGUMENT):
        body['defaultReminders'].append(getCalendarReminder(True))
    elif myarg == 'notification':
      body.setdefault('notificationSettings', {'notifications': []})
      method = getChoice(CALENDAR_NOTIFICATION_METHODS+Cmd.CLEAR_NONE_ARGUMENT)
      if method not in Cmd.CLEAR_NONE_ARGUMENT:
        for ntype in _getFieldsList():
          if ntype in CALENDAR_NOTIFICATION_TYPES_MAP:
            body['notificationSettings']['notifications'].append({'method': method,
                                                                  'type': CALENDAR_NOTIFICATION_TYPES_MAP[ntype]})
          else:
            invalidChoiceExit(ntype, CALENDAR_NOTIFICATION_TYPES_MAP, True)
      else:
        body['notificationSettings']['notifications'] = []
    elif returnOnUnknownArgument:
      Cmd.Backup()
      return
    else:
      unknownArgumentExit()

def _showCalendar(calendar, j, jcount, FJQC, acls=None):
  if FJQC.formatJSON:
    if acls:
      calendar['acls'] = [{'id': rule['id'], 'role': rule['role']} for rule in acls]
    printLine(json.dumps(cleanJSON(calendar), ensure_ascii=False, sort_keys=True))
    return
  _showCalendarSettings(calendar, j, jcount)
  Ind.Increment()
  if 'primary' in calendar:
    printKeyValueList(['Primary', calendar['primary']])
  if 'accessRole' in calendar:
    printKeyValueList(['Access Level', calendar['accessRole']])
  if 'deleted' in calendar:
    printKeyValueList(['Deleted', calendar['deleted']])
  if 'hidden' in calendar:
    printKeyValueList(['Hidden', calendar['hidden']])
  if 'selected' in calendar:
    printKeyValueList(['Selected', calendar['selected']])
  if 'colorId' in calendar:
    printKeyValueList(['Color ID', calendar['colorId'], 'Background Color', calendar['backgroundColor'], 'Foreground Color', calendar['foregroundColor']])
  if 'defaultReminders' in calendar:
    printKeyValueList(['Default Reminders', None])
    Ind.Increment()
    for reminder in calendar['defaultReminders']:
      printKeyValueList(['Method', reminder['method'], 'Minutes', reminder['minutes']])
    Ind.Decrement()
  if 'notificationSettings' in calendar:
    printKeyValueList(['Notifications', None])
    Ind.Increment()
    for notification in calendar['notificationSettings'].get('notifications', []):
      printKeyValueList(['Method', notification['method'], 'Type', notification['type']])
    Ind.Decrement()
  if acls:
    j = 0
    jcount = len(acls)
    printEntitiesCount(Ent.CALENDAR_ACL, acls)
    Ind.Increment()
    for rule in acls:
      j += 1
      printKeyValueListWithCount(ACLRuleKeyValueList(rule), j, jcount)
    Ind.Decrement()
  Ind.Decrement()

# Process CalendarList functions
def _processCalendarList(user, i, count, calId, j, jcount, cal, function, **kwargs):
  try:
    callGAPI(cal.calendarList(), function,
             throwReasons=[GAPI.NOT_FOUND, GAPI.DUPLICATE, GAPI.UNKNOWN_ERROR,
                           GAPI.CANNOT_CHANGE_OWN_ACL, GAPI.CANNOT_CHANGE_OWN_PRIMARY_SUBSCRIPTION],
             **kwargs)
    entityActionPerformed([Ent.USER, user, Ent.CALENDAR, calId], j, jcount)
  except (GAPI.notFound, GAPI.duplicate, GAPI.unknownError, GAPI.serviceNotAvailable,
          GAPI.cannotChangeOwnAcl, GAPI.cannotChangeOwnPrimarySubscription) as e:
    entityActionFailedWarning([Ent.USER, user, Ent.CALENDAR, calId], str(e), j, jcount)
  except GAPI.notACalendarUser:
    userCalServiceNotEnabledWarning(user, i, count)

# gam <UserTypeEntity> add calendars <UserCalendarAddEntity> <CalendarAttribute>*
def addCalendars(users):
  calendarEntity = getUserCalendarEntity()
  body = {'selected': True, 'hidden': False}
  _getCalendarAttributes(body)
  colorRgbFormat = 'backgroundColor' in body or 'foregroundColor' in body
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for calId in calIds:
      j += 1
      body['id'] = calId = normalizeCalendarId(calId, user)
      _processCalendarList(user, i, count, calId, j, jcount, cal, 'insert',
                           body=body, colorRgbFormat=colorRgbFormat, fields='')
    Ind.Decrement()

def _updateDeleteCalendars(users, calendarEntity, function, **kwargs):
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for calId in calIds:
      j += 1
      calId = normalizeCalendarId(calId, user)
      _processCalendarList(user, i, count, calId, j, jcount, cal, function,
                           calendarId=calId, **kwargs)
    Ind.Decrement()

# gam <UserTypeEntity> update calendars <UserCalendarEntity> <CalendarAttribute>+
def updateCalendars(users):
  calendarEntity = getUserCalendarEntity()
  body = {}
  _getCalendarAttributes(body)
  colorRgbFormat = 'backgroundColor' in body or 'foregroundColor' in body
  _updateDeleteCalendars(users, calendarEntity, 'patch', body=body, colorRgbFormat=colorRgbFormat, fields='')

# gam <UserTypeEntity> delete calendars <UserCalendarEntity>
def deleteCalendars(users):
  calendarEntity = getUserCalendarEntity()
  checkForExtraneousArguments()
  _updateDeleteCalendars(users, calendarEntity, 'delete')


# gam <UserTypeEntity> create calendars <CalendarSettings>
def createCalendar(users):
  calendarEntity = initUserCalendarEntity()
  body = getCalendarSettings(summaryRequired=True)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, cal, _, _ = _validateUserGetCalendarIds(user, i, count, calendarEntity, showAction=False, setRC=False)
    if not cal:
      continue
    try:
      calId = callGAPI(cal.calendars(), 'insert',
                       throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.FORBIDDEN],
                       body=body, fields='id')['id']
      entityActionPerformed([Ent.USER, user, Ent.CALENDAR, calId], i, count)
    except GAPI.forbidden as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
    except GAPI.notACalendarUser:
      userCalServiceNotEnabledWarning(user, i, count)

def addCreateCalendars(users):
  if Act.Get() == Act.ADD:
    addCalendars(users)
  else:
    createCalendar(users)

def _modifyRemoveCalendars(users, calendarEntity, function, **kwargs):
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for calId in calIds:
      j += 1
      calId = normalizeCalendarId(calId, user)
      try:
        callGAPI(cal.calendars(), function,
                 throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.CANNOT_DELETE_PRIMARY_CALENDAR,
                                                           GAPI.FORBIDDEN, GAPI.INVALID, GAPI.REQUIRED_ACCESS_LEVEL],
                 calendarId=calId, **kwargs)
        entityActionPerformed([Ent.USER, user, Ent.CALENDAR, calId], j, jcount)
      except (GAPI.notFound, GAPI.cannotDeletePrimaryCalendar, GAPI.forbidden, GAPI.invalid, GAPI.requiredAccessLevel) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.CALENDAR, calId], str(e), j, jcount)
      except GAPI.notACalendarUser:
        userCalServiceNotEnabledWarning(user, i, count)
        break
    Ind.Decrement()

# gam <UserTypeEntity> modify calendars <UserCalendarEntity> <CalendarSettings>
def modifyCalendars(users):
  calendarEntity = getUserCalendarEntity()
  body = getCalendarSettings(summaryRequired=False)
  _modifyRemoveCalendars(users, calendarEntity, 'patch', body=body)

# gam <UserTypeEntity> remove calendars <UserCalendarEntity>
def removeCalendars(users):
  calendarEntity = getUserCalendarEntity()
  checkForExtraneousArguments()
  _modifyRemoveCalendars(users, calendarEntity, 'delete')

def _getCalendarPermissions(cal, calendar):
  if calendar['accessRole'] == 'owner':
    try:
      return callGAPIpages(cal.acl(), 'list', 'items',
                           throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND],
                           calendarId=calendar['id'], fields='nextPageToken,items(id,role,scope)')
    except (GAPI.notACalendarUser, GAPI.notFound):
      pass
  return []

CALENDAR_LIST_FIELDS_CHOICE_MAP = {
  "accessrole": 'accessRole',
  "backgroundcolor": 'backgroundColor',
  "backgroundcolour": 'backgroundColor',
  "colorid": 'colorId',
  "conferenceproperties": 'conferenceProperties',
  "defaultreminders": 'defaultReminders',
  "deleted": 'deleted',
  "description": 'description',
  "foregroundcolor": 'foregroundColor',
  "foregroundcolour": 'foregroundColor',
  "hidden": 'hidden',
  "id": 'id',
  "location": 'location',
  "notificationsettings": 'notificationSettings',
  "primary": 'primary',
  "selected": 'selected',
  "summary": ['summary', 'summaryOverride'],
  "summaryoverride": ['summary', 'summaryOverride'],
  "timezone": 'timeZone',
   }

# gam <UserTypeEntity> info calendars <UserCalendarEntity>
#	[fields <CalendarFieldList>]  [permissions]
#	[formatjson]
def infoCalendars(users):
  calendarEntity = getUserCalendarEntity()
  FJQC = FormatJSONQuoteChar()
  fieldsList = []
  acls = []
  getCalPermissions = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in [Cmd.ARG_ACLS, Cmd.ARG_CALENDARACLS, Cmd.ARG_PERMISSIONS]:
      getCalPermissions = True
    elif getFieldsList(myarg, CALENDAR_LIST_FIELDS_CHOICE_MAP, fieldsList, initialField='id'):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  if fieldsList:
    if getCalPermissions:
      fieldsList.append('accessRole')
  fields = getFieldsFromFieldsList(fieldsList)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity, showAction=not FJQC.formatJSON)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for calId in calIds:
      j += 1
      calId = normalizeCalendarId(calId, user)
      try:
        result = callGAPI(cal.calendarList(), 'get',
                          throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND],
                          calendarId=calId, fields=fields)
        if getCalPermissions:
          acls = _getCalendarPermissions(cal, result)
        _showCalendar(result, j, jcount, FJQC, acls)
      except GAPI.notFound as e:
        entityActionFailedWarning([Ent.USER, user, Ent.CALENDAR, calId], str(e), j, jcount)
      except GAPI.notACalendarUser:
        userCalServiceNotEnabledWarning(user, i, count)
        break
    Ind.Decrement()

CALENDAR_SIMPLE_LISTS = {'allowedConferenceSolutionTypes'}
CALENDAR_EXCLUDE_OPTIONS = {'noprimary', 'nogroups', 'noresources', 'nosystem', 'nousers'}
CALENDAR_EXCLUDE_DOMAINS = {
  'nogroups': 'group.calendar.google.com',
  'noresources': 'resource.calendar.google.com',
  'nosystem': 'group.v.calendar.google.com',
  }

# gam <UserTypeEntity> print calendars <UserCalendarEntity> [todrive <ToDriveAttribute>*]
#	[primary] <CalendarSelectProperty>* [noprimary] [nogroups] [noresources] [nosystem] [nousers]
#	[fields <CalendarFieldList>] [permissions]
#	[formatjson [quotechar <Character>]] [delimiter <Character>]
# gam <UserTypeEntity> show calendars <UserCalendarEntity>
#	[primary] <CalendarSelectProperty>* [noprimary] [nogroups] [noresources] [nosystem] [nousers]
#	[fields <CalendarFieldList>] [permissions]
#	[formatjson]
def printShowCalendars(users):
  acls = []
  getCalPermissions = noPrimary = primaryOnly = False
  excludes = set()
  excludeDomains = set()
  csvPF = CSVPrintFile(['primaryEmail', 'calendarId'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  kwargs = {}
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  fieldsList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in [Cmd.ARG_ACLS, Cmd.ARG_CALENDARACLS, Cmd.ARG_PERMISSIONS]:
      getCalPermissions = True
    elif myarg == 'allcalendars':
      pass
    elif myarg == 'primary':
      primaryOnly = True
    elif _getCalendarSelectProperty(myarg, kwargs):
      pass
    elif myarg in CALENDAR_EXCLUDE_OPTIONS:
      excludes.add(myarg)
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    elif getFieldsList(myarg, CALENDAR_LIST_FIELDS_CHOICE_MAP, fieldsList, initialField='id'):
      pass
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  for exclude in excludes:
    if exclude == 'noprimary':
      noPrimary = True
    elif exclude == 'nousers':
      excludeDomains.add(GC.Values[GC.DOMAIN])
    else:
      excludeDomains.add(CALENDAR_EXCLUDE_DOMAINS[exclude])
  if fieldsList:
    if getCalPermissions:
      fieldsList.append('accessRole')
    if noPrimary or primaryOnly:
      fieldsList.append('primary')
  fields = getItemFieldsFromFieldsList('items', fieldsList)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, cal = validateCalendar(user, i, count, noClientAccess=True)
    if not cal:
      continue
    if csvPF:
      printGettingEntityItemForWhom(Ent.CALENDAR, user, i, count)
    try:
      calendars = callGAPIpages(cal.calendarList(), 'list', 'items',
                                throwReasons=GAPI.CALENDAR_THROW_REASONS,
                                fields=fields, **kwargs)
    except GAPI.notACalendarUser:
      userCalServiceNotEnabledWarning(user, i, count)
      continue
    if primaryOnly:
      for calendar in calendars:
        if calendar.get('primary', False):
          calendars = [calendar]
          break
      else:
        calendars = []
    elif noPrimary or excludeDomains:
      allCalendars = calendars[:]
      calendars = []
      for calendar in allCalendars:
        primary = calendar.get('primary', False)
        if noPrimary and primary:
          continue
        if not primary and excludeDomains:
          _, domain = splitEmailAddress(calendar['id'])
          if domain in excludeDomains:
            continue
        calendars.append(calendar)
    jcount = len(calendars)
    if not csvPF:
      if not FJQC.formatJSON:
        entityPerformActionNumItems([Ent.USER, user], jcount, Ent.CALENDAR, i, count)
      Ind.Increment()
      j = 0
      for calendar in calendars:
        j += 1
        if getCalPermissions:
          acls = _getCalendarPermissions(cal, calendar)
        _showCalendar(calendar, j, jcount, FJQC, acls)
      Ind.Decrement()
    else:
      if calendars:
        for calendar in calendars:
          row = {'primaryEmail': user, 'calendarId': calendar['id']}
          if getCalPermissions:
            flattenJSON({'permissions': _getCalendarPermissions(cal, calendar)}, flattened=row)
          flattenJSON(calendar, flattened=row, simpleLists=CALENDAR_SIMPLE_LISTS, delimiter=delimiter)
          if not FJQC.formatJSON:
            row.pop('id')
            csvPF.WriteRowTitles(row)
          elif csvPF.CheckRowTitles(row):
            csvPF.WriteRowNoFilter({'primaryEmail': user, 'calendarId': calendar['id'],
                                    'JSON': json.dumps(cleanJSON(calendar), ensure_ascii=False, sort_keys=True)})
      elif GC.Values[GC.CSV_OUTPUT_USERS_AUDIT]:
        csvPF.WriteRowNoFilter({'primaryEmail': user})
  if csvPF:
    csvPF.writeCSVfile('Calendars')

USER_CALENDAR_SETTINGS_FIELDS_CHOICE_MAP = {
  'autoaddhangouts': 'autoAddHangouts',
  'datefieldorder': 'dateFieldOrder',
  'defaulteventlength': 'defaultEventLength',
  'format24hourtime': 'format24HourTime',
  'hideinvitations': 'hideInvitations',
  'hideweekends': 'hideWeekends',
  'locale': 'locale',
  'remindonrespondedeventsonly': 'remindOnRespondedEventsOnly',
  'showdeclinedevents': 'showDeclinedEvents',
  'timezone': 'timezone',
  'usekeyboardshortcuts': 'useKeyboardShortcuts',
  'weekstart': 'weekStart'
  }

# gam <UserTypeEntity> print calsettings  [todrive <ToDriveAttribute>*]
#	[fields <UserCalendarSettingsFieldList>]
#	[formatjson] [quotechar <Character>}
# gam <UserTypeEntity> show calsettings
#	[formatjson]
def printShowCalSettings(users):
  csvPF = CSVPrintFile(['User'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  fieldsList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif getFieldsList(myarg, USER_CALENDAR_SETTINGS_FIELDS_CHOICE_MAP, fieldsList):
      pass
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  fields = set(fieldsList)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, cal = validateCalendar(user, i, count, noClientAccess=True)
    if not cal:
      continue
    try:
      feed = callGAPIpages(cal.settings(), 'list', 'items',
                           throwReasons=GAPI.CALENDAR_THROW_REASONS)
    except GAPI.notACalendarUser:
      userCalServiceNotEnabledWarning(user, i, count)
      continue
    settings = {}
    for setting in feed:
      if not fields or setting['id'] in fields:
        settings[setting['id']] = setting['value']
    if not csvPF:
      if not FJQC.formatJSON:
        printEntityKVList([Ent.USER, user], [Ent.Plural(Ent.CALENDAR_SETTINGS), None], i, count)
        Ind.Increment()
        for attr in sorted(settings):
          printKeyValueList([attr, settings[attr]])
        Ind.Decrement()
      else:
        printLine(json.dumps({'User': user, 'settings': settings}, ensure_ascii=False, sort_keys=True))
    else:
      row = flattenJSON(settings, flattened={'User': user})
      if not FJQC.formatJSON:
        csvPF.WriteRowTitles(row)
      elif csvPF.CheckRowTitles(row):
        csvPF.WriteRowNoFilter({'User': user, 'JSON': json.dumps(settings, ensure_ascii=False, sort_keys=True)})
  if csvPF:
    csvPF.writeCSVfile('Calendar Settings')

# gam <UserTypeEntity> create calendaracls <UserCalendarEntity> <CalendarACLRole> <CalendarACLScopeEntity> [sendnotifications <Boolean>]
def createCalendarACLs(users):
  calendarEntity = getUserCalendarEntity()
  role, ACLScopeEntity, sendNotifications = getCalendarCreateUpdateACLsOptions(True)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity, Ent.CALENDAR_ACL, Act.MODIFIER_TO)
    if jcount == 0:
      continue
    Ind.Increment()
    _doCalendarsCreateACLs(origUser, user, cal, calIds, jcount, role, ACLScopeEntity, sendNotifications)
    Ind.Decrement()

def updateDeleteCalendarACLs(users, calendarEntity, function, modifier, ACLScopeEntity, role, sendNotifications):
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity, Ent.CALENDAR_ACL, modifier)
    if jcount == 0:
      continue
    Ind.Increment()
    _doUpdateDeleteCalendarACLs(origUser, user, cal, function, calIds, jcount, ACLScopeEntity, role, sendNotifications)
    Ind.Decrement()

# gam <UserTypeEntity> update calendaracls <UserCalendarEntity> <CalendarACLRole> <CalendarACLScopeEntity> [sendnotifications <Boolean>]
def updateCalendarACLs(users):
  calendarEntity = getUserCalendarEntity()
  role, ACLScopeEntity, sendNotifications = getCalendarCreateUpdateACLsOptions(True)
  updateDeleteCalendarACLs(users, calendarEntity, 'patch', Act.MODIFIER_IN, ACLScopeEntity, role, sendNotifications)

# gam <UserTypeEntity> delete calendaracls <UserCalendarEntity> [<CalendarACLRole>] <CalendarACLScopeEntity>
def deleteCalendarACLs(users):
  calendarEntity = getUserCalendarEntity()
  role, ACLScopeEntity = getCalendarDeleteACLsOptions(True)
  updateDeleteCalendarACLs(users, calendarEntity, 'delete', Act.MODIFIER_FROM, ACLScopeEntity, role, False)

# gam <UserTypeEntity> info calendaracls <UserCalendarEntity> <CalendarACLScopeEntity>
#	[formatjson]
def infoCalendarACLs(users):
  calendarEntity = getUserCalendarEntity()
  ACLScopeEntity = getCalendarSiteACLScopeEntity()
  FJQC = _getCalendarInfoACLOptions()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity, Ent.CALENDAR_ACL, Act.MODIFIER_FROM, showAction=not FJQC.formatJSON)
    if jcount == 0:
      continue
    Ind.Increment()
    _doInfoCalendarACLs(origUser, user, cal, calIds, jcount, ACLScopeEntity, FJQC)
    Ind.Decrement()

# gam <UserTypeEntity> print calendaracls <UserCalendarEntity> [todrive <ToDriveAttribute>*]
#	[noselfowner] (addcsvdata <FieldName> <String>)*
#	[formatjson [quotechar <Character>]]
# gam <UserTypeEntity> show calendaracls <UserCalendarEntity>
#	[noselfowner]
#	[formatjson]
def printShowCalendarACLs(users):
  calendarEntity = getUserCalendarEntity(default='all')
  csvPF, FJQC, noSelfOwner, addCSVData = _getCalendarPrintShowACLOptions(['primaryEmail', 'calendarId'])
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity, Ent.CALENDAR_ACL, Act.MODIFIER_FROM, showAction=not csvPF and not FJQC.formatJSON)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for calId in calIds:
      j += 1
      calId = convertUIDtoEmailAddress(calId)
      _printShowCalendarACLs(cal, user, Ent.CALENDAR, calId, j, jcount, csvPF, FJQC, noSelfOwner, addCSVData)
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('Calendar ACLs')

TRANSFER_CALENDAR_APPEND_FIELDS = ['description', 'location', 'summary']

# gam <UserTypeEntity> transfer calendars|seccals <UserItem> [<UserCalendarEntity>]
#	[keepuser | (retainrole <CalendarACLRole>)] [sendnotifications <Boolean>] [noretentionmessages]
#	<CalendarSettings>* [append description|location|summary] [noupdatemessages]
#	[deletefromoldowner] [addtonewowner <CalendarAttribute>*] [nolistmessages]
def transferCalendars(users):
  targetUser = getEmailAddress()
  calendarEntity = getUserCalendarEntity(noSelectionKwargs={'minAccessRole': 'owner', 'showHidden': True})
  notAllowedForbidden = Msg.FORBIDDEN if (not calendarEntity['all']) and (not calendarEntity.get('kwargs', {}).get('minAccessRole', '') == 'owner') else Msg.NOT_ALLOWED
  retainRoleBody = {'role': 'none'}
  sendNotifications = showListMessages = showRetentionMessages = showUpdateMessages = True
  updateBody = {}
  appendFieldsList = []
  addToNewOwner = deleteFromOldOwner = False
  targetListBody = {'selected': True, 'hidden': False}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'keepuser':
      retainRoleBody['role'] = 'owner'
    elif myarg == 'retainrole':
      retainRoleBody['role'] = getChoice(CALENDAR_ACL_ROLES_MAP, mapChoice=True)
    elif myarg == 'sendnotifications':
      sendNotifications = getBoolean()
    elif _getCalendarSetting(myarg, updateBody):
      pass
    elif myarg == 'append':
      for field in _getFieldsList():
        if field in TRANSFER_CALENDAR_APPEND_FIELDS:
          appendFieldsList.append(field)
        else:
          invalidChoiceExit(field, TRANSFER_CALENDAR_APPEND_FIELDS, True)
    elif myarg == 'nolistmessages':
      showListMessages = False
    elif myarg == 'noretentionmessages':
      showRetentionMessages = False
    elif myarg == 'noupdatemessages':
      showUpdateMessages = False
    elif myarg == 'deletefromoldowner':
      deleteFromOldOwner = True
    elif myarg == 'addtonewowner':
      addToNewOwner = True
      _getCalendarAttributes(targetListBody, returnOnUnknownArgument=True)
    else:
      unknownArgumentExit()
  targetUser, targetCal = validateCalendar(targetUser, noClientAccess=True)
  if not targetCal:
    return
  colorRgbFormat = 'backgroundColor' in targetListBody or 'foregroundColor' in targetListBody
  if 'summaryOverride' in targetListBody and 'summary' not in updateBody:
    updateBody['summary'] = targetListBody.pop('summaryOverride')
  if updateBody:
    timestamp = currentISOformatTimeStamp('seconds')
    appendFields = ','.join(set(appendFieldsList))
  targetRoleBody = {'role': 'owner', 'scope': {'type': 'user', 'value': targetUser}}
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    Act.Set(Act.TRANSFER_OWNERSHIP)
    user, sourceCal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity, secondaryCalendarsOnly=True)
    if jcount == 0:
      continue
    if updateBody:
      userName, domain = splitEmailAddress(user)
      for field in updateBody:
        updateBody[field] = _substituteForUser(updateBody[field], user, userName)
        updateBody[field] = updateBody[field].replace('#domain#', domain)
        updateBody[field] = updateBody[field].replace('#timestamp#', timestamp)
    sourceRuleId = f'user:{user}'
    Ind.Increment()
    j = 0
    for calId in calIds:
      j += 1
      Act.Set(Act.TRANSFER_OWNERSHIP)
      if calId.find('@group.calendar.google.com') == -1:
        entityActionNotPerformedWarning([Ent.CALENDAR, calId], notAllowedForbidden, j, jcount)
        continue
      try:
        callGAPI(sourceCal.acl(), 'insert',
                 throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.FORBIDDEN, GAPI.REQUIRED_ACCESS_LEVEL],
                 calendarId=calId, body=targetRoleBody, sendNotifications=sendNotifications, fields='')
        entityModifierNewValueItemValueListActionPerformed([Ent.CALENDAR, calId], Act.MODIFIER_TO, None, [Ent.USER, targetUser], j, jcount)
      except (GAPI.forbidden, GAPI.requiredAccessLevel) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.CALENDAR, calId], str(e), j, jcount)
        continue
      except (GAPI.notFound, GAPI.invalid):
        entityUnknownWarning(Ent.CALENDAR, calId, j, jcount)
        continue
      except GAPI.notACalendarUser:
        userCalServiceNotEnabledWarning(user, i, count)
        break
      if updateBody:
        Act.Set(Act.UPDATE)
        try:
          if appendFields:
            body = callGAPI(targetCal.calendars(), 'get',
                            throwReasons=GAPI.CALENDAR_THROW_REASONS+GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                            calendarId=calId, fields=appendFields)
            for field in appendFieldsList:
              if field in updateBody:
                if field in body:
                  body[field] += updateBody[field]
                else:
                  body[field] = updateBody[field]
          else:
            body = {}
          for field, updateField in iter(updateBody.items()):
            if field not in appendFieldsList:
              body[field] = updateField
          callGAPI(targetCal.calendars(), 'patch',
                   throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                   calendarId=calId, body=body)
          if showUpdateMessages:
            entityActionPerformed([Ent.CALENDAR, calId], j, jcount)
        except (GAPI.notFound) as e:
          entityActionFailedWarning([Ent.CALENDAR, calId], str(e), j, jcount)
        except GAPI.notACalendarUser:
          userCalServiceNotEnabledWarning(targetUser, i, count)
          break
      if addToNewOwner:
        Act.Set(Act.ADD)
        targetListBody['id'] = calId
        try:
          callGAPI(targetCal.calendarList(), 'insert',
                   throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.DUPLICATE, GAPI.UNKNOWN_ERROR,
                                                             GAPI.CANNOT_CHANGE_OWN_ACL, GAPI.CANNOT_CHANGE_OWN_PRIMARY_SUBSCRIPTION],
                   body=targetListBody, colorRgbFormat=colorRgbFormat, fields='')
          if showListMessages:
            entityModifierNewValueItemValueListActionPerformed([Ent.CALENDAR, calId], Act.MODIFIER_TO, None, [Ent.USER, targetUser], j, jcount)
        except (GAPI.notFound, GAPI.duplicate, GAPI.unknownError,
                GAPI.cannotChangeOwnAcl, GAPI.cannotChangeOwnPrimarySubscription) as e:
          entityActionFailedWarning([Ent.CALENDAR, calId], str(e), j, jcount)
        except GAPI.notACalendarUser:
          userCalServiceNotEnabledWarning(targetUser, i, count)
          break
      Act.Set(Act.RETAIN)
      if retainRoleBody['role'] == 'owner':
        if showRetentionMessages:
          entityActionPerformed([Ent.CALENDAR, calId, Ent.CALENDAR_ACL, formatACLScopeRole(sourceRuleId, retainRoleBody['role'])], j, jcount)
      elif retainRoleBody['role'] != 'none':
        try:
          callGAPI(targetCal.acl(), 'patch',
                   throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.INVALID, GAPI.INVALID_PARAMETER,
                                                             GAPI.INVALID_SCOPE_VALUE, GAPI.ILLEGAL_ACCESS_ROLE_FOR_DEFAULT,
                                                             GAPI.CANNOT_CHANGE_OWN_ACL, GAPI.CANNOT_CHANGE_OWNER_ACL, GAPI.FORBIDDEN],
                   calendarId=calId, ruleId=sourceRuleId, body=retainRoleBody, sendNotifications=sendNotifications, fields='')
          if showRetentionMessages:
            entityActionPerformed([Ent.CALENDAR, calId, Ent.CALENDAR_ACL, formatACLScopeRole(sourceRuleId, retainRoleBody['role'])], j, jcount)
        except GAPI.notFound as e:
          if not checkCalendarExists(targetCal, calId, i, count):
            entityUnknownWarning(Ent.CALENDAR, calId, j, jcount)
          else:
            entityActionFailedWarning([Ent.CALENDAR, calId, Ent.CALENDAR_ACL, formatACLScopeRole(sourceRuleId, retainRoleBody['role'])], str(e), j, jcount)
        except (GAPI.invalid, GAPI.invalidParameter, GAPI.invalidScopeValue, GAPI.illegalAccessRoleForDefault, GAPI.forbidden, GAPI.cannotChangeOwnAcl, GAPI.cannotChangeOwnerAcl) as e:
          entityActionFailedWarning([Ent.CALENDAR, calId, Ent.CALENDAR_ACL, formatACLScopeRole(sourceRuleId, retainRoleBody['role'])], str(e), j, jcount)
        except GAPI.notACalendarUser:
          userCalServiceNotEnabledWarning(targetUser, i, count)
          break
      else:
        try:
          callGAPI(targetCal.acl(), 'delete',
                   throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.INVALID],
                   calendarId=calId, ruleId=sourceRuleId)
          if showRetentionMessages:
            entityActionPerformed([Ent.CALENDAR, calId, Ent.CALENDAR_ACL, formatACLScopeRole(sourceRuleId, retainRoleBody['role'])], j, jcount)
        except (GAPI.notFound, GAPI.invalid):
          entityUnknownWarning(Ent.CALENDAR, calId, j, jcount)
        except GAPI.notACalendarUser:
          userCalServiceNotEnabledWarning(targetUser, i, count)
          break
      if deleteFromOldOwner:
        Act.Set(Act.DELETE)
        try:
          callGAPI(sourceCal.calendarList(), 'delete',
                   throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.DUPLICATE, GAPI.UNKNOWN_ERROR,
                                                             GAPI.CANNOT_CHANGE_OWN_ACL, GAPI.CANNOT_CHANGE_OWN_PRIMARY_SUBSCRIPTION],
                   calendarId=calId)
          entityModifierNewValueItemValueListActionPerformed([Ent.CALENDAR, calId], Act.MODIFIER_FROM, None, [Ent.USER, user], j, jcount)
        except GAPI.notFound as e:
          if retainRoleBody['role'] == 'none':
            if showListMessages:
              entityModifierNewValueItemValueListActionPerformed([Ent.CALENDAR, calId], Act.MODIFIER_FROM, None, [Ent.USER, user], j, jcount)
          else:
            entityActionFailedWarning([Ent.CALENDAR, calId], str(e), j, jcount)
        except (GAPI.duplicate, GAPI.unknownError,
                GAPI.cannotChangeOwnAcl, GAPI.cannotChangeOwnPrimarySubscription) as e:
          entityActionFailedWarning([Ent.CALENDAR, calId], str(e), j, jcount)
        except GAPI.notACalendarUser:
          userCalServiceNotEnabledWarning(user, i, count)
          break
    Ind.Decrement()

def _createImportCalendarEvent(users, function):
  calendarEntity = getUserCalendarEntity()
  body, parameters = _getCalendarCreateImportUpdateEventOptions(function, Ent.USER)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity, Ent.EVENT, Act.MODIFIER_TO)
    if jcount == 0:
      continue
    Ind.Increment()
    _createCalendarEvents(user, cal, function, calIds, jcount, body, parameters)
    Ind.Decrement()

# gam <UserTypeEntity> create event <UserCalendarEntity> [id <String>] <EventAddAttribute>+
#	[showdayofweek]
#	[csv [todrive <ToDriveAttribute>*] [formatjson [quotechar <Character>]]]
def createCalendarEvent(users):
  _createImportCalendarEvent(users, 'insert')

# gam <UserTypeEntity> import event <UserCalendarEntity> icaluid <iCalUID> <EventImportAttribute>+
#	[showdayofweek]
#	[csv [todrive <ToDriveAttribute>*] [formatjson [quotechar <Character>]]]
def importCalendarEvent(users):
  _createImportCalendarEvent(users, 'import')

# gam <UserTypeEntity> update events <UserCalendarEntity> [<EventEntity>] [replacemode] <EventUpdateAttribute>+ [<EventNotificationAttribute>]
#	[showdayofweek]
#	[csv [todrive <ToDriveAttribute>*] [formatjson [quotechar <Character>]]]
def updateCalendarEvents(users):
  calendarEntity = getUserCalendarEntity()
  calendarEventEntity = getCalendarEventEntity()
  body, parameters = _getCalendarCreateImportUpdateEventOptions('update', Ent.USER)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity, Ent.EVENT, Act.MODIFIER_IN)
    if jcount == 0:
      continue
    Ind.Increment()
    _updateCalendarEvents(origUser, user, cal, calIds, jcount, calendarEventEntity, body, parameters)
    Ind.Decrement()

# gam <UserTypeEntity> delete events <UserCalendarEntity> <EventEntity> [doit] [<EventNotificationAttribute>]
def deleteCalendarEvents(users):
  calendarEntity = getUserCalendarEntity()
  calendarEventEntity = getCalendarEventEntity()
  parameters = _getCalendarDeleteEventOptions()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity, Ent.EVENT, Act.MODIFIER_FROM)
    if jcount == 0:
      continue
    Ind.Increment()
    _deleteCalendarEvents(origUser, user, cal, calIds, jcount, calendarEventEntity, parameters)
    Ind.Decrement()

# gam <UserTypeEntity> purge events <UserCalendarEntity> <EventEntity> [doit] [<EventNotificationAttribute>]
def purgeCalendarEvents(users):
  calendarEntity = getUserCalendarEntity()
  calendarEventEntity = getCalendarEventEntity()
  parameters = _getCalendarDeleteEventOptions()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity, Ent.EVENT, Act.MODIFIER_FROM)
    if jcount == 0:
      continue
    Ind.Increment()
    _purgeCalendarEvents(origUser, user, cal, calIds, jcount, calendarEventEntity, parameters, False)
    Ind.Decrement()

# gam <UserTypeEntity> wipe events <UserCalendarEntity>
def wipeCalendarEvents(users):
  calendarEntity = getUserCalendarEntity()
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity, Ent.EVENT, Act.MODIFIER_FROM)
    if jcount == 0:
      continue
    Ind.Increment()
    _wipeCalendarEvents(user, cal, calIds, jcount)
    Ind.Decrement()

# gam <UserTypeEntity> move events <UserCalendarEntity> <EventEntity> to|destination <CalendarItem> [<EventNotificationAttribute>]
def moveCalendarEvents(users):
  calendarEntity = getUserCalendarEntity()
  calendarEventEntity = getCalendarEventEntity()
  checkArgumentPresent(['to', 'destination'])
  newCalId = convertUIDtoEmailAddress(getString(Cmd.OB_CALENDAR_ITEM))
  parameters, _ = _getCalendarMoveEventsOptions()
  if not checkCalendarExists(None, newCalId, 0, 0, True):
    return
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity, Ent.EVENT, Act.MODIFIER_FROM, newCalId=newCalId)
    if jcount == 0:
      continue
    Ind.Increment()
    _moveCalendarEvents(origUser, user, cal, calIds, jcount, calendarEventEntity, newCalId, parameters)
    Ind.Decrement()

# gam <UserTypeEntity> empty calendartrash <UserCalendarEntity>
def emptyCalendarTrash(users):
  calendarEntity = getUserCalendarEntity()
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    Act.Set(Act.PURGE)
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity, Ent.TRASHED_EVENT, Act.MODIFIER_FROM)
    if jcount == 0:
      continue
    Ind.Increment()
    _emptyCalendarTrash(user, cal, calIds, jcount)
    Ind.Decrement()

# gam <UserTypeEntity> update calattendees <UserCalendarEntity> <EventEntity> [anyorganizer]
#	[<EventNotificationAttribute>] [splitupdate] [doit]
#	(<CSVFileSelector>)
#	(delete <EmailAddress>)*
#	(deleteentity <EmailAddressEntity>)*
#	(add <EmailAddress>)*
#	(addentity <EmailAddressEntity>)*
#	(addstatus [<AttendeeAttendance>] [<AttendeeStatus>] <EmailAddress>)*
#	(addentitystatus [<AttendeeAttendance>] [<AttendeeStatus>] <EmailAddressEntity>)*
#	(replace <EmailAddress> <EmailAddress>)*
#	(replacestatus [<AttendeeAttendance>] [<AttendeeStatus>] <EmailAddress> <EmailAddress>)*
#	(updatestatus [<AttendeeAttendance>] [<AttendeeStatus>] <EmailAddress>)*
#	(updateentitystatus [<AttendeeAttendance>] [<AttendeeStatus>] <EmailAddressEntity>)*
def updateCalendarAttendees(users):
  def getStatus(option):
    if option.endswith('status'):
      return(getChoice(CALENDAR_ATTENDEE_OPTIONAL_CHOICE_MAP, defaultChoice=None, mapChoice=True),
             getChoice(CALENDAR_ATTENDEE_STATUS_CHOICE_MAP, defaultChoice=None, mapChoice=True))
    return (None, None)

  calendarEntity = getUserCalendarEntity()
  calendarEventEntity = getCalendarEventEntity()
  anyOrganizer = doIt = splitUpdate = False
  parameters = {'sendUpdates': 'none'}
  attendeeMap = {}
  errors = 0
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'csv', 'csvfile'}:
      errors = 0
      f, csvFile, _ = openCSVFileReader(getString(Cmd.OB_FILE_NAME), fieldnames=['addr', 'op', 'optional', 'status'])
      for row in csvFile:
        updAddr = row['addr']
        updOp = row['op'].lower()
        updOptional = row['optional']
        updStatus = row['status']
        if not updAddr and not updOp:
          continue
        if updOptional:
          updOptional = updOptional.lower()
        if updStatus:
          updStatus = updStatus.lower()
        if (not updAddr or not updOp or
            (updOptional and updOptional not in CALENDAR_ATTENDEE_OPTIONAL_CHOICE_MAP) or
            (updStatus and updStatus not in CALENDAR_ATTENDEE_STATUS_CHOICE_MAP)):
          stderrErrorMsg(Msg.INVALID_ATTENDEE_CHANGE.format(','.join([updAddr, updOp, updStatus, updOptional])))
          errors += 1
          continue
        updAddr = normalizeEmailAddressOrUID(updAddr, noUid=True)
        if updOp == 'delete':
          attendeeMap[updAddr] = {'op': updOp, 'done': False}
        else:
          updOptional = CALENDAR_ATTENDEE_OPTIONAL_CHOICE_MAP[updOptional] if updOptional else None
          updStatus = CALENDAR_ATTENDEE_STATUS_CHOICE_MAP[updStatus] if updStatus else None
          if updOp == 'add':
            attendeeMap[updAddr] = {'op': updOp, 'status': updStatus, 'optional': updOptional, 'done': False}
          elif updOp == 'update':
            attendeeMap[updAddr] = {'op': updOp, 'status': updStatus, 'optional': updOptional, 'done': False}
          else: #replace
            attendeeMap[updAddr] = {'op': 'replace', 'status': updStatus, 'optional': updOptional, 'email': normalizeEmailAddressOrUID(updOp, noUid=True), 'done': False}
      closeFile(f)
    elif myarg == 'delete':
      updAddr = getEmailAddress(noUid=True)
      attendeeMap[updAddr] = {'op': 'delete'}
    elif myarg == 'deleteentity':
      for updAddr in getNormalizedEmailAddressEntity(noUid=True):
        attendeeMap[updAddr] = {'op': 'delete'}
    elif myarg in {'add', 'addstatus'}:
      updOptional, updStatus = getStatus(myarg)
      updAddr = getEmailAddress(noUid=True)
      attendeeMap[updAddr] = {'op': 'add', 'status': updStatus, 'optional': updOptional, 'done': False}
    elif myarg in {'addentity', 'addentitystatus'}:
      updOptional, updStatus = getStatus(myarg)
      for updAddr in getNormalizedEmailAddressEntity(noUid=True):
        attendeeMap[updAddr] = {'op': 'add', 'status': updStatus, 'optional': updOptional, 'done': False}
    elif myarg in {'update', 'updatestatus'}:
      updOptional, updStatus = getStatus(myarg)
      updAddr = getEmailAddress(noUid=True)
      attendeeMap[updAddr] = {'op': 'update', 'status': updStatus, 'optional': updOptional, 'done': False}
    elif myarg in {'updateentity', 'updateentitystatus'}:
      updOptional, updStatus = getStatus(myarg)
      for updAddr in getNormalizedEmailAddressEntity(noUid=True):
        attendeeMap[updAddr] = {'op': 'update', 'status': updStatus, 'optional': updOptional, 'done': False}
    elif myarg in {'replace', 'replacestatus'}:
      updOptional, updStatus = getStatus(myarg)
      updAddr = getEmailAddress(noUid=True)
      newAddr = getEmailAddress(noUid=True)
      attendeeMap[updAddr] = {'op': 'replace', 'status': updStatus, 'optional': updOptional, 'email': newAddr, 'done': False}
    elif myarg in {'anyorganizer', 'allevents'}:
      anyOrganizer = True
    elif _getCalendarSendUpdates(myarg, parameters):
      pass
    elif myarg == 'doit':
      doIt = True
    elif myarg == 'dryrun':
      doIt = False
    elif myarg == 'splitupdate':
      splitUpdate = True
    else:
      unknownArgumentExit()
  if not attendeeMap:
    missingArgumentExit(Msg.UPDATE_ATTENDEE_CHANGES)
  ucount = len(attendeeMap)
  if errors:
    systemErrorExit(USAGE_ERROR_RC, '')
  removeMessage = Msg.ATTENDEES_REMOVE
  addMessage = Msg.ATTENDEES_ADD_REMOVE if not splitUpdate else Msg.ATTENDEES_ADD
  fieldsList = ['attendees', 'id', 'organizer', 'status', 'summary']
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for calId in calIds:
      j += 1
      Act.Set(Act.UPDATE)
      calId, cal, events, kcount = _validateCalendarGetEvents(origUser, user, cal, calId, j, jcount, calendarEventEntity,
                                                              fieldsList, True)
      if kcount == 0:
        continue
      Ind.Increment()
      k = 0
      for event in events:
        k += 1
        eventSummary = event.get('summary', event['id'])
        if event['status'] == 'cancelled':
          entityActionNotPerformedWarning([Ent.EVENT, eventSummary], Msg.EVENT_IS_CANCELED, k, kcount)
          continue
        if not anyOrganizer and not event.get('organizer', {}).get('self'):
          entityActionNotPerformedWarning([Ent.EVENT, eventSummary], Msg.USER_IS_NOT_ORGANIZER, k, kcount)
          continue
        needsUpdate = False
        for _, v in sorted(iter(attendeeMap.items())):
          v['done'] = False
        updatedAttendeesAdd = []
        updatedAttendeesRemove = []
        entityPerformActionNumItems([Ent.EVENT, eventSummary], ucount, Ent.ATTENDEE, k, kcount)
        Ind.Increment()
        u = 0
        for attendee in event.get('attendees', []):
          oldAddr = attendee.get('email', '').lower()
          if not oldAddr:
            updatedAttendeesAdd.append(attendee)
            if splitUpdate:
              updatedAttendeesRemove.append(attendee)
            continue
          update = attendeeMap.get(oldAddr)
          if not update:
            updatedAttendeesAdd.append(attendee)
            if splitUpdate:
              updatedAttendeesRemove.append(attendee)
            continue
          updOp = update['op']
          if updOp == 'delete':
            u += 1
            update['done'] = True
            Act.Set(Act.DELETE)
            entityPerformAction([Ent.EVENT, eventSummary, Ent.ATTENDEE, oldAddr], u, ucount)
            needsUpdate = True
          else:
            oldStatus = attendee.get('responseStatus')
            oldOptional = attendee.get('optional', False)
            updStatus = update['status']
            updOptional = update['optional']
            if updOp in {'add', 'update'}:
              u += 1
              update['done'] = True
              if ((updStatus is not None and updStatus != oldStatus) or
                  (updOptional is not None and updOptional != oldOptional)):
                attendee['responseStatus'] = updStatus if updStatus is not None else oldStatus
                attendee['optional'] = updOptional if updOptional is not None else oldOptional
                Act.Set(Act.UPDATE)
                entityPerformAction([Ent.EVENT, eventSummary, Ent.ATTENDEE, oldAddr], u, ucount)
                needsUpdate = True
              else:
                Act.Set(Act.SKIP)
                entityPerformAction([Ent.EVENT, eventSummary, Ent.ATTENDEE, oldAddr], u, ucount)
              updatedAttendeesAdd.append(attendee)
            else: #replace
              u += 1
              update['done'] = True
              attendee['email'] = update['email']
              attendee['responseStatus'] = updStatus if updStatus is not None else oldStatus
              attendee['optional'] = updOptional if updOptional is not None else oldOptional
              Act.Set(Act.REPLACE)
              entityPerformActionModifierNewValue([Ent.EVENT, eventSummary, Ent.ATTENDEE, oldAddr], Act.MODIFIER_WITH, update['email'], u, ucount)
              updatedAttendeesAdd.append(attendee)
              needsUpdate = True
        for newAddr, v in sorted(iter(attendeeMap.items())):
          if v['op'] == 'add' and not v['done']:
            u += 1
            v['done'] = True
            attendee = {'email': newAddr}
            if v['status'] is not None:
              attendee['responseStatus'] = v['status']
            if v['optional'] is not None:
              attendee['optional'] = v['optional']
            Act.Set(Act.ADD)
            entityPerformAction([Ent.EVENT, eventSummary, Ent.ATTENDEE, newAddr], u, ucount)
            updatedAttendeesAdd.append(attendee)
            needsUpdate = True
        for newAddr, v in sorted(iter(attendeeMap.items())):
          if not v['done']:
            u += 1
            Act.Set(Act.SKIP)
            entityPerformAction([Ent.EVENT, eventSummary, Ent.ATTENDEE, newAddr], u, ucount)
        Ind.Decrement()
        if needsUpdate:
          Act.Set(Act.UPDATE)
          if doIt:
            status = True
            if splitUpdate:
              try:
                callGAPI(cal.events(), 'patch',
                         throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID],
                         calendarId=calId, eventId=event['id'], body={'attendees': updatedAttendeesRemove},
                         sendUpdates=parameters['sendUpdates'], fields='')
                entityActionPerformedMessage([Ent.EVENT, eventSummary], removeMessage, j, jcount)
              except GAPI.notFound as e:
                if not checkCalendarExists(cal, calId, i, count):
                  entityUnknownWarning(Ent.CALENDAR, calId, j, jcount)
                  break
                entityActionFailedWarning([Ent.CALENDAR, calId, Ent.EVENT, eventSummary], str(e), k, kcount)
                status = False
              except (GAPI.forbidden, GAPI.invalid) as e:
                entityActionFailedWarning([Ent.CALENDAR, calId], str(e), j, jcount)
                break
              except GAPI.notACalendarUser:
                userCalServiceNotEnabledWarning(user, i, count)
                break
            if status:
              try:
                callGAPI(cal.events(), 'patch',
                         throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID],
                         calendarId=calId, eventId=event['id'], body={'attendees': updatedAttendeesAdd},
                         sendUpdates=parameters['sendUpdates'], fields='')
                entityActionPerformedMessage([Ent.EVENT, eventSummary], addMessage, jcount)
              except GAPI.notFound as e:
                if not checkCalendarExists(cal, calId, i, count):
                  entityUnknownWarning(Ent.CALENDAR, calId, j, jcount)
                  break
                entityActionFailedWarning([Ent.CALENDAR, calId, Ent.EVENT, eventSummary], str(e), k, kcount)
              except (GAPI.forbidden, GAPI.invalid) as e:
                entityActionFailedWarning([Ent.CALENDAR, calId], str(e), j, jcount)
                break
              except GAPI.notACalendarUser:
                userCalServiceNotEnabledWarning(user, i, count)
                break
          else:
            entityActionNotPerformedWarning([Ent.EVENT, eventSummary], Msg.USE_DOIT_ARGUMENT_TO_PERFORM_ACTION, j, jcount)
      Ind.Decrement()
    Ind.Decrement()

# gam <UserTypeEntity> info events <UserCalendarEntity> <EventEntity> [maxinstances <Number>]
#	[fields <EventFieldNameList>] [showdayofweek]
#	[formatjson]
def infoCalendarEvents(users):
  calendarEntity = getUserCalendarEntity()
  calendarEventEntity = getCalendarEventEntity()
  FJQC, fieldsList = _getCalendarInfoEventOptions(calendarEventEntity)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity, Ent.EVENT, Act.MODIFIER_IN, showAction=not FJQC.formatJSON)
    if jcount == 0:
      continue
    Ind.Increment()
    _infoCalendarEvents(origUser, user, cal, calIds, jcount, calendarEventEntity, FJQC, fieldsList)
    Ind.Decrement()

# gam <UserTypeEntity> print events <UserCalendarEntity> <EventEntity> <EventDisplayProperties>*
#	[fields <EventFieldNameList>] [showdayofweek]
#	[countsonly [eventrowfilter]]
#	[formatjson [quotechar <Character>]] [todrive <ToDriveAttribute>*]
# gam <UserTypeEntity> show events <UserCalendarEntity> <EventEntity> <EventDisplayProperties>*
#	[fields <EventFieldNameList>] [showdayofweek]
#	[countsonly]]
#	[formatjson]
def printShowCalendarEvents(users):
  calendarEntity = getUserCalendarEntity()
  calendarEventEntity = getCalendarEventEntity()
  csvPF, FJQC, fieldsList = _getCalendarPrintShowEventOptions(calendarEventEntity, Ent.USER)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, cal, calIds, jcount = _validateUserGetCalendarIds(user, i, count, calendarEntity, Ent.EVENT, Act.MODIFIER_FROM,
                                                            showAction=not csvPF and not FJQC.formatJSON)
    if jcount == 0:
      continue
    Ind.Increment()
    _printShowCalendarEvents(origUser, user, cal, calIds, jcount, calendarEventEntity,
                             csvPF, FJQC, fieldsList)
    Ind.Decrement()
  if csvPF:
    if calendarEventEntity['countsOnly'] and calendarEventEntity['eventRowFilter']:
      csvPF.SetTitles(calendarEventEntity['countsOnlyTitles'])
      csvPF.writeCSVfile('Calendar Events', True)
    else:
      csvPF.writeCSVfile('Calendar Events')

def getStatusEventDateTime(dateType, dateList):
  if dateType == 'timerange':
    startTime = getTimeOrDeltaFromNow(returnDateTime=True)[0]
    endTime = getTimeOrDeltaFromNow(returnDateTime=True)[0]
    recurrence = []
    while checkArgumentPresent(['recurrence']):
      recurrence.append(getString(Cmd.OB_RECURRENCE))
    dateList.append({'type': dateType, 'first': startTime, 'last': endTime, 'ulast': endTime, 'recurrence': recurrence})
    return
  firstDate = getYYYYMMDD(minLen=1, returnDateTime=True).replace(tzinfo=GC.Values[GC.TIMEZONE])
  if dateType == 'range':
    lastDate = getYYYYMMDD(minLen=1, returnDateTime=True).replace(tzinfo=GC.Values[GC.TIMEZONE])
  deltaDay = datetime.timedelta(days=1)
  deltaWeek = datetime.timedelta(weeks=1)
  if dateType in {'date', 'allday'}:
    dateList.append({'type': 'date', 'first': firstDate, 'last': firstDate+deltaDay,
                     'ulast': firstDate+deltaDay, 'udelta': deltaDay})
  elif dateType == 'range':
    dateList.append({'type': dateType, 'first': firstDate, 'last': lastDate+deltaDay,
                     'ulast': lastDate, 'udelta': deltaDay})
  elif dateType == 'daily':
    argRepeat = getInteger(minVal=1, maxVal=366)
    dateList.append({'type': dateType, 'first': firstDate, 'last': firstDate+datetime.timedelta(days=argRepeat),
                     'ulast': firstDate+datetime.timedelta(days=argRepeat), 'udelta': deltaDay})
  else: #weekly
    argRepeat = getInteger(minVal=1, maxVal=52)
    dateList.append({'type': dateType, 'first': firstDate, 'last': firstDate+deltaDay, 'pdelta': deltaWeek, 'repeats': argRepeat,
                     'ulast': firstDate+datetime.timedelta(weeks=argRepeat), 'udelta': deltaWeek})

def _showCalendarStatusEvent(primaryEmail, calId, eventEntityType, event, k, kcount, FJQC):
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON({'primaryEmail': primaryEmail, 'calendarId': calId, 'event': event},
                                   timeObjects=EVENT_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  printEntity([eventEntityType, event['id']], k, kcount)
  skipObjects = {'id'}
  Ind.Increment()
  showJSON(None, event, skipObjects, EVENT_TIME_OBJECTS)
  Ind.Decrement()

EVENT_AUTO_DECLINE_MODE_CHOICE_MAP = {
  'declinenone': 'declineNone',
  'declineallconflictinginvitations': 'declineAllConflictingInvitations',
  'declineonlynewconflictinginvitations': 'declineOnlyNewConflictingInvitations',
  'none': 'declineNone',
  'all': 'declineAllConflictingInvitations',
  'new': 'declineOnlyNewConflictingInvitations',
  }

EVENT_CHAT_STATUS_CHOICE_MAP = {
  'available': 'available',
  'dnd': 'doNotDisturb',
  'donotdisturb': 'doNotDisturb',
  }

def getFocusTimeProperties(body, parameters, dateList):
  eventProperties = EVENT_TYPE_PROPERTIES_NAME_MAP[EVENT_TYPE_FOCUSTIME]
  body.update({'eventType': EVENT_TYPE_FOCUSTIME, 'summary': 'Focus time',
               eventProperties: {'autoDeclineMode': 'declineNone', 'chatStatus': 'available', 'declineMessage': 'Declined'},
               'transparency':'opaque'})
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'summary':
      body['summary'] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg == 'declinemode':
      body[eventProperties]['autoDeclineMode'] = getChoice(EVENT_AUTO_DECLINE_MODE_CHOICE_MAP, mapChoice=True)
    elif myarg == 'declinemessage':
      body[eventProperties]['declineMessage'] = getString(Cmd.OB_STRING)
    elif myarg == 'chatstatus':
      body[eventProperties]['chatStatus'] = getChoice(EVENT_CHAT_STATUS_CHOICE_MAP, mapChoice=True)
    elif myarg == 'timerange':
      getStatusEventDateTime(myarg, dateList)
    elif myarg == 'timezone':
      parameters['timeZone'] = getString(Cmd.OB_STRING)
    else:
      unknownArgumentExit()

def getOutOfOfficeProperties(body, parameters, dateList):
  eventProperties = EVENT_TYPE_PROPERTIES_NAME_MAP[EVENT_TYPE_OUTOFOFFICE]
  body.update({'eventType': EVENT_TYPE_OUTOFOFFICE, 'summary': 'Out of office',
               eventProperties: {'autoDeclineMode': 'declineNone', 'declineMessage': 'Declined'},
               'transparency':'opaque'})
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'summary':
      body['summary'] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg == 'declinemode':
      body[eventProperties]['autoDeclineMode'] = getChoice(EVENT_AUTO_DECLINE_MODE_CHOICE_MAP, mapChoice=True)
    elif myarg == 'declinemessage':
      body[eventProperties]['declineMessage'] = getString(Cmd.OB_STRING)
    elif myarg == 'timerange':
      getStatusEventDateTime(myarg, dateList)
    elif myarg == 'timezone':
      parameters['timeZone'] = getString(Cmd.OB_STRING)
    else:
      unknownArgumentExit()

STATUS_EVENTS_DATETIME_CHOICES = {'date', 'allday', 'range', 'daily', 'weekly', 'timerange'}

WORKING_LOCATION_CHOICE_MAP = {
  'custom': 'customLocation',
  'home': 'homeOffice',
  'office': 'officeLocation',
  }

def getWorkingLocationProperties(body, parameters, dateList):
  eventProperties = EVENT_TYPE_PROPERTIES_NAME_MAP[EVENT_TYPE_WORKINGLOCATION]
  body.update({'eventType': EVENT_TYPE_WORKINGLOCATION, eventProperties: {},
               'visibility': 'public', 'transparency':'transparent'})
  location = ''
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in WORKING_LOCATION_CHOICE_MAP:
      location = WORKING_LOCATION_CHOICE_MAP[myarg]
      body[eventProperties]['type'] = location
      if location == 'homeOffice':
        pass
      elif location == 'customLocation':
        body[eventProperties][location] = {'label': getString(Cmd.OB_STRING)}
      else: #officeLocation
        body[eventProperties][location] = {'label': getString(Cmd.OB_STRING)}
        entry = body[eventProperties][location]
        while Cmd.ArgumentsRemaining():
          myarg = getArgument()
          if myarg in {'building', 'buildingid'}:
            entry['buildingId'] = _getBuildingByNameOrId(None)
          elif myarg in {'floor', 'floorname'}:
            entry['floorId'] = getString(Cmd.OB_STRING, minLen=0)
          elif myarg in {'section', 'floorsection'}:
            entry['floorSectionId'] = getString(Cmd.OB_STRING, minLen=0)
          elif myarg in {'desk', 'deskcode'}:
            entry['deskId'] = getString(Cmd.OB_STRING, minLen=0)
          elif myarg == 'endlocation':
            break
          else:
            Cmd.Backup()
            break
    elif myarg in STATUS_EVENTS_DATETIME_CHOICES:
      getStatusEventDateTime(myarg, dateList)
    elif myarg == 'timezone':
      parameters['timeZone'] = getString(Cmd.OB_STRING)
    else:
      unknownArgumentExit()
  return location

# gam <UserTypeEntity> create focustime
#	[chatstatus available|donotdisturb]
#	[declinemode none|all|new]
#	[declinemessage <String>]
#	[summary <String>]
#	(timerange <Time> <Time> [recurrence <String>])+
#	[timezone <String>]
# gam <UserTypeEntity> create outofoffice
#	[declinemode none|all|new]
#	[declinemessage <String>]
#	[summary <String>]
#	(timerange <Time> <Time> [recurrence <String>])+
#	[timezone <String>]
# gam <UserTypeEntity> create workinglocation
#	(home|
#	 (custom <String>)|
#	 (office <String> [building|buildingid <String>] [floor|floorname <String>]
#	   		  [section|floorsection <String>] [desk|deskcode <String>]))
#	((date yyyy-mm-dd)|
#	 (range yyyy-mm-dd yyyy-mm-dd)|
#	 (daily yyyy-mm-dd N)|
#	 (weekly yyyy-mm-dd N)|
#	 (timerange <Time> <Time>))+
#	[timezone <String>]
def createStatusEvent(users, eventType):
  eventProperties = EVENT_TYPE_PROPERTIES_NAME_MAP[eventType]
  entityType = EVENT_TYPE_ENTITY_MAP[eventType]
  body = {'start': {}, 'end': {}, 'recurrence': None}
  calId = 'primary'
  parameters = {}
  dateList = []
  if eventType == EVENT_TYPE_WORKINGLOCATION:
    location =  getWorkingLocationProperties(body, parameters, dateList)
    if not location:
      missingArgumentExit('|'.join(WORKING_LOCATION_CHOICE_MAP))
  elif eventType == EVENT_TYPE_OUTOFOFFICE:
    getOutOfOfficeProperties(body, parameters, dateList)
  else: # elif eventType == EVENT_TYPE_FOCUSTIME:
    getFocusTimeProperties(body, parameters, dateList)
  if not dateList:
    missingChoiceExit(STATUS_EVENTS_DATETIME_CHOICES)
  datekvList = [Ent.CALENDAR, '', Ent.EVENT, '', Ent.DATE, '']
  timekvList = [Ent.CALENDAR, '', Ent.EVENT, '', Ent.START_TIME, '', Ent.END_TIME, '']
  if eventType == EVENT_TYPE_WORKINGLOCATION:
    location = body[eventProperties]['type']
    if location in body[eventProperties] and 'label' in body[eventProperties][location]:
      location += f"/{body[eventProperties][location]['label']}"
    datekvList.extend([Ent.LOCATION, location])
    timekvList.extend([Ent.LOCATION, location])
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, cal = buildGAPIServiceObject(API.CALENDAR, user, i, count)
    if not cal:
      continue
    jcount = len(dateList)
    entityPerformAction([Ent.CALENDAR, user, entityType, None], i, count)
    Ind.Increment()
    j = 0
    for wlDate in dateList:
      j += 1
      first = wlDate['first']
      last = wlDate['ulast']
      kvList = datekvList if wlDate['type'] != 'timerange' else timekvList
      kvList[1] = user
      while first < last:
        body.pop('recurrence', None)
        if wlDate['type'] != 'timerange':
          body['start']['date'] = first.strftime(YYYYMMDD_FORMAT)
          kvList[5] = body['start']['date']
          body['end']['date'] = (first+datetime.timedelta(days=1)).strftime(YYYYMMDD_FORMAT)
        else:
          body['start']['dateTime'] = ISOformatTimeStamp(first)
          kvList[5] = body['start']['dateTime']
          body['end']['dateTime'] = ISOformatTimeStamp(last)
          kvList[7] = body['end']['dateTime']
          if wlDate['recurrence']:
            body['recurrence'] = wlDate['recurrence']
            if not _setEventRecurrenceTimeZone(cal, calId, body, parameters, i, count):
              break
        try:
          event = callGAPI(cal.events(), 'insert',
                           throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID, GAPI.BAD_REQUEST,
                                                                     GAPI.TIME_RANGE_EMPTY, GAPI.MALFORMED_WORKING_LOCATION_EVENT],
                           calendarId=calId, body=body, fields='id')
          kvList[3] = event['id']
          entityActionPerformed(kvList, j, jcount)
          if wlDate['type'] == 'timerange':
            break
          first += wlDate['udelta']
        except (GAPI.forbidden, GAPI.invalid) as e:
          entityActionFailedWarning([Ent.CALENDAR, user], str(e), i, count)
          break
        except (GAPI.badRequest, GAPI.timeRangeEmpty, GAPI.malformedWorkingLocationEvent) as e:
          entityActionFailedWarning(kvList, str(e), j, jcount)
          break
        except GAPI.notACalendarUser:
          userCalServiceNotEnabledWarning(user, i, count)
          break
    Ind.Decrement()

def createFocusTime(users):
  createStatusEvent(users, EVENT_TYPE_FOCUSTIME)

def createOutOfOffice(users):
  createStatusEvent(users, EVENT_TYPE_OUTOFOFFICE)

def createWorkingLocation(users):
  createStatusEvent(users, EVENT_TYPE_WORKINGLOCATION)

# gam <UserTypeEntity> delete focustime|outofoffice|workinglocation
#	((date yyyy-mm-dd)|
#	 (range yyyy-mm-dd yyyy-mm-dd)|
#	 (daily yyyy-mm-dd N)|
#	 (weekly yyyy-mm-dd N)|
#	 (timerange <Time> <Time>))+
def deleteStatusEvent(users, eventType):
  eventProperties = EVENT_TYPE_PROPERTIES_NAME_MAP[eventType]
  entityType = EVENT_TYPE_ENTITY_MAP[eventType]
  kwargs = {'eventTypes': [eventType], 'showDeleted': False, 'singleEvents': True,
            'timeMax': None, 'timeMin': None, 'orderBy': 'startTime'}
  calId = 'primary'
  dateList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in STATUS_EVENTS_DATETIME_CHOICES:
      getStatusEventDateTime(myarg, dateList)
    else:
      unknownArgumentExit()
  if not dateList:
    missingChoiceExit(STATUS_EVENTS_DATETIME_CHOICES)
  basekvList = [Ent.CALENDAR, '', Ent.EVENT, '']
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, cal = buildGAPIServiceObject(API.CALENDAR, user, i, count)
    if not cal:
      continue
    jcount = len(dateList)
    entityPerformAction([Ent.CALENDAR, user, entityType, None], i, count)
    Ind.Increment()
    j = 0
    for wlDate in dateList:
      j += 1
      first = wlDate['first']
      last = wlDate['last']
      basekvList[1] = user
      events = []
      for _ in range(1, wlDate.get('repeats', 1)+1):
        kwargs['timeMin'] = ISOformatTimeStamp(first)
        kwargs['timeMax'] = ISOformatTimeStamp(last)
        try:
          events = callGAPIpages(cal.events(), 'list', 'items',
                                 throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID, GAPI.BAD_REQUEST],
                                 calendarId=calId, fields=f'nextPageToken,items(id,start,end,{eventProperties})', **kwargs)
        except (GAPI.notFound, GAPI.forbidden, GAPI.invalid, GAPI.badRequest) as e:
          entityActionFailedWarning([Ent.CALENDAR, user], str(e), j, jcount)
          break
        except GAPI.notACalendarUser:
          userCalServiceNotEnabledWarning(user, i, count)
          break
        kcount = len(events)
        k = 0
        for event in events:
          k += 1
          eventId = event['id']
          basekvList[3] = eventId
          kvList = basekvList[:]
          if eventType == EVENT_TYPE_WORKINGLOCATION:
            location = event[eventProperties]['type']
            if location in event[eventProperties] and 'label' in event[eventProperties][location]:
              location += f"/{event[eventProperties][location]['label']}"
          try:
            callGAPI(cal.events(), 'delete',
                     throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.DELETED, GAPI.FORBIDDEN,
                                                               GAPI.INVALID, GAPI.REQUIRED, GAPI.REQUIRED_ACCESS_LEVEL],
                     calendarId=calId, eventId=eventId, sendUpdates='none')
            if 'date' in event['start']:
              kvList.extend([Ent.DATE, event['start']['date']])
              if eventType == EVENT_TYPE_WORKINGLOCATION:
                kvList.extend([Ent.LOCATION, location])
            else:
              kvList.extend([Ent.START_TIME, formatLocalTime(event['start']['dateTime']),
                             Ent.END_TIME, formatLocalTime(event['end']['dateTime'])])
              if eventType == EVENT_TYPE_WORKINGLOCATION:
                kvList.extend([Ent.LOCATION, location])
            entityActionPerformed(kvList, k, kcount)
          except (GAPI.notFound, GAPI.deleted) as e:
            if not checkCalendarExists(cal, calId, i, count):
              entityUnknownWarning(Ent.CALENDAR, calId, k, kcount)
              break
            entityActionFailedWarning([Ent.CALENDAR, calId, Ent.EVENT, eventId], str(e), k, kcount)
          except (GAPI.forbidden, GAPI.invalid, GAPI.required, GAPI.requiredAccessLevel) as e:
            entityActionFailedWarning([Ent.CALENDAR, calId, Ent.EVENT, eventId], str(e), k, kcount)
          except GAPI.notACalendarUser:
            userCalServiceNotEnabledWarning(user, i, count)
            break
    Ind.Decrement()

def deleteFocusTime(users):
  deleteStatusEvent(users, EVENT_TYPE_FOCUSTIME)

def deleteOutOfOffice(users):
  deleteStatusEvent(users, EVENT_TYPE_OUTOFOFFICE)

def deleteWorkingLocation(users):
  deleteStatusEvent(users, EVENT_TYPE_WORKINGLOCATION)

# gam <UserTypeEntity> show focustime|outofoffice|workinglocation
#	((date yyyy-mm-dd)|
#	 (range yyyy-mm-dd yyyy-mm-dd)|
#	 (daily yyyy-mm-dd N)|
#	 (weekly yyyy-mm-dd N)|
#	 (timerange <Time> <Time>))+
#	[showdayofweek]
#	[formatjson]
# gam <UserTypeEntity> print focustime|outofoffice|workinglocation
#	((date yyyy-mm-dd)|
#	 (range yyyy-mm-dd yyyy-mm-dd)|
#	 (daily yyyy-mm-dd N)|
#	 (weekly yyyy-mm-dd N)|
#	 (timerange <Time> <Time>))+
#	[showdayofweek]
#	[formatjson [quotechar <Character>]] [todrive <ToDriveAttribute>*]
def printShowStatusEvent(users, eventType):
  eventProperties = EVENT_TYPE_PROPERTIES_NAME_MAP[eventType]
  entityType = EVENT_TYPE_ENTITY_MAP[eventType]
  csvPF = CSVPrintFile(['primaryEmail', 'calendarId', 'id'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  kwargs = {'eventTypes': [eventType], 'showDeleted': False, 'singleEvents': True,
            'timeMax': None, 'timeMin': None, 'orderBy': 'startTime'}
  calId = 'primary'
  showDayOfWeek = False
  dateList = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in STATUS_EVENTS_DATETIME_CHOICES:
      getStatusEventDateTime(myarg, dateList)
    elif myarg == 'showdayofweek':
      showDayOfWeek = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if not dateList:
    missingChoiceExit(STATUS_EVENTS_DATETIME_CHOICES)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, cal = buildGAPIServiceObject(API.CALENDAR, user, i, count)
    if not cal:
      continue
    jcount = len(dateList)
    if not csvPF and not FJQC.formatJSON:
      entityPerformActionNumItems([Ent.CALENDAR, user], jcount, Ent.DATE, i, count)
    j = 0
    for wlDate in dateList:
      j += 1
      first = wlDate['first']
      last = wlDate['last']
      for _ in range(1, wlDate.get('repeats', 1)+1):
        kwargs['timeMin'] = ISOformatTimeStamp(first)
        kwargs['timeMax'] = ISOformatTimeStamp(last)
        try:
          events = callGAPIpages(cal.events(), 'list', 'items',
                                 throwReasons=GAPI.CALENDAR_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID, GAPI.BAD_REQUEST],
                                 calendarId=calId, fields=f'nextPageToken,items(id,start,end,eventType,{eventProperties},transparency,visibility)',
                                 **kwargs)
        except (GAPI.notFound, GAPI.forbidden, GAPI.invalid, GAPI.badRequest) as e:
          entityActionFailedWarning([Ent.CALENDAR, user], str(e), j, jcount)
          break
        except GAPI.notACalendarUser:
          userCalServiceNotEnabledWarning(user, i, count)
          break
        if not csvPF:
          kcount = len(events)
          Ind.Increment()
          k = 0
          for event in events:
            k += 1
            if showDayOfWeek:
              _getEventDaysOfWeek(event)
            _showCalendarStatusEvent(user, calId, Ent.EVENT, event, k, kcount, FJQC)
          Ind.Decrement()
        else:
          for event in events:
            if showDayOfWeek:
              _getEventDaysOfWeek(event)
            _printCalendarEvent(user, calId, event, csvPF, FJQC)
        if 'pdelta' in wlDate:
          first += wlDate['pdelta']
          last += wlDate['pdelta']
  if csvPF:
    csvPF.writeCSVfile(f'Calendar {Ent.Plural(entityType)}')

def printShowFocusTime(users):
  printShowStatusEvent(users, EVENT_TYPE_FOCUSTIME)

def printShowOutOfOffice(users):
  printShowStatusEvent(users, EVENT_TYPE_OUTOFOFFICE)

def printShowWorkingLocation(users):
  printShowStatusEvent(users, EVENT_TYPE_WORKINGLOCATION)

YOUTUBE_CHANNEL_FIELDS_CHOICE_MAP = {
  'brandingsettings': 'brandingSettings',
  'contentdetails': 'contentDetails',
  'contentownerdetails': 'contentOwnerDetails',
  'id': 'id',
  'localizations': 'localizations',
  'snippet': 'snippet',
  'statistics': 'statistics',
  'status': 'status',
  'topicdetails': 'topicDetails',
  }

YOUTUBE_CHANNEL_TIME_OBJECTS = {'publishedAt'}

# gam <UserTypeEntity> show youtubechannels
#	(mine|
#	 (ids|channels <YouTubeChannelIDList>)|
#	 (forusername <String>)|
#	 (managedbyme <String>))
#	[languagecode <BCP47LanguageCode>]
#	[allfields|(fields <YouTubeChannelFieldNameList>)]
#	[formatjson]
# gam <UserTypeEntity> print youtubechannels [todrive <ToDriveAttribute>*]
#	(mine|
#	 (ids|channels <YouTubeChannelIDList>)|
#	 (forusername <String>)|
#	 (managedbyme <String>))
#	[languagecode <BCP47LanguageCode>]
#	[allfields|(fields <YouTubeChannelFieldNameList>)]
#	[formatjson [quotechar <Character>]]
def printShowYouTubeChannel(users):
  csvPF = CSVPrintFile(['User', 'id'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  kwargs = {'mine': True}
  languageCode = ''
  fieldsList = ['id']
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'mine':
      kwargs = {'mine': True}
    elif myarg in {'id', 'ids', 'channel', 'channels'}:
      kwargs = {'id': ','.join(getEntityList(Cmd.OB_YOUTUBE_CHANNEL_ID_LIST))}
    elif myarg == 'forusername':
      kwargs = {'forUsername': getString(Cmd.OB_USER_NAME)}
    elif myarg == 'managedbyme':
      kwargs = {'managedByMe': True, 'onBehalfOfContentOwner': getString(Cmd.OB_USER_NAME)}
    elif getFieldsList(myarg, YOUTUBE_CHANNEL_FIELDS_CHOICE_MAP, fieldsList):
      pass
    elif myarg == 'allfields':
      for field in YOUTUBE_CHANNEL_FIELDS_CHOICE_MAP:
        addFieldToFieldsList(field, YOUTUBE_CHANNEL_FIELDS_CHOICE_MAP, fieldsList)
    elif myarg in {'languagecode', 'hl'}:
      languageCode = getLanguageCode(BCP47_LANGUAGE_CODES_MAP)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  kwargs['part'] = ','.join(set(fieldsList))
  if languageCode:
    kwargs['hl'] = languageCode
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, yt = buildGAPIServiceObject(API.YOUTUBE, user, i, count)
    if not yt:
      continue
    try:
      channels = callGAPIpages(yt.channels(), 'list', 'items',
                               throwReasons=GAPI.YOUTUBE_THROW_REASONS,
                               fields='nextPageToken,items', **kwargs)
    except (GAPI.unsupportedSupervisedAccount, GAPI.unsupportedLanguageCode) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
      continue
    except GAPI.contentOwnerAccountNotFound as e:
      if 'managedByMe' in kwargs:
        entityActionFailedWarning([Ent.USER, user, Ent.OWNER, kwargs['onBehalfOfContentOwner']], str(e), i, count)
      else:
        entityActionFailedWarning([Ent.USER, user], str(e), i, count)
      continue
    except (GAPI.serviceNotAvailable, GAPI.authError):
      userYouTubeServiceNotEnabledWarning(user, i, count)
      continue
    if not csvPF:
      jcount = len(channels)
      if not FJQC.formatJSON:
        entityPerformActionNumItems([Ent.USER, user], jcount, Ent.YOUTUBE_CHANNEL, i, count)
      Ind.Increment()
      j = 0
      for channel in channels:
        j += 1
        if FJQC.formatJSON:
          printLine(json.dumps(cleanJSON(channel, timeObjects=YOUTUBE_CHANNEL_TIME_OBJECTS),
                               ensure_ascii=False, sort_keys=True))
          break
        printEntity([Ent.YOUTUBE_CHANNEL, channel['id']], j, jcount)
        Ind.Increment()
        showJSON(None, channel, skipObjects={'id'}, timeObjects=YOUTUBE_CHANNEL_TIME_OBJECTS)
        Ind.Decrement()
      Ind.Decrement()
    else:
      for channel in channels:
        row = {'User': user, 'id': channel['id']}
        flattenJSON(channel, flattened=row, timeObjects=YOUTUBE_CHANNEL_TIME_OBJECTS)
        if not FJQC.formatJSON:
          csvPF.WriteRowTitles(row)
        elif csvPF.CheckRowTitles(row):
          row = {'User': user, 'id': channel['id'],
                 'JSON': json.dumps(cleanJSON(channel, timeObjects=YOUTUBE_CHANNEL_TIME_OBJECTS),
                                    ensure_ascii=False, sort_keys=True)}
          csvPF.WriteRowNoFilter(row)
  if csvPF:
    csvPF.writeCSVfile('YouTube Channels')

def _getEntityMimeType(fileEntry):
  if fileEntry['mimeType'] == MIMETYPE_GA_FOLDER:
    return Ent.DRIVE_FOLDER
  if fileEntry['mimeType'].startswith(MIMETYPE_GA_3P_SHORTCUT):
    return Ent.DRIVE_3PSHORTCUT
  if fileEntry['mimeType'] != MIMETYPE_GA_SHORTCUT:
    return Ent.DRIVE_FILE
  if 'shortcutDetails' not in fileEntry or 'targetMimeType' not in fileEntry['shortcutDetails']:
    return Ent.DRIVE_SHORTCUT
  return Ent.DRIVE_FOLDER_SHORTCUT if fileEntry['shortcutDetails']['targetMimeType'] == MIMETYPE_GA_FOLDER else Ent.DRIVE_FILE_SHORTCUT

def _getTargetEntityMimeType(fileEntry):
  return Ent.DRIVE_FOLDER if fileEntry['shortcutDetails']['targetMimeType'] == MIMETYPE_GA_FOLDER else Ent.DRIVE_FILE

CORPORA_ALL_DRIVES = 'allDrives'
CORPORA_CHOICE_MAP = {
  'alldrives': CORPORA_ALL_DRIVES,
  'allshareddrives': CORPORA_ALL_DRIVES,
  'allteamdrives': CORPORA_ALL_DRIVES,
  'domain': 'domain',
  'onlyshareddrives': CORPORA_ALL_DRIVES,
  'onlyteamdrives': CORPORA_ALL_DRIVES,
  'user': 'user',
  }

QUERY_SHORTCUTS_MAP = {
  'allfiles': f"mimeType != '{MIMETYPE_GA_FOLDER}'",
  'allfolders': f"mimeType = '{MIMETYPE_GA_FOLDER}'",
  'allforms': f"mimeType = '{MIMETYPE_GA_FORM}'",
  'allgooglefiles': f"mimeType != '{MIMETYPE_GA_FOLDER}' and mimeType contains 'vnd.google'",
  'allnongooglefiles': "not mimeType contains 'vnd.google'",
  'allshortcuts': f"mimeType = '{MIMETYPE_GA_SHORTCUT}'",
  'all3pshortcuts': f"mimeType = '{MIMETYPE_GA_3P_SHORTCUT}'",
  'allitems': 'allitems',
  'mycommentableitems': ME_IN_OWNERS_AND+f"(mimeType = '{MIMETYPE_GA_DOCUMENT}' or mimeType = '{MIMETYPE_GA_SPREADSHEET}' or mimeType = '{MIMETYPE_GA_PRESENTATION}')",
  'mydocs': ME_IN_OWNERS_AND+f"mimeType = '{MIMETYPE_GA_DOCUMENT}'",
  'myfiles': ME_IN_OWNERS_AND+f"mimeType != '{MIMETYPE_GA_FOLDER}'",
  'myfolders': ME_IN_OWNERS_AND+f"mimeType = '{MIMETYPE_GA_FOLDER}'",
  'myforms': ME_IN_OWNERS_AND+f"mimeType = '{MIMETYPE_GA_FORM}'",
  'mygooglefiles': ME_IN_OWNERS_AND+f"mimeType != '{MIMETYPE_GA_FOLDER}' and mimeType contains 'vnd.google'",
  'mynongooglefiles': ME_IN_OWNERS_AND+"not mimeType contains 'vnd.google'",
  'mypresentations': ME_IN_OWNERS_AND+f"mimeType = '{MIMETYPE_GA_PRESENTATION}'",
  'mypublishableitems': ME_IN_OWNERS_AND+f"(mimeType = '{MIMETYPE_GA_DOCUMENT}' or mimeType = '{MIMETYPE_GA_SPREADSHEET}' or mimeType = '{MIMETYPE_GA_FORM}' or mimeType = '{MIMETYPE_GA_PRESENTATION}')",
  'mysheets': ME_IN_OWNERS_AND+f"mimeType = '{MIMETYPE_GA_SPREADSHEET}'",
  'myshortcuts': ME_IN_OWNERS_AND+f"mimeType = '{MIMETYPE_GA_SHORTCUT}'",
  'myslides': ME_IN_OWNERS_AND+f"mimeType = '{MIMETYPE_GA_PRESENTATION}'",
  'my3pshortcuts': ME_IN_OWNERS_AND+f"mimeType = '{MIMETYPE_GA_3P_SHORTCUT}'",
  'myitems': ME_IN_OWNERS,
  'mytopfiles': ME_IN_OWNERS_AND+f"mimeType != '{MIMETYPE_GA_FOLDER}' and 'root' in parents",
  'mytopfolders': ME_IN_OWNERS_AND+f"mimeType = '{MIMETYPE_GA_FOLDER}' and 'root' in parents",
  'mytopitems': ME_IN_OWNERS_AND+"'root' in parents",
  'othersfiles': NOT_ME_IN_OWNERS_AND+f"mimeType != '{MIMETYPE_GA_FOLDER}'",
  'othersfolders': NOT_ME_IN_OWNERS_AND+f"mimeType = '{MIMETYPE_GA_FOLDER}'",
  'othersforms': NOT_ME_IN_OWNERS_AND+f"mimeType = '{MIMETYPE_GA_FORM}'",
  'othersgooglefiles': NOT_ME_IN_OWNERS_AND+f"mimeType != '{MIMETYPE_GA_FOLDER}' and mimeType contains 'vnd.google'",
  'othersnongooglefiles': NOT_ME_IN_OWNERS_AND+"not mimeType contains 'vnd.google'",
  'othersshortcuts': NOT_ME_IN_OWNERS_AND+f"mimeType = '{MIMETYPE_GA_SHORTCUT}'",
  'others3pshortcuts': NOT_ME_IN_OWNERS_AND+f"mimeType = '{MIMETYPE_GA_3P_SHORTCUT}'",
  'othersitems': NOT_ME_IN_OWNERS,
  'writablefiles': f"'me' in writers and mimeType != '{MIMETYPE_GA_FOLDER}'",
  }
SHAREDDRIVE_QUERY_SHORTCUTS_MAP = {
  'allfiles': f"mimeType != '{MIMETYPE_GA_FOLDER}'",
  'allfolders': f"mimeType = '{MIMETYPE_GA_FOLDER}'",
  'allgooglefiles': f"mimeType != '{MIMETYPE_GA_FOLDER}' and mimeType contains 'vnd.google'",
  'allnongooglefiles': "not mimeType contains 'vnd.google'",
  'allitems': 'allitems',
  }

def doDriveSearch(drive, user, i, count, query=None, parentQuery=False, emptyQueryOK=False, orderBy=None, sharedDriveOnly=False, **kwargs):
  if query == 'allitems':
    query = None
  if GC.Values[GC.SHOW_GETTINGS]:
    printGettingAllEntityItemsForWhom(Ent.DRIVE_FILE_OR_FOLDER, user, i, count, query=query)
  try:
    files = callGAPIpages(drive.files(), 'list', 'files',
                          pageMessage=getPageMessageForWhom(),
                          throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID,
                                                                      GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND,
                                                                      GAPI.NOT_FOUND, GAPI.TEAMDRIVE_MEMBERSHIP_REQUIRED],
                          retryReasons=[GAPI.UNKNOWN_ERROR],
                          q=query, orderBy=orderBy, fields='nextPageToken,files(id,driveId)', pageSize=GC.Values[GC.DRIVE_MAX_RESULTS], **kwargs)
    if files or not parentQuery:
      return [f_file['id'] for f_file in files if not sharedDriveOnly or f_file.get('driveId')]
    if emptyQueryOK:
      return []
    entityActionNotPerformedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER, None],
                                    emptyQuery(query, Ent.DRIVE_FILE_OR_FOLDER if not parentQuery else Ent.DRIVE_PARENT_FOLDER), i, count)
  except (GAPI.invalidQuery, GAPI.invalid, GAPI.badRequest):
    entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER, None], invalidQuery(query), i, count)
  except GAPI.fileNotFound:
    printGotEntityItemsForWhom(0)
    if emptyQueryOK:
      return []
  except (GAPI.notFound, GAPI.teamDriveMembershipRequired) as e:
    entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, kwargs['driveId']], str(e), i, count)
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
    userDriveServiceNotEnabledWarning(user, str(e), i, count)
  return None

def doSharedDriveSearch(drive, user, i, count, query, useDomainAdminAccess):
  if GC.Values[GC.SHOW_GETTINGS]:
    printGettingAllEntityItemsForWhom(Ent.SHAREDDRIVE, user, i, count, query=query)
  try:
    files = callGAPIpages(drive.drives(), 'list', 'drives',
                          pageMessage=getPageMessageForWhom(),
                          throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID,
                                                                      GAPI.QUERY_REQUIRES_ADMIN_CREDENTIALS,
                                                                      GAPI.NO_LIST_TEAMDRIVES_ADMINISTRATOR_PRIVILEGE],
                          q=query, useDomainAdminAccess=useDomainAdminAccess,
                          fields='nextPageToken,drives(id)', pageSize=100)
    if files:
      return [f_file['id'] for f_file in files]
    entityActionNotPerformedWarning([Ent.USER, user, Ent.DRIVE_FILE, None], emptyQuery(query, Ent.SHAREDDRIVE), i, count)
  except (GAPI.invalidQuery, GAPI.invalid):
    entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE, None], invalidQuery(query), i, count)
  except (GAPI.queryRequiresAdminCredentials, GAPI.noListTeamDrivesAdministratorPrivilege) as e:
    entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE, None], str(e), i, count)
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
    userDriveServiceNotEnabledWarning(user, str(e), i, count)
  return None

def _getFileIdFromURL(fileId):
  loc = fileId.find('/d/')
  if loc > 0:
    fileId = fileId[loc+3:]
    loc = fileId.find('/')
    return fileId[:loc] if loc != -1 else fileId
  loc = fileId.find('?id=')
  if loc > 0:
    fileId = fileId[loc+4:]
    loc = fileId.find('&')
    return fileId[:loc] if loc != -1 else fileId
  loc = fileId.find('/files/')
  if loc > 0:
    fileId = fileId[loc+7:]
    loc = fileId.find('&')
    if loc > 0:
      return fileId[:loc]
    loc = fileId.find('?')
    return fileId[:loc] if loc != -1 else fileId
  loc = fileId.find('/folders/')
  if loc > 0:
    fileId = fileId[loc+9:]
    loc = fileId.find('&')
    if loc > 0:
      return fileId[:loc]
    loc = fileId.find('?')
    return fileId[:loc] if loc != -1 else fileId
  return None

def cleanFileIDsList(fileIdEntity, fileIds):
  fileIdEntity['list'] = []
  fileIdEntity[ROOT] = []
  i = 0
  for fileId in fileIds:
    if fileId[:8].lower() == 'https://' or fileId[:7].lower() == 'http://':
      fileId = _getFileIdFromURL(fileId)
      if fileId is None:
        continue
    elif fileId.lower() in {ROOT, ROOTID}:
      fileIdEntity[ROOT].append(i)
      fileId = fileId.lower()
    fileIdEntity['list'].append(fileId)
    i += 1

TITLE_QUERY_PATTERN = re.compile(r'title((?: *!?=)|(?: +contains))', flags=re.IGNORECASE)

def _mapDrive2QueryToDrive3(query):
  if query:
    query = TITLE_QUERY_PATTERN.sub(r'name\1', query).replace('modifiedDate', 'modifiedTime').replace('lastViewedByMeDate', 'viewedByMeTime')
    query = mapQueryRelativeTimes(query, ['modifiedTime', 'viewedByMeTime'])
  return query

def escapeDriveFileName(filename):
  if filename.find("'") == -1 and filename.find('\\') == -1:
    return filename
  encfilename = ''
  for c in filename:
    if c == "'":
      encfilename += "\\'"
    elif c == '\\':
      encfilename += '\\\\'
    else:
      encfilename += c
  return encfilename

def getEscapedDriveFileName():
  return escapeDriveFileName(getString(Cmd.OB_DRIVE_FILE_NAME))

def getEscapedDriveFolderName():
  return escapeDriveFileName(getString(Cmd.OB_DRIVE_FOLDER_NAME))

def initDriveFileEntity():
  return {'list': [], 'shareddrivename': None, 'shareddriveadminquery': None, 'shareddrivefilequery': None,
          'query': None, 'dict': None, ROOT: [], 'shareddrive': {},
          'location': None, 'nonDomainAdminAccess': False}

DRIVE_BY_NAME_CHOICE_MAP = {
  'anyname': WITH_ANY_FILE_NAME,
  'anydrivefilename': WITH_ANY_FILE_NAME,
  'anyownername': WITH_ANY_FILE_NAME,
  'anyownerdrivefilename': WITH_ANY_FILE_NAME,
  'sharedname': WITH_ANY_FILE_NAME,
  'shareddrivefilename': WITH_ANY_FILE_NAME,
  'name': WITH_MY_FILE_NAME,
  'drivefilename': WITH_MY_FILE_NAME,
  'othername': WITH_OTHER_FILE_NAME,
  'otherdrivefilename': WITH_ANY_FILE_NAME,
  }

LOCATION_ALL_DRIVES = 0
LOCATION_MYDRIVE = 1
LOCATION_ORPHANS = 2
LOCATION_OWNEDBY_ANY = 3
LOCATION_OWNEDBY_OTHERS = 4
LOCATION_SHARED_WITHME = 5
LOCATION_ONLY_SHARED_DRIVES = 6

LOCATION_CHOICE_MAP = {
  'alldrives': {'fileids': [ROOT, ORPHANS, SHARED_WITHME, SHARED_DRIVES], 'owner': None, 'location': LOCATION_ALL_DRIVES, 'setShowOwnedBy': True},
  'root': {'fileids': [ROOT], 'owner': True, 'location': LOCATION_MYDRIVE, 'setShowOwnedBy': False},
  'rootwithorphans': {'fileids': [ROOT, ORPHANS], 'owner': True, 'location': LOCATION_MYDRIVE, 'setShowOwnedBy': False},
  'mydrive': {'fileids': [ROOT], 'owner': True, 'location': LOCATION_MYDRIVE, 'setShowOwnedBy': False},
  'mydrivewithorphans': {'fileids': [ROOT, ORPHANS], 'owner': True, 'location': LOCATION_MYDRIVE, 'setShowOwnedBy': False},
  'mydriveany': {'fileids': [ROOT, ORPHANS], 'owner': None, 'location': LOCATION_MYDRIVE, 'setShowOwnedBy': True},
  'mydriveme': {'fileids': [ROOT, ORPHANS], 'owner': True, 'location': LOCATION_MYDRIVE, 'setShowOwnedBy': True},
  'mydriveothers': {'fileids': [ROOT], 'owner': False, 'location': LOCATION_MYDRIVE, 'setShowOwnedBy': True},
  'onlyshareddrives': {'fileids': [SHARED_DRIVES], 'owner': False, 'location': LOCATION_ONLY_SHARED_DRIVES, 'setShowOwnedBy': True},
  'onlyteamdrives': {'fileids': [SHARED_DRIVES], 'owner': False, 'location': LOCATION_ONLY_SHARED_DRIVES, 'setShowOwnedBy': True},
  'orphans': {'fileids': [ORPHANS], 'owner': True, 'location': LOCATION_ORPHANS, 'setShowOwnedBy': True},
  'ownedbyany': {'fileids': [ROOT, ORPHANS, SHARED_WITHME], 'owner': None, 'location': LOCATION_OWNEDBY_ANY, 'setShowOwnedBy': True},
  'ownedbyme': {'fileids': [ROOT, ORPHANS, SHARED_WITHME], 'owner': True, 'location': LOCATION_MYDRIVE, 'setShowOwnedBy': True},
  'ownedbyothers': {'fileids': [ROOT, ORPHANS, SHARED_WITHME], 'owner': False, 'location': LOCATION_OWNEDBY_OTHERS, 'setShowOwnedBy': True},
  'sharedwithme': {'fileids': [ROOT, SHARED_WITHME], 'owner': False, 'location': LOCATION_OWNEDBY_OTHERS, 'setShowOwnedBy': True},
  'sharedwithmemydrive': {'fileids': [ROOT], 'owner': False, 'location': LOCATION_MYDRIVE, 'setShowOwnedBy': True},
  'sharedwithmenotmydrive': {'fileids': [SHARED_WITHME], 'owner': False, 'location': LOCATION_SHARED_WITHME, 'setShowOwnedBy': True},
  }

def getDriveFileEntity(queryShortcutsOK=True, DLP=None):
  def _getKeywordColonValue(kwColonValue):
    kw, value = kwColonValue.split(':', 1)
    kw = kw.lower().replace('_', '').replace('-', '')
    if kw == 'id':
      cleanFileIDsList(fileIdEntity, [value])
    elif kw == 'ids':
      cleanFileIDsList(fileIdEntity, value.replace(',', ' ').split())
    elif kw == 'query':
      fileIdEntity['query'] = _mapDrive2QueryToDrive3(value)
      fileIdEntity['nonDomainAdminAccess'] = True
    elif kw in DRIVE_BY_NAME_CHOICE_MAP:
      fileIdEntity['query'] = DRIVE_BY_NAME_CHOICE_MAP[kw].format(escapeDriveFileName(value))
      fileIdEntity['nonDomainAdminAccess'] = True
    else:
      return False
    return True

  def _getTDKeywordColonValue(kwColonValue):
    kw, value = kwColonValue.split(':', 1)
    kw = kw.lower().replace('_', '').replace('-', '')
    if kw in {'teamdriveid', 'shareddriveid'}:
      fileIdEntity['shareddrive']['driveId'] = value
    elif kw in {'teamdrive', 'shareddrive'}:
      fileIdEntity['shareddrivename'] = value
    elif kw in {'teamdriveadminquery', 'shareddriveadminquery'}:
      fileIdEntity['shareddriveadminquery'] = value
    elif kw in {'teamdrivefilename', 'shareddrivefilename'}:
      fileIdEntity['shareddrivefilequery'] = WITH_ANY_FILE_NAME.format(escapeDriveFileName(value))
    elif kw in {'teamdrivequery', 'shareddrivequery'}:
      fileIdEntity['shareddrivefilequery'] = _mapDrive2QueryToDrive3(value)
    else:
      return False
    return True

  fileIdEntity = initDriveFileEntity()
  entitySelector = getEntitySelector()
  if entitySelector:
    entityList = getEntitySelection(entitySelector, False)
    if isinstance(entityList, dict):
      fileIdEntity['dict'] = entityList
    else:
      cleanFileIDsList(fileIdEntity, entityList)
    return fileIdEntity
  fileIdEntity['location'] = Cmd.Location()
  myarg = getString(Cmd.OB_DRIVE_FILE_ID, checkBlank=True)
  mycmd = myarg.lower().replace('_', '').replace('-', '')
  if mycmd == 'id':
    cleanFileIDsList(fileIdEntity, getStringReturnInList(Cmd.OB_DRIVE_FILE_ID))
  elif mycmd == 'ids':
    cleanFileIDsList(fileIdEntity, getString(Cmd.OB_DRIVE_FILE_ID).replace(',', ' ').split())
  elif mycmd == 'query':
    fileIdEntity['query'] = _mapDrive2QueryToDrive3(getString(Cmd.OB_QUERY))
    fileIdEntity['nonDomainAdminAccess'] = True
  elif queryShortcutsOK and mycmd in QUERY_SHORTCUTS_MAP:
    fileIdEntity['query'] = QUERY_SHORTCUTS_MAP[mycmd]
    fileIdEntity['nonDomainAdminAccess'] = True
  elif mycmd in DRIVE_BY_NAME_CHOICE_MAP:
    fileIdEntity['query'] = DRIVE_BY_NAME_CHOICE_MAP[mycmd].format(getEscapedDriveFileName())
    fileIdEntity['nonDomainAdminAccess'] = True
  elif mycmd in {'rootid', 'mydriveid'}:
    cleanFileIDsList(fileIdEntity, [ROOTID])
    fileIdEntity['nonDomainAdminAccess'] = True
  elif not DLP and mycmd in {'root', 'mydrive'}:
    cleanFileIDsList(fileIdEntity, [ROOT])
    fileIdEntity['nonDomainAdminAccess'] = True
  elif DLP and mycmd in LOCATION_CHOICE_MAP:
    DLP.SetLocation(LOCATION_CHOICE_MAP[mycmd])
    cleanFileIDsList(fileIdEntity, DLP.locationFileIds)
    fileIdEntity['nonDomainAdminAccess'] = True
  elif mycmd.startswith('teamdrive') or mycmd.startswith('shareddrive'):
    fileIdEntity['shareddrive'] = {'driveId': None,
                                   'corpora': 'drive', 'includeItemsFromAllDrives': True, 'supportsAllDrives': True}
    while True:
      if mycmd in {'teamdriveid', 'shareddriveid'}:
        fileIdEntity['shareddrive']['driveId'] = getString(Cmd.OB_SHAREDDRIVE_ID).strip()
      elif mycmd in {'teamdrive', 'shareddrive'}:
        fileIdEntity['shareddrivename'] = getString(Cmd.OB_SHAREDDRIVE_NAME)
      elif mycmd in {'teamdriveadminquery', 'shareddriveadminquery'}:
        fileIdEntity['shareddriveadminquery'] = getString(Cmd.OB_QUERY)
      elif mycmd in {'teamdrivefilename', 'shareddrivefilename'}:
        fileIdEntity['shareddrivefilequery'] = WITH_ANY_FILE_NAME.format(getEscapedDriveFileName())
      elif mycmd in {'teamdrivequery', 'shareddrivequery'}:
        fileIdEntity['shareddrivefilequery'] = _mapDrive2QueryToDrive3(getString(Cmd.OB_QUERY))
      elif queryShortcutsOK and mycmd in SHAREDDRIVE_QUERY_SHORTCUTS_MAP:
        fileIdEntity['shareddrivefilequery'] = SHAREDDRIVE_QUERY_SHORTCUTS_MAP[mycmd]
      elif (mycmd.find(':') > 0) and _getTDKeywordColonValue(myarg):
        pass
      else:
        unknownArgumentExit()
      if Cmd.ArgumentsRemaining():
        myarg = getString(Cmd.OB_STRING)
        mycmd = myarg.lower().replace('_', '').replace('-', '')
        if (mycmd.startswith('teamdriveparent') or mycmd.startswith('shareddriveparent') or
            ((not (mycmd.startswith('teamdrive') or mycmd.startswith('shareddrive'))) and
             (not (queryShortcutsOK and mycmd in SHAREDDRIVE_QUERY_SHORTCUTS_MAP)))):
          Cmd.Backup()
          break
      else:
        break
    if not fileIdEntity['shareddrive'].get('driveId'):
      fileIdEntity['shareddrive']['corpora'] = CORPORA_ALL_DRIVES
  elif (mycmd.find(':') > 0) and _getKeywordColonValue(myarg):
    pass
  else:
    cleanFileIDsList(fileIdEntity, [myarg])
  return fileIdEntity

def getDriveFileEntitySharedDriveOnly():
  def _getTDKeywordColonValue(kwColonValue):
    kw, value = kwColonValue.split(':', 1)
    kw = kw.lower().replace('_', '').replace('-', '')
    if kw in {'teamdriveid', 'shareddriveid'}:
      fileIdEntity['shareddrive']['driveId'] = value
    elif kw in {'teamdrive', 'shareddrive'}:
      fileIdEntity['shareddrivename'] = value
    elif kw in {'teamdriveadminquery', 'shareddriveadminquery'}:
      fileIdEntity['shareddriveadminquery'] = value
    else:
      return False
    return True

  fileIdEntity = initDriveFileEntity()
  myarg = getString(Cmd.OB_DRIVE_FILE_ID, checkBlank=True)
  mycmd = myarg.lower().replace('_', '').replace('-', '')
  if mycmd.startswith('teamdrive') or mycmd.startswith('shareddrive'):
    fileIdEntity['shareddrive'] = {'driveId': None,
                                 'corpora': 'drive', 'includeItemsFromAllDrives': True, 'supportsAllDrives': True}
    if mycmd in {'teamdriveid', 'shareddriveid'}:
      fileIdEntity['shareddrive']['driveId'] = getString(Cmd.OB_SHAREDDRIVE_ID)
    elif mycmd in {'teamdrive', 'shareddrive'}:
      fileIdEntity['shareddrivename'] = getString(Cmd.OB_SHAREDDRIVE_NAME)
    elif mycmd in {'teamdriveadminquery', 'shareddriveadminquery'}:
      fileIdEntity['shareddriveadminquery'] = getString(Cmd.OB_QUERY)
    elif (mycmd.find(':') > 0) and _getTDKeywordColonValue(myarg):
      pass
    else:
      unknownArgumentExit()
    if not fileIdEntity['shareddrive'].get('driveId'):
      fileIdEntity['shareddrive']['corpora'] = CORPORA_ALL_DRIVES
  elif (mycmd.find(':') > 0) and _getTDKeywordColonValue(myarg):
    pass
  else:
    unknownArgumentExit()
  return fileIdEntity

def getSharedDriveEntity():
  def _getTDKeywordColonValue(kwColonValue):
    kw, value = kwColonValue.split(':', 1)
    kw = kw.lower().replace('_', '').replace('-', '')
    if kw in {'teamdriveid', 'shareddriveid'}:
      fileIdEntity['shareddrive']['driveId'] = value
    elif kw in {'teamdrive', 'shareddrive', 'name'}:
      fileIdEntity['shareddrivename'] = value
    else:
      return False
    return True

  fileIdEntity = initDriveFileEntity()
  myarg = getString(Cmd.OB_DRIVE_FILE_ID, checkBlank=True)
  mycmd = myarg.lower().replace('_', '').replace('-', '')
  fileIdEntity['shareddrive'] = {'driveId': None,
                               'corpora': 'drive', 'includeItemsFromAllDrives': True, 'supportsAllDrives': True}
  if mycmd in {'teamdriveid', 'shareddriveid'}:
    fileIdEntity['shareddrive']['driveId'] = getString(Cmd.OB_SHAREDDRIVE_ID)
  elif mycmd in {'teamdrive', 'shareddrive', 'name'}:
    fileIdEntity['shareddrivename'] = getString(Cmd.OB_SHAREDDRIVE_NAME)
  elif (mycmd.find(':') > 0) and _getTDKeywordColonValue(myarg):
    pass
  else:
    fileIdEntity['shareddrive']['driveId'] = myarg
  return fileIdEntity

def _convertSharedDriveNameToId(drive, user, i, count, fileIdEntity, useDomainAdminAccess=False):
  try:
    if "\\'" in fileIdEntity['shareddrivename']:
      name = fileIdEntity['shareddrivename']
    else:
      name = fileIdEntity['shareddrivename'].replace("'", "\\'")
    tdlist = callGAPIpages(drive.drives(), 'list', 'drives',
                           throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                           useDomainAdminAccess=useDomainAdminAccess,
                           q=f"name='{name}'",
                           fields='nextPageToken,drives(id,name)', pageSize=100)
    if "\\'" in fileIdEntity['shareddrivename']:
      fileIdEntity['shareddrivename'] = fileIdEntity['shareddrivename'].replace("\\'", "'")
    if not tdlist:
      name = fileIdEntity['shareddrivename'].lower()
      feed = callGAPIpages(drive.drives(), 'list', 'drives',
                           throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                           useDomainAdminAccess=useDomainAdminAccess,
                           fields='nextPageToken,drives(id,name)', pageSize=100)
      tdlist = [td for td in feed if td['name'].lower() == name]
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy):
    entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_NAME, fileIdEntity['shareddrivename']], Msg.DOES_NOT_EXIST, i, count)
    return False
  jcount = len(tdlist)
  if jcount == 1:
    fileIdEntity['shareddrive']['driveId'] = tdlist[0]['id']
    return True
  if jcount == 0:
    entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_NAME, fileIdEntity['shareddrivename']], Msg.DOES_NOT_EXIST, i, count)
  else:
    entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_NAME, fileIdEntity['shareddrivename']],
                              Msg.MULTIPLE_ENTITIES_FOUND.format(Ent.Plural(Ent.SHAREDDRIVE_ID), jcount,
                                                                 ','.join([td['id'] for td in tdlist])), i, count)
  return False

def _getSharedDriveNameFromId(drive, sharedDriveId, useDomainAdminAccess=False):
  sharedDriveName = GM.Globals[GM.MAP_SHAREDDRIVE_ID_TO_NAME].get(sharedDriveId)
  if not sharedDriveName:
    try:
      sharedDriveName = callGAPI(drive.drives(), 'get',
                                 throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND],
                                 useDomainAdminAccess=useDomainAdminAccess,
                                 driveId=sharedDriveId, fields='name')['name']
    except (GAPI.notFound, GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy):
      sharedDriveName = TEAM_DRIVE
    GM.Globals[GM.MAP_SHAREDDRIVE_ID_TO_NAME][sharedDriveId] = sharedDriveName
  return sharedDriveName

def _getDriveFileNameFromId(drive, fileId, combineTitleId=True, useDomainAdminAccess=False):
  try:
    result = callGAPI(drive.files(), 'get',
                      throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS,
                      fileId=fileId, fields='name,mimeType,driveId', supportsAllDrives=True)
    if result:
      fileName = result['name']
      if (result['mimeType'] == MIMETYPE_GA_FOLDER) and result.get('driveId') and (result['name'] == TEAM_DRIVE):
        fileName = _getSharedDriveNameFromId(drive, result['driveId'])
      if combineTitleId:
        fileName += '('+fileId+')'
      return (fileName, _getEntityMimeType(result), result['mimeType'])
  except GAPI.fileNotFound:
    if useDomainAdminAccess:
      try:
        result = callGAPI(drive.drives(), 'get',
                          throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND],
                          useDomainAdminAccess=useDomainAdminAccess,
                          driveId=fileId, fields='name')
        if result:
          fileName = result['name']
          if combineTitleId:
            fileName += '('+fileId+')'
          return (fileName, Ent.DRIVE_FOLDER, MIMETYPE_GA_FOLDER)
      except GAPI.notFound:
        pass
  except (GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.internalError,
          GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy):
    pass
  return (fileId, Ent.DRIVE_FILE_OR_FOLDER_ID, None)

def _simpleFileIdEntityList(fileIdEntityList):
  for fileId in fileIdEntityList:
    if fileId not in {ROOT, ORPHANS, SHARED_WITHME, SHARED_DRIVES}:
      return False
  return True

def _validateUserGetFileIDs(user, i, count, fileIdEntity, drive=None, entityType=None, orderBy=None, useDomainAdminAccess=False):
  def _identifyRoot():
    try:
      rootFolderId = callGAPI(drive.files(), 'get',
                              throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                              fileId=ROOT, fields='id')['id']
      for j in fileIdEntity[ROOT]:
        fileIdEntity['list'][j] = rootFolderId
      return True
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      return False

  if fileIdEntity['dict']:
    cleanFileIDsList(fileIdEntity, fileIdEntity['dict'][user])
  if not drive:
    user, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
    if not drive:
      return (user, None, 0)
  else:
    user = convertUIDtoEmailAddress(user)
  if fileIdEntity['list'] and _simpleFileIdEntityList(fileIdEntity['list']):
    l = len(fileIdEntity['list'])
    if ROOT in fileIdEntity['list'] and fileIdEntity[ROOT]:
      if not _identifyRoot():
        return (user, None, 0)
    if entityType:
      entityPerformActionNumItems([Ent.USER, user], l, entityType, i, count)
    return (user, drive, l)
  if fileIdEntity['shareddrivename'] and 'driveId' not in fileIdEntity:
    if not _convertSharedDriveNameToId(drive, user, i, count, fileIdEntity, useDomainAdminAccess):
      return (user, None, 0)
    if not fileIdEntity['shareddrivefilequery']:
      fileIdEntity['list'] = [fileIdEntity['shareddrive']['driveId']]
    fileIdEntity['shareddrive']['corpora'] = 'drive'
  elif fileIdEntity['shareddrive'].get('driveId'):
    if not fileIdEntity['shareddrivefilequery']:
      fileIdEntity['list'] = [fileIdEntity['shareddrive']['driveId']]
    fileIdEntity['shareddrive']['corpora'] = 'drive'
  if fileIdEntity['query']:
    fileIdEntity['list'] = doDriveSearch(drive, user, i, count, query=fileIdEntity['query'], orderBy=orderBy)
    if fileIdEntity['list'] is None:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      return (user, None, 0)
  elif fileIdEntity['shareddriveadminquery']:
    fileIdEntity['list'] = doSharedDriveSearch(drive, user, i, count, fileIdEntity['shareddriveadminquery'], useDomainAdminAccess)
    if fileIdEntity['list'] is None:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      return (user, None, 0)
  elif fileIdEntity['shareddrivefilequery']:
    if not fileIdEntity['shareddrive'].get('driveId'):
      fileIdEntity['shareddrive']['corpora'] = CORPORA_ALL_DRIVES
    fileIdEntity['list'] = doDriveSearch(drive, user, i, count, query=fileIdEntity['shareddrivefilequery'], orderBy=orderBy, sharedDriveOnly=True, **fileIdEntity['shareddrive'])
    if fileIdEntity['list'] is None or not fileIdEntity['list']:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      return (user, None, 0)
    fileIdEntity['shareddrive'].pop('driveId', None)
    fileIdEntity['shareddrive'].pop('corpora', None)
  elif fileIdEntity[ROOT]:
    if not _identifyRoot():
      return (user, None, 0)
  l = len(fileIdEntity['list'])
  if l == 0:
    setSysExitRC(NO_ENTITIES_FOUND_RC)
  if entityType:
    entityPerformActionNumItems([Ent.USER, user], l, entityType, i, count)
  return (user, drive, l)

# Disc File Access paramaters
DFA_CREATED_TIME = 'createdTime'
DFA_MODIFIED_TIME = 'modifiedTime'
DFA_PRESERVE_FILE_TIMES = 'preserveFileTimes'
DFA_IGNORE_DEFAULT_VISIBILITY = 'ignoreDefaultVisibility'
DFA_KEEP_REVISION_FOREVER = 'keepRevisionForever'
DFA_URL = 'url'
DFA_LOCALFILEPATH = 'localFilepath'
DFA_LOCALFILENAME = 'localFilename'
DFA_LOCALMIMETYPE = 'localMimeType'
DFA_STRIPNAMEPREFIX = 'stripNamePrefix'
DFA_REPLACEFILENAME = 'replaceFileName'
DFA_OCRLANGUAGE = 'ocrLanguage'
DFA_PARENTID = 'parentId'
DFA_PARENTQUERY = 'parentQuery'
DFA_ADD_PARENT_IDS = 'addParentIds'
DFA_ADD_PARENT_NAMES = 'addParentNames'
DFA_REMOVE_PARENT_IDS = 'removeParentIds'
DFA_REMOVE_PARENT_NAMES = 'removeParentNames'
DFA_SHAREDDRIVE_PARENT = 'sharedDriveParent'
DFA_SHAREDDRIVE_PARENTID = 'sharedDriveParentId'
DFA_SHAREDDRIVE_PARENTQUERY = 'sharedDriveParentQuery'
DFA_KWARGS = 'kwargs'
DFA_SEARCHARGS = 'searchargs'
DFA_USE_CONTENT_AS_INDEXABLE_TEXT = 'useContentAsIndexableText'
DFA_TIMESTAMP = 'timestamp'
DFA_TIMEFORMAT = 'timeformat'

def _driveFileParentSpecified(parameters):
  return (parameters[DFA_PARENTID] or parameters[DFA_PARENTQUERY] or
          parameters[DFA_SHAREDDRIVE_PARENT] or parameters[DFA_SHAREDDRIVE_PARENTID] or
          parameters[DFA_SHAREDDRIVE_PARENTQUERY])

def _getDriveFileParentInfo(drive, user, i, count, body, parameters, emptyQueryOK=False, defaultToRoot=True, entityType=Ent.DRIVE_FILE):
  def _setSearchArgs(driveId):
    parameters[DFA_SEARCHARGS] = {'driveId': driveId, 'corpora': 'drive',
                                  'includeItemsFromAllDrives': True, 'supportsAllDrives': True}
  body.pop('parents', None)
  if parameters[DFA_PARENTID]:
    body.setdefault('parents', [])
    try:
      result = callGAPI(drive.files(), 'get',
                        throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FILE_NOT_FOUND],
                        fileId=parameters[DFA_PARENTID], fields='id,name,mimeType,driveId', supportsAllDrives=True)
      if result['mimeType'] != MIMETYPE_GA_FOLDER:
        entityActionNotPerformedWarning([Ent.USER, user, entityType, None],
                                        f'parentid: {parameters[DFA_PARENTID]}, {Msg.NOT_AN_ENTITY.format((Ent.Singular(Ent.DRIVE_FOLDER)))}', i, count)
        return False
      body['parents'].append(result['id'])
      if result.get('driveId'):
        _setSearchArgs(result['driveId'])
    except GAPI.fileNotFound as e:
      entityActionNotPerformedWarning([Ent.USER, user, entityType, None],
                                      f'parentid: {parameters[DFA_PARENTID]}, {str(e)}', i, count)
      return False
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      return False
  if parameters[DFA_PARENTQUERY]:
    parents = doDriveSearch(drive, user, i, count, query=parameters[DFA_PARENTQUERY], parentQuery=True, emptyQueryOK=emptyQueryOK)
    if parents is None:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      return False
    body.setdefault('parents', [])
    for parent in parents:
      body['parents'].append(parent)
  if parameters[DFA_SHAREDDRIVE_PARENTID]:
    try:
      if not parameters[DFA_SHAREDDRIVE_PARENTQUERY]:
        body.setdefault('parents', [])
        result = callGAPI(drive.files(), 'get',
                          throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FILE_NOT_FOUND],
                          fileId=parameters[DFA_SHAREDDRIVE_PARENTID], fields='id,mimeType,driveId', supportsAllDrives=True)
        if result['mimeType'] != MIMETYPE_GA_FOLDER:
          entityActionNotPerformedWarning([Ent.USER, user, entityType, None],
                                          f'shareddriveparentid: {parameters[DFA_SHAREDDRIVE_PARENTID]}, {Msg.NOT_AN_ENTITY.format(Ent.Singular(Ent.DRIVE_FOLDER))}', i, count)
          return False
        if not result.get('driveId'):
          entityActionNotPerformedWarning([Ent.USER, user, entityType, None],
                                          f'shareddriveparentid: {parameters[DFA_SHAREDDRIVE_PARENTID]}, {Msg.NOT_AN_ENTITY.format(Ent.Singular(Ent.SHAREDDRIVE_FOLDER))}', i, count)
          return False
        body['parents'].append(result['id'])
        _setSearchArgs(result['driveId'])
      else:
        result = callGAPI(drive.drives(), 'get',
                          throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND],
                          driveId=parameters[DFA_SHAREDDRIVE_PARENTID], fields='id')
        parameters[DFA_KWARGS]['corpora'] = 'drive'
        parameters[DFA_KWARGS]['driveId'] = result['id']
        _setSearchArgs(result['id'])
    except (GAPI.fileNotFound, GAPI.notFound) as e:
      entityActionNotPerformedWarning([Ent.USER, user, entityType, None],
                                      f'shareddriveparentid: {parameters[DFA_SHAREDDRIVE_PARENTID]}, {str(e)}', i, count)
      return False
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      return False
  if parameters[DFA_SHAREDDRIVE_PARENT]:
    tempIdEntity = {'shareddrivename': parameters[DFA_SHAREDDRIVE_PARENT], 'shareddrive': {}}
    if not _convertSharedDriveNameToId(drive, user, i, count, tempIdEntity):
      return False
    if not parameters[DFA_SHAREDDRIVE_PARENTQUERY]:
      body.setdefault('parents', [])
      body['parents'].append(tempIdEntity['shareddrive']['driveId'])
    else:
      parameters[DFA_KWARGS]['corpora'] = 'drive'
      parameters[DFA_KWARGS]['driveId'] = tempIdEntity['shareddrive']['driveId']
    _setSearchArgs(tempIdEntity['shareddrive']['driveId'])
  if parameters[DFA_SHAREDDRIVE_PARENTQUERY]:
    parents = doDriveSearch(drive, user, i, count, query=parameters[DFA_SHAREDDRIVE_PARENTQUERY], parentQuery=True, emptyQueryOK=emptyQueryOK,
                            sharedDriveOnly=True, **parameters[DFA_KWARGS])
    if parents is None:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      return False
    body.setdefault('parents', [])
    for parent in parents:
      body['parents'].append(parent)
  if defaultToRoot and ('parents' not in body or not body['parents']):
    body['parents'] = [ROOT]
  numParents = len(body.get('parents', []))
  if numParents > 1:
    entityActionNotPerformedWarning([Ent.USER, user, entityType, None],
                                    Msg.MULTIPLE_PARENTS_SPECIFIED.format(numParents), i, count)
    return False
  return True

def _getDriveFileAddRemoveParentInfo(user, i, count, parameters, drive):
  addParents = parameters[DFA_ADD_PARENT_IDS][:]
  for query in parameters[DFA_ADD_PARENT_NAMES]:
    parents = doDriveSearch(drive, user, i, count, query=query, parentQuery=True)
    if parents is None:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      return (False, None, None)
    addParents.extend(parents)
  removeParents = parameters[DFA_REMOVE_PARENT_IDS][:]
  for query in parameters[DFA_REMOVE_PARENT_NAMES]:
    parents = doDriveSearch(drive, user, i, count, query=query, parentQuery=True)
    if parents is None:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      return (False, None, None)
    removeParents.extend(parents)
  return (True, addParents, removeParents)

def _validateUserGetSharedDriveFileIDs(user, i, count, fileIdEntity, drive=None, entityType=None):
  if fileIdEntity['dict']:
    cleanFileIDsList(fileIdEntity, fileIdEntity['dict'][user])
  if not drive:
    user, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
    if not drive:
      return (user, None, 0)
  else:
    user = convertUIDtoEmailAddress(user)
  if fileIdEntity.get('shareddrivename') and not _convertSharedDriveNameToId(drive, user, i, count, fileIdEntity):
    return (user, None, 0)
  if fileIdEntity['shareddrivefilequery']:
    fileIdEntity['list'] = doDriveSearch(drive, user, i, count, query=fileIdEntity['shareddrivefilequery'], sharedDriveOnly=True, **fileIdEntity['shareddrive'])
    if fileIdEntity['list'] is None or not fileIdEntity['list']:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      return (user, None, 0)
    fileIdEntity['shareddrive'].pop('driveId', None)
    fileIdEntity['shareddrive'].pop('corpora', None)
  l = len(fileIdEntity['list'])
  if l == 0:
    setSysExitRC(NO_ENTITIES_FOUND_RC)
  if entityType:
    entityPerformActionNumItems([Ent.USER, user], l, entityType, i, count)
  return (user, drive, l)

def _validateUserSharedDrive(user, i, count, fileIdEntity, useDomainAdminAccess=False):
  user, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
  if not drive:
    return (user, None)
  if fileIdEntity.get('shareddrivename'):
    if not _convertSharedDriveNameToId(drive, user, i, count, fileIdEntity, useDomainAdminAccess):
      return (user, None)
    fileIdEntity['shareddrive']['corpora'] = 'drive'
  return (user, drive)

DRIVE_LABEL_CHOICE_MAP = {
  'modified': 'modifiedByMe',
  'modifiedbyme': 'modifiedByMe',
  'restrict': 'copyRequiresWriterPermission',
  'restricted': 'copyRequiresWriterPermission',
  'star': 'starred',
  'starred': 'starred',
  'trash': 'trashed',
  'trashed': 'trashed',
  'view': 'viewedByMe',
  'viewed': 'viewedByMe',
  'viewedbyme': 'viewedByMe',
  }

MIMETYPE_CHOICE_MAP = {
  'gdoc': MIMETYPE_GA_DOCUMENT,
  'gdocument': MIMETYPE_GA_DOCUMENT,
  'gdrawing': MIMETYPE_GA_DRAWING,
  'gfile': MIMETYPE_GA_FILE,
  'gfolder': MIMETYPE_GA_FOLDER,
  'gdirectory': MIMETYPE_GA_FOLDER,
  'gform': MIMETYPE_GA_FORM,
  'gfusion': MIMETYPE_GA_FUSIONTABLE,
  'gfusiontable': MIMETYPE_GA_FUSIONTABLE,
  'gjam': MIMETYPE_GA_JAM,
  'gmap': MIMETYPE_GA_MAP,
  'gpresentation': MIMETYPE_GA_PRESENTATION,
  'gscript': MIMETYPE_GA_SCRIPT,
  'gshortcut': MIMETYPE_GA_SHORTCUT,
  'g3pshortcut': MIMETYPE_GA_3P_SHORTCUT,
  'gsite': MIMETYPE_GA_SITE,
  'gsheet': MIMETYPE_GA_SPREADSHEET,
  'gspreadsheet': MIMETYPE_GA_SPREADSHEET,
  'shortcut': MIMETYPE_GA_SHORTCUT,
  }

MIMETYPE_TYPES = ['application', 'audio', 'font', 'image', 'message', 'model', 'multipart', 'text', 'video']

def validateMimeType(mimeType):
  if mimeType in MIMETYPE_CHOICE_MAP:
    return MIMETYPE_CHOICE_MAP[mimeType]
  if mimeType.startswith(APPLICATION_VND_GOOGLE_APPS):
    return mimeType
  if mimeType.find('/') > 0:
    mediaType, subType = mimeType.split('/', 1)
    if mediaType in MIMETYPE_TYPES and subType:
      return mimeType
  invalidChoiceExit(mimeType, list(MIMETYPE_CHOICE_MAP)+[f'({formatChoiceList(MIMETYPE_TYPES)})/mediatype'], True)

def getMimeType():
  return validateMimeType(getString(Cmd.OB_MIMETYPE).lower())

class MimeTypeCheck():

  def __init__(self):
    self.mimeTypes = set()
    self.reverse = False
    self.category = set()

  def Get(self):
    if checkArgumentPresent('category'):
      for mimeType in getString(Cmd.OB_MIMETYPE_LIST).lower().replace(',', ' ').split():
        if mimeType in MIMETYPE_TYPES:
          self.category.add(mimeType)
        else:
          invalidChoiceExit(mimeType, MIMETYPE_TYPES, True)
      return
    self.reverse = checkArgumentPresent('not')
    for mimeType in getString(Cmd.OB_MIMETYPE_LIST).lower().replace(',', ' ').split():
      self.mimeTypes.add(validateMimeType(mimeType))

  def AddMimeTypeToQuery(self, query):
    if query:
      query += ' and ('
    else:
      query = '('
    if not self.reverse:
      for mimeType in self.mimeTypes:
        query += f"mimeType = '{mimeType}' or "
      for mimeType in self.category:
        query += f"mimeType contains '{mimeType}' or "
      query = query[:-4]
    else:
      for mimeType in self.mimeTypes:
        query += f"mimeType != '{mimeType}' and "
      query = query[:-5]
    query += ')'
    return query

  def Check(self, fileEntry):
    if not self.mimeTypes and not self.category:
      return True
    for mimeType in self.category:
      if fileEntry['mimeType'].startswith(mimeType):
        return True
    if not self.reverse:
      return fileEntry['mimeType'] in self.mimeTypes
    return fileEntry['mimeType'] not in self.mimeTypes

def initDriveFileAttributes():
  return {DFA_CREATED_TIME: None,
          DFA_MODIFIED_TIME: None,
          DFA_PRESERVE_FILE_TIMES: False,
          DFA_IGNORE_DEFAULT_VISIBILITY: False,
          DFA_KEEP_REVISION_FOREVER: False,
          DFA_URL: None,
          DFA_LOCALFILEPATH: None,
          DFA_LOCALFILENAME: None,
          DFA_LOCALMIMETYPE: None,
          DFA_STRIPNAMEPREFIX: None,
          DFA_REPLACEFILENAME: [],
          DFA_OCRLANGUAGE: None,
          DFA_PARENTID: None,
          DFA_PARENTQUERY: None,
          DFA_ADD_PARENT_IDS: [],
          DFA_ADD_PARENT_NAMES: [],
          DFA_REMOVE_PARENT_IDS: [],
          DFA_REMOVE_PARENT_NAMES: [],
          DFA_SHAREDDRIVE_PARENT: None,
          DFA_SHAREDDRIVE_PARENTID: None,
          DFA_SHAREDDRIVE_PARENTQUERY: None,
          DFA_KWARGS: {},
          DFA_SEARCHARGS: {},
          DFA_USE_CONTENT_AS_INDEXABLE_TEXT: False,
          DFA_TIMESTAMP: False,
          DFA_TIMEFORMAT: None}

DRIVEFILE_PROPERTY_VISIBILITY_CHOICE_MAP = {
  'private': 'appProperties',
  'public': 'properties'
  }

DRIVE_FILE_CONTENT_RESTRICTIONS_CHOICE_MAP = {
  'readonly': 'readOnly',
  'ownerrestricted': 'ownerRestricted',
  }

def getDriveFileProperty(visibility=None):
  key = getString(Cmd.OB_PROPERTY_KEY)
  value = getString(Cmd.OB_PROPERTY_VALUE, minLen=0) or None
  if visibility is None:
    if Cmd.PeekArgumentPresent(list(DRIVEFILE_PROPERTY_VISIBILITY_CHOICE_MAP.keys())):
      visibility = getChoice(DRIVEFILE_PROPERTY_VISIBILITY_CHOICE_MAP, mapChoice=True)
    else:
      visibility = 'properties'
  return {'key': key, 'value': value, 'visibility': visibility}

def getDriveFileParentAttribute(myarg, parameters):
  if myarg == 'parentid':
    parameters[DFA_PARENTID] = getString(Cmd.OB_DRIVE_FOLDER_ID)
  elif myarg == 'parentname':
    parameters[DFA_PARENTQUERY] = MY_NON_TRASHED_FOLDER_NAME.format(getEscapedDriveFolderName())
  elif myarg in {'anyownerparentname', 'sharedparentname'}:
    parameters[DFA_PARENTQUERY] = ANY_NON_TRASHED_FOLDER_NAME.format(getEscapedDriveFolderName())
  elif myarg in {'teamdriveparent', 'shareddriveparent'}:
    parameters[DFA_SHAREDDRIVE_PARENT] = getString(Cmd.OB_SHAREDDRIVE_NAME)
  elif myarg in {'teamdriveparentid', 'shareddriveparentid'}:
    parameters[DFA_SHAREDDRIVE_PARENTID] = getString(Cmd.OB_DRIVE_FOLDER_ID)
  elif myarg in {'teamdriveparentname', 'shareddriveparentname'}:
    parameters[DFA_SHAREDDRIVE_PARENTQUERY] = ANY_NON_TRASHED_FOLDER_NAME.format(getEscapedDriveFolderName())
    parameters[DFA_KWARGS]['corpora'] = CORPORA_ALL_DRIVES
    parameters[DFA_KWARGS]['includeItemsFromAllDrives'] = True
    parameters[DFA_KWARGS]['supportsAllDrives'] = True
  elif myarg == 'enforcesingleparent':
    deprecatedArgument(myarg)
  else:
    return False
  return True

def getDriveFileAddRemoveParentAttribute(myarg, parameters):
  if myarg in {'addparent', 'addparents'}:
    parameters[DFA_ADD_PARENT_IDS].extend(getString(Cmd.OB_DRIVE_FOLDER_ID_LIST).replace(',', ' ').split())
  elif myarg in {'removeparent', 'removeparents'}:
    parameters[DFA_REMOVE_PARENT_IDS].extend(getString(Cmd.OB_DRIVE_FOLDER_ID_LIST).replace(',', ' ').split())
  elif myarg == 'addparentname':
    parameters[DFA_ADD_PARENT_NAMES].append(MY_NON_TRASHED_FOLDER_NAME.format(getEscapedDriveFolderName()))
  elif myarg == 'removeparentname':
    parameters[DFA_REMOVE_PARENT_NAMES].append(MY_NON_TRASHED_FOLDER_NAME.format(getEscapedDriveFolderName()))
  elif myarg in {'addanyownerparentname', 'addsharedparentname'}:
    parameters[DFA_ADD_PARENT_NAMES].append(ANY_NON_TRASHED_FOLDER_NAME.format(getEscapedDriveFolderName()))
  elif myarg in {'removeanyownerparentname', 'removesharedparentname'}:
    parameters[DFA_REMOVE_PARENT_NAMES].append(ANY_NON_TRASHED_FOLDER_NAME.format(getEscapedDriveFolderName()))
  elif myarg == 'enforcesingleparent':
    deprecatedArgument(myarg)
  else:
    return False
  return True

def getDriveFileCopyAttribute(myarg, body, parameters):
  if myarg == 'ignoredefaultvisibility':
    parameters[DFA_IGNORE_DEFAULT_VISIBILITY] = getBoolean()
  elif myarg in {'keeprevisionforever', 'pinned'}:
    parameters[DFA_KEEP_REVISION_FOREVER] = getBoolean()
  elif myarg == 'ocrlanguage':
    parameters[DFA_OCRLANGUAGE] = getLanguageCode(LANGUAGE_CODES_MAP)
  elif myarg == 'description':
    body['description'] = getStringWithCRsNLs()
  elif myarg == 'mimetype':
    body['mimeType'] = getMimeType()
  elif myarg in {'lastviewedbyme', 'lastviewedbyuser', 'lastviewedbymedate', 'lastviewedbymetime'}:
    body['viewedByMeTime'] = getTimeOrDeltaFromNow()
  elif myarg in {'modifieddate', 'modifiedtime'}:
    body['modifiedTime'] = getTimeOrDeltaFromNow()
  elif myarg == 'viewerscancopycontent':
    body['copyRequiresWriterPermission'] = not getBoolean()
  elif myarg in {'copyrequireswriterpermission', 'restrict', 'restricted'}:
    body['copyRequiresWriterPermission'] = getBoolean()
  elif myarg == 'writerscanshare':
    body['writersCanShare'] = getBoolean()
  elif myarg == 'writerscantshare':
    body['writersCanShare'] = not getBoolean()
  elif myarg == 'contentrestrictions':
    while Cmd.PeekArgumentPresent(list(DRIVE_FILE_CONTENT_RESTRICTIONS_CHOICE_MAP.keys())):
      body.setdefault('contentRestrictions', [{}])
      restriction = getChoice(DRIVE_FILE_CONTENT_RESTRICTIONS_CHOICE_MAP, mapChoice=True)
      body['contentRestrictions'][0][restriction] = getBoolean()
      if restriction == 'readOnly':
        if checkArgumentPresent(['reason']):
          if body['contentRestrictions'][0][restriction]:
            body['contentRestrictions'][0]['reason'] = getString(Cmd.OB_STRING, minLen=0)
          else:
            Cmd.Backup()
            usageErrorExit(Msg.REASON_ONLY_VALID_WITH_CONTENTRESTRICTIONS_READONLY_TRUE)
  elif myarg == 'inheritedpermissionsdisabled':
    body['inheritedPermissionsDisabled'] = getBoolean()
  elif myarg == 'property':
    driveprop = getDriveFileProperty()
    body.setdefault(driveprop['visibility'], {})
    body[driveprop['visibility']][driveprop['key']] = driveprop['value']
  elif myarg == 'privateproperty':
    driveprop = getDriveFileProperty('appProperties')
    body.setdefault(driveprop['visibility'], {})
    body[driveprop['visibility']][driveprop['key']] = driveprop['value']
  elif myarg == 'publicproperty':
    driveprop = getDriveFileProperty('properties')
    body.setdefault(driveprop['visibility'], {})
    body[driveprop['visibility']][driveprop['key']] = driveprop['value']
  else:
    return False
  return True

def getDriveFileAttribute(myarg, body, parameters, updateCmd):
  if myarg == 'localfile':
    parameters[DFA_URL] = None
    parameters[DFA_LOCALFILEPATH] = os.path.expanduser(getString(Cmd.OB_FILE_NAME))
    if parameters[DFA_LOCALFILEPATH] != '-':
      try:
        f = open(parameters[DFA_LOCALFILEPATH], 'rb')
        f.close()
        # See http://stackoverflow.com/a/39501288/1709587 for explanation.
        mtime = os.path.getmtime(parameters[DFA_LOCALFILEPATH])
        parameters[DFA_MODIFIED_TIME] = formatLocalSecondsTimestamp(mtime)
        if not updateCmd:
          if platform.system() == 'Windows':
            ctime = os.path.getctime(parameters[DFA_LOCALFILEPATH])
          else:
            stat = os.stat(parameters[DFA_LOCALFILEPATH])
            if hasattr(stat, 'st_birthtime'):
              ctime = stat.st_birthtime
            else:
              # We're probably on Linux. No easy way to get creation dates here,
              # so we'll settle for when its content was last modified.
              ctime = stat.st_mtime
          parameters[DFA_CREATED_TIME] = formatLocalSecondsTimestamp(ctime)
      except IOError as e:
        Cmd.Backup()
        usageErrorExit(f'{parameters[DFA_LOCALFILEPATH]}: {str(e)}')
      parameters[DFA_LOCALFILENAME] = os.path.basename(parameters[DFA_LOCALFILEPATH])
      if not updateCmd:
        body.setdefault('name', parameters[DFA_LOCALFILENAME])
      body['mimeType'] = mimetypes.guess_type(parameters[DFA_LOCALFILEPATH])[0]
      if body['mimeType'] is None:
        body['mimeType'] = 'application/octet-stream'
    else:
      parameters[DFA_LOCALFILENAME] = '-'
      if body.get('mimeType') is None:
        body['mimeType'] = 'application/octet-stream'
    parameters[DFA_LOCALMIMETYPE] = body['mimeType']
  elif myarg == 'url':
    parameters[DFA_LOCALFILEPATH] = None
    parameters[DFA_URL] = getString(Cmd.OB_URL)
  elif myarg =='stripnameprefix':
    parameters[DFA_STRIPNAMEPREFIX] = getString(Cmd.OB_STRING, minLen=0)
  elif myarg == 'replacefilename':
    parameters[DFA_REPLACEFILENAME].append(getREPatternSubstitution(re.IGNORECASE))
  elif myarg in {'convert', 'ocr'}:
    deprecatedArgument(myarg)
    stderrWarningMsg(Msg.USE_MIMETYPE_TO_SPECIFY_GOOGLE_FORMAT)
  elif myarg in DRIVE_LABEL_CHOICE_MAP:
    myarg = DRIVE_LABEL_CHOICE_MAP[myarg]
    body[myarg] = getBoolean()
  elif not updateCmd and myarg in {'createddate', 'createdtime'}:
    body['createdTime'] = getTimeOrDeltaFromNow()
  elif myarg == 'preservefiletimes':
    parameters[DFA_PRESERVE_FILE_TIMES] = getBoolean()
  elif myarg == 'shortcut':
    body['mimeType'] = MIMETYPE_GA_SHORTCUT
    body['shortcutDetails'] = {'targetId': getString(Cmd.OB_DRIVE_FOLDER_ID)}
  elif getDriveFileParentAttribute(myarg, parameters):
    pass
  elif myarg == 'foldercolorrgb':
    body['folderColorRgb'] = getColor()
  elif myarg == 'usecontentasindexabletext':
    parameters[DFA_USE_CONTENT_AS_INDEXABLE_TEXT] = getBoolean()
  elif myarg == 'indexabletext':
    body.setdefault('contentHints', {})
    body['contentHints']['indexableText'] = getString(Cmd.OB_STRING)
  elif myarg == 'securityupdate':
    body['linkShareMetadata'] = {'securityUpdateEnabled': getBoolean(), 'securityUpdateEligible': True}
  elif myarg == 'timestamp':
    parameters[DFA_TIMESTAMP] = getBoolean()
  elif myarg == 'timeformat':
    parameters[DFA_TIMEFORMAT] = getString(Cmd.OB_STRING, minLen=0)
  elif getDriveFileCopyAttribute(myarg, body, parameters):
    pass
  else:
    unknownArgumentExit()

def setPreservedFileTimes(body, parameters, updateCmd):
  body['modifiedTime'] = parameters[DFA_MODIFIED_TIME]
  if not updateCmd:
    body['createdTime'] = parameters[DFA_CREATED_TIME]

def getMediaBody(parameters):
  if parameters[DFA_URL]:
    try:
      status, c = getHttpObj(timeout=10).request(parameters[DFA_URL])
      if status['status'] != '200':
        entityActionFailedExit([Ent.URL, parameters[DFA_URL]], Msg.URL_ERROR.format(status['status']))
      parameters[DFA_LOCALMIMETYPE] = status['content-type']
      return googleapiclient.http.MediaIoBaseUpload(io.BytesIO(c), mimetype=status['content-type'], resumable=True)
    except (IOError, httplib2.error.ServerNotFoundError) as e:
      systemErrorExit(FILE_ERROR_RC, fileErrorMessage(parameters[DFA_URL], str(e), entityType=Ent.URL))
  else:
    try:
      if parameters[DFA_LOCALFILEPATH] != '-':
        media_body = googleapiclient.http.MediaFileUpload(parameters[DFA_LOCALFILEPATH], mimetype=parameters[DFA_LOCALMIMETYPE], resumable=True)
      else:
        media_body = googleapiclient.http.MediaIoBaseUpload(io.BytesIO(sys.stdin.buffer.read()), mimetype=parameters[DFA_LOCALMIMETYPE], resumable=True)
      if media_body.size() == 0:
        media_body = None
      return media_body
    except IOError as e:
      systemErrorExit(FILE_ERROR_RC, fileErrorMessage(parameters[DFA_LOCALFILEPATH], str(e)))

DRIVE_ACTIVITY_ACTION_MAP = {
  'comment': 'COMMENT',
  'create': 'CREATE',
  'delete': 'DELETE',
  'dlpchange': 'DLP_CHANGE',
  'edit': 'EDIT',
  'emptytrash': 'DELETE',
  'move': 'MOVE',
  'permissionchange': 'PERMISSION_CHANGE',
  'reference': 'REFERENCE',
  'rename': 'RENAME',
  'restore': 'RESTORE',
  'settingschange': 'SETTINGS_CHANGE',
  'trash': 'DELETE',
  'untrash': 'RESTORE',
  'upload': 'CREATE',
  }

CONSOLIDATION_GROUPING_STRATEGY_CHOICE_MAP = {'driveui': 'legacy', 'legacy': 'legacy', 'none': 'none'}

# gam <UserTypeEntity> print driveactivity [todrive <ToDriveAttribute>*]
#	[(fileid <DriveFileID>) | (folderid <DriveFolderID>) |
#	 (drivefilename <DriveFileName>) | (drivefoldername <DriveFolderName>) | (query <QueryDriveFile>)]
#	[([start <Date>|<Time>] [end <Date>|<Time>])|(range <Date>|<Time> <Date>|<Time>)|
#	 yesterday|today|thismonth|(previousmonths <Integer>)]
#	[action|actions [not] <DriveActivityActionList>]
#	[consolidationstrategy legacy|none]
#	[idmapfile <CSVFileInput> endcsv]
#	[stripcrsfromname] [formatjson [quotechar <Character>]]
def printDriveActivity(users):
  def _getUserInfo(userId):
    if userId.startswith('people/'):
      userId = userId[7:]
    entry = userInfo.get(userId)
    if entry is None:
      try:
        result = callGAPI(cd.users(), 'get',
                          throwReasons=GAPI.USER_GET_THROW_REASONS+[GAPI.INVALID_INPUT],
                          userKey=userId, fields='primaryEmail,name.fullName')
        entry = (result['primaryEmail'], result['name']['fullName'])
      except (GAPI.userNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden,
              GAPI.badRequest, GAPI.backendError, GAPI.systemError, GAPI.invalidInput):
        entry = (f'uid:{userId}', UNKNOWN)
      userInfo[userId] = entry
    return entry

  def _updateKnownUsers(structure):
    if isinstance(structure, list):
      for v in structure:
        if isinstance(v, (dict, list)):
          _updateKnownUsers(v)
    elif isinstance(structure, dict):
      for k, v in sorted(iter(structure.items())):
        if k != 'knownUser':
          if isinstance(v, (dict, list)):
            _updateKnownUsers(v)
        else:
          entry = _getUserInfo(v['personName'])
          v['emailAddress'] = entry[0]
          v['personName'] = entry[1]
          break

  cd = buildGAPIObject(API.DIRECTORY)
  startEndTime = StartEndTime()
  baseFileList = []
  query = ''
  activityFilter = ''
  actions = set()
  strategy = 'none'
  negativeAction = stripCRsFromName = False
  checkArgumentPresent(['v2'])
  csvPF = CSVPrintFile([f'user{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}name',
                        f'user{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}emailAddress',
                        f'target{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}id',
                        f'target{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}name',
                        f'target{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}mimeType',
                        'eventTime'],
                       'sortall')
  FJQC = FormatJSONQuoteChar(csvPF)
  userInfo = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'fileid':
      baseFileList.append({'id': getString(Cmd.OB_DRIVE_FILE_ID), 'mimeType': MIMETYPE_GA_DOCUMENT})
    elif myarg == 'folderid':
      baseFileList.append({'id': getString(Cmd.OB_DRIVE_FOLDER_ID), 'mimeType': MIMETYPE_GA_FOLDER})
    elif myarg == 'drivefilename':
      query = f"mimeType != '{MIMETYPE_GA_FOLDER}' and name = '{getEscapedDriveFileName()}'"
    elif myarg == 'drivefoldername':
      query = f"mimeType = '{MIMETYPE_GA_FOLDER}' and name = '{getEscapedDriveFileName()}'"
    elif myarg == 'query':
      query = _mapDrive2QueryToDrive3(getString(Cmd.OB_QUERY))
    elif myarg in {'start', 'starttime', 'end', 'endtime', 'yesterday', 'today', 'range', 'thismonth', 'previousmonths'}:
      startEndTime.Get(myarg)
    elif myarg in {'action', 'actions'}:
      negativeAction = checkArgumentPresent('not')
      for action in _getFieldsList():
        if action in DRIVE_ACTIVITY_ACTION_MAP:
          mappedAction = DRIVE_ACTIVITY_ACTION_MAP[action]
          if mappedAction:
            actions.add(mappedAction)
        else:
          invalidChoiceExit(action, DRIVE_ACTIVITY_ACTION_MAP, True)
    elif myarg in {'allevents', 'combinedevents', 'singleevents'}:
      pass
    elif myarg in {'consolidationstrategy', 'groupingstrategy'}:
      strategy = getChoice(CONSOLIDATION_GROUPING_STRATEGY_CHOICE_MAP, mapChoice=True)
    elif myarg == 'idmapfile':
      f, csvFile, _ = openCSVFileReader(getString(Cmd.OB_FILE_NAME))
      for row in csvFile:
        userInfo[row['id']] = (row['primaryEmail'], row.get('name.fullName', UNKNOWN))
      closeFile(f)
    elif myarg == 'stripcrsfromname':
      stripCRsFromName = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if not baseFileList and not query:
    baseFileList = [{'id': ROOT, 'mimeType': MIMETYPE_GA_FOLDER}]
  if startEndTime.startTime:
    if activityFilter:
      activityFilter += ' AND '
    activityFilter += f'time >= "{startEndTime.startTime}"'
  if startEndTime.endTime:
    if activityFilter:
      activityFilter += ' AND '
    activityFilter += f'time <= "{startEndTime.endTime}"'
  if actions:
    if activityFilter:
      activityFilter += ' AND '
    activityFilter += f'{"-" if negativeAction else ""}detail.action_detail_case:({" ".join(actions)})'
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, activity = buildGAPIServiceObject(API.DRIVEACTIVITY, user, i, count)
    if not activity:
      continue
    _, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
    if not drive:
      continue
    fileList = baseFileList[:]
    if query:
      if GC.Values[GC.SHOW_GETTINGS]:
        printGettingAllEntityItemsForWhom(Ent.DRIVE_FILE_OR_FOLDER, user, i, count, query=query)
      try:
        fileList.extend(callGAPIpages(drive.files(), 'list', 'files',
                                      pageMessage=getPageMessageForWhom(),
                                      throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID,
                                                                                  GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND],
                                      retryReasons=[GAPI.UNKNOWN_ERROR],
                                      q=query, fields='nextPageToken,files(id,mimeType)', pageSize=GC.Values[GC.DRIVE_MAX_RESULTS]))
        if not fileList:
          entityActionNotPerformedWarning([Ent.USER, user, Ent.DRIVE_FILE, None], emptyQuery(query, Ent.DRIVE_FILE_OR_FOLDER), i, count)
          continue
      except (GAPI.invalidQuery, GAPI.invalid, GAPI.badRequest):
        entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE, None], invalidQuery(query), i, count)
        break
      except GAPI.fileNotFound:
        printGotEntityItemsForWhom(0)
        continue
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        continue
    for f_file in fileList:
      fileId = f_file['id']
      entityType = Ent.DRIVE_FOLDER_ID if f_file['mimeType'] == MIMETYPE_GA_FOLDER else Ent.DRIVE_FILE_ID
      if entityType == Ent.DRIVE_FILE_ID:
        drive_key = 'itemName'
      else:
        drive_key = 'ancestorName'
      qualifier = f' for {Ent.Singular(entityType)}: {fileId}'
      printGettingAllEntityItemsForWhom(Ent.ACTIVITY, user, i, count, qualifier=qualifier)
      pageMessage = getPageMessageForWhom()
      kwargs = {
        'consolidationStrategy': {strategy: {}},
        'pageSize': GC.Values[GC.ACTIVITY_MAX_RESULTS],
        'pageToken': None,
        drive_key: f'items/{fileId}',
        'filter': activityFilter}
      try:
        feed = yieldGAPIpages(activity.activity(), 'query', 'activities',
                              pageMessage=pageMessage,
                              throwReasons=GAPI.ACTIVITY_THROW_REASONS,
                              pageArgsInBody=True,
                              fields='nextPageToken,activities', body=kwargs)
        for activities in feed:
          for activityEvent in activities:
            eventRow = {}
            actors = activityEvent.get('actors', [])
            if actors:
              userId = actors[0].get('user', {}).get('knownUser', {}).get('personName', '')
              if not userId:
                userId = actors[0].get('impersonation', {}).get('impersonatedUser', {}).get('knownUser', {}).get('personName', '')
              if userId:
                entry = _getUserInfo(userId)
                eventRow[f'user{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}name'] = entry[1]
                eventRow[f'user{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}emailAddress'] = entry[0]
            targets = activityEvent.get('targets', [])
            if targets:
              driveItem = targets[0].get('driveItem')
              if driveItem:
                eventRow[f'target{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}id'] = driveItem['name'][6:]
                if stripCRsFromName:
                  driveItem['title'] = _stripControlCharsFromName(driveItem['title'])
                eventRow[f'target{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}name'] = driveItem['title']
                eventRow[f'target{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}mimeType'] = driveItem['mimeType']
              else:
                sharedDrive = targets[0].get('teamDrive')
                if sharedDrive:
                  eventRow[f'target{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}id'] = sharedDrive['name'][11:]
                  if stripCRsFromName:
                    sharedDrive['title'] = _stripControlCharsFromName(sharedDrive['title'])
                  eventRow[f'target{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}name'] = sharedDrive['title']
            if 'timestamp' in activityEvent:
              eventRow['eventTime'] = formatLocalTime(activityEvent['timestamp'])
            elif 'timeRange' in activityEvent:
              timeRange = activityEvent['timeRange']
              eventRow['eventTime'] = f'{formatLocalTime(timeRange["startTime"])}-{formatLocalTime(timeRange["endTime"])}'
            _updateKnownUsers(activityEvent)
            if not FJQC.formatJSON:
              activityEvent.pop('timestamp', None)
              activityEvent.pop('timeRange', None)
              flattenJSON(activityEvent, flattened=eventRow)
              csvPF.WriteRowTitles(eventRow)
            else:
              checkRow = eventRow.copy()
              flattenJSON(activityEvent, flattened=checkRow)
              if csvPF.CheckRowTitles(checkRow):
                eventRow['JSON'] = json.dumps(cleanJSON(activityEvent), ensure_ascii=False, sort_keys=True)
                csvPF.WriteRowNoFilter(eventRow)
      except GAPI.badRequest as e:
        entityActionFailedWarning([Ent.USER, user, entityType, fileId], str(e), i, count)
        continue
      except GAPI.serviceNotAvailable as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        continue
  csvPF.writeCSVfile('Drive Activity')

DRIVESETTINGS_FIELDS_CHOICE_MAP = {
  'appinstalled': 'appInstalled',
  'exportformats': 'exportFormats',
  'foldercolorpalette': 'folderColorPalette',
  'importformats': 'importFormats',
  'largestchangeid': 'largestChangeId',
  'limit': 'limit',
  'maximportsizes': 'maxImportSizes',
  'maxuploadsize': 'maxUploadSize',
  'name': 'name',
  'permissionid': 'permissionId',
  'rootfolderid': 'rootFolderId',
  'drivethemes': 'driveThemes',
  'shareddrivethemes': 'driveThemes',
  'teamdrivethemes': 'driveThemes',
  'usage': 'usage',
  'usageindrive': 'usageInDrive',
  'usageindrivetrash': 'usageInDriveTrash',
  }

DRIVESETTINGS_SCALAR_FIELDS = [
  'name',
  'appInstalled',
  'largestChangeId',
  'limit',
  'maxUploadSize',
  'permissionId',
  'rootFolderId',
  'usage',
  'usageInDrive',
  'usageInDriveTrash',
  ]

DRIVESETTINGS_USAGE_BYTES_FIELDS = {
  'usage': 'usageBytes',
  'usageInDrive': 'usageInDriveBytes',
  'usageInDriveTrash': 'usageInDriveTrashBytes',
  }

def _showSharedDriveThemeSettings(themes):
  Ind.Increment()
  for theme in themes:
    printKeyValueList(['id', theme['id']])
    Ind.Increment()
    printKeyValueList(['backgroundImageLink', theme['backgroundImageLink']])
    printKeyValueList(['colorRgb', theme['colorRgb']])
    Ind.Decrement()
  Ind.Decrement()

# gam <UserTypeEntity> print drivesettings [todrive <ToDriveAttribute>*]
#	[allfields|<DriveSettingsFieldName>*|(fields <DriveSettingsFieldNameList>)]
#	[delimiter <Character>] [showusagebytes]
# gam <UserTypeEntity> show drivesettings
#	[allfields|<DriveSettingsFieldName>*|(fields <DriveSettingsFieldNameList>)]
#	[delimiter <Character>] [showusagebytes]
def printShowDriveSettings(users):
  def _showFormats(title):
    if title in fieldsList and title in feed:
      printKeyValueList([title, None])
      Ind.Increment()
      for item, value in sorted(iter(feed[title].items())):
        printKeyValueList([item, delimiter.join(value)])
      Ind.Decrement()

  def _showSetting(title):
    if title in fieldsList and title in feed:
      if not isinstance(feed[title], list):
        printKeyValueList([title, feed[title]])
      else:
        printKeyValueList([title, delimiter.join(feed[title])])

  def _addFormats(row, title):
    if title in fieldsList and title in feed:
      jcount = len(feed[title])
      row[title] = jcount
      j = 0
      for item, value in sorted(iter(feed[title].items())):
        row[f'{title}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{j:02d}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{item}'] = delimiter.join(value)
        j += 1

  def _addSetting(row, title):
    if title in fieldsList and title in feed:
      if not isinstance(feed[title], list):
        row[title] = feed[title]
      else:
        row[title] = delimiter.join(feed[title])

  csvPF = CSVPrintFile(['email'], ['email']+DRIVESETTINGS_SCALAR_FIELDS) if Act.csvFormat() else None
  fieldsList = []
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  showUsageBytes = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    elif myarg == 'allfields':
      fieldsList.extend(DRIVESETTINGS_FIELDS_CHOICE_MAP.values())
    elif getFieldsList(myarg, DRIVESETTINGS_FIELDS_CHOICE_MAP, fieldsList):
      pass
    elif myarg == 'showusagebytes':
      showUsageBytes = True
    else:
      unknownArgumentExit()
  if not fieldsList:
    fieldsList = DRIVESETTINGS_SCALAR_FIELDS[:]
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
    if not drive:
      continue
    if csvPF:
      printGettingEntityItemForWhom(Ent.DRIVE_SETTINGS, user, i, count)
    try:
      feed = callGAPI(drive.about(), 'get',
                      throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                      fields='*')
      feed['name'] = feed['user']['displayName']
      feed['maxUploadSize'] = formatFileSize(int(feed['maxUploadSize']))
      feed['permissionId'] = feed['user']['permissionId']
      if 'limit' in feed['storageQuota']:
        feed['limit'] = formatFileSize(int(feed['storageQuota']['limit']))
      else:
        feed['limit'] = 'UNLIMITED'
      for setting in ['usage', 'usageInDrive', 'usageInDriveTrash']:
        uval = int(feed['storageQuota'].get(setting, '0'))
        feed[setting] = formatFileSize(uval)
        if showUsageBytes:
          feed[DRIVESETTINGS_USAGE_BYTES_FIELDS[setting]] = uval
      if 'rootFolderId' in fieldsList:
        feed['rootFolderId'] = callGAPI(drive.files(), 'get',
                                        throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                                        fileId=ROOT, fields='id')['id']
      if 'largestChangeId' in fieldsList:
        feed['largestChangeId'] = callGAPI(drive.changes(), 'getStartPageToken',
                                           throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                                           fields='startPageToken')['startPageToken']
      if not csvPF:
        entityPerformActionNumItems([Ent.USER, user], 1, Ent.DRIVE_SETTINGS, i, count)
        Ind.Increment()
        for setting in DRIVESETTINGS_SCALAR_FIELDS:
          _showSetting(setting)
        if showUsageBytes:
          for title, setting in DRIVESETTINGS_USAGE_BYTES_FIELDS.items():
            if title in fieldsList and setting in feed:
              printKeyValueList([setting, feed[setting]])
        _showSetting('folderColorPalette')
        _showFormats('exportFormats')
        _showFormats('importFormats')
        if 'maxImportSizes' in fieldsList and 'maxImportSizes' in fieldsList:
          printKeyValueList(['maxImportSizes', None])
          Ind.Increment()
          for setting, value in iter(feed['maxImportSizes'].items()):
            printKeyValueList([setting, formatFileSize(int(value))])
          Ind.Decrement()
        if 'driveThemes' in fieldsList and 'driveThemes' in feed:
          printKeyValueList(['driveThemes', None])
          _showSharedDriveThemeSettings(feed['driveThemes'])
        Ind.Decrement()
      else:
        row = {'email': user}
        for setting in DRIVESETTINGS_SCALAR_FIELDS:
          _addSetting(row, setting)
        if showUsageBytes:
          for title, setting in DRIVESETTINGS_USAGE_BYTES_FIELDS.items():
            if title in fieldsList and setting in feed:
              row[setting] = feed[setting]
        _addSetting(row, 'folderColorPalette')
        _addFormats(row, 'exportFormats')
        _addFormats(row, 'importFormats')
        if 'maxImportSizes' in fieldsList and 'maxImportSizes' in fieldsList:
          jcount = len(feed['maxImportSizes'])
          row['maxImportSizes'] = jcount
          j = 0
          for setting, value in iter(feed['maxImportSizes'].items()):
            row[f'maxImportSizes{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{j}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{setting}'] = formatFileSize(int(value))
            j += 1
        if 'driveThemes' in fieldsList and 'driveThemes' in feed:
          jcount = len(feed['driveThemes'])
          row['driveThemes'] = jcount
          j = 0
          for setting in feed['driveThemes']:
            row[f'driveThemes{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{j:02d}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}id'] = setting['id']
            row[f'driveThemes{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{j:02d}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}backgroundImageLink'] = setting['backgroundImageLink']
            row[f'driveThemes{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{j:02d}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}colorRgb'] = setting['colorRgb']
            j += 1
        csvPF.WriteRowTitles(row)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
  if csvPF:
    csvPF.writeCSVfile('User Drive Settings')

# gam <UserTypeEntity> show shareddrivethemes
def showSharedDriveThemes(users):
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
    if not drive:
      continue
    try:
      themes = callGAPIitems(drive.about(), 'get', 'driveThemes',
                             throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                             fields='driveThemes')
      jcount = len(themes)
      entityPerformActionNumItems([Ent.USER, user], jcount, Ent.SHAREDDRIVE_THEME, i, count)
      _showSharedDriveThemeSettings(themes)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)

def doShowSharedDriveThemes():
  showSharedDriveThemes([_getAdminEmail()])

def initFilePathInfo(delimiter):
  return {'ids': {}, 'allPaths': {}, 'localPaths': None, 'delimiter': delimiter}

def getFilePaths(drive, fileTree, initialResult, filePathInfo, addParentsToTree=False,
                 fullpath=False, showDepth=False, folderPathOnly=False):
  def _getParentName(result):
    if (result['mimeType'] == MIMETYPE_GA_FOLDER) and result.get('driveId') and (result['name'] == TEAM_DRIVE):
      parentName = _getSharedDriveNameFromId(drive, result['driveId'])
      if parentName != TEAM_DRIVE:
        return f'{SHARED_DRIVES}{filePathInfo["delimiter"]}{parentName}'
    return result['name']

  def _followParent(paths, parentId):
    result = None
    paths.setdefault(parentId, {})
    if fileTree:
      parentEntry = fileTree.get(parentId)
      if not parentEntry:
        if not addParentsToTree:
          return
        parentEntry = fileTree[parentId] = {'info': {'id': parentId, 'name': parentId, 'mimeType': MIMETYPE_GA_FOLDER}, 'children': []}
      if parentEntry['info']['name'] == parentEntry['info']['id'] and parentEntry['info']['id'] not in {ORPHANS, SHARED_WITHME, SHARED_DRIVES}:
        try:
          result = callGAPI(drive.files(), 'get',
                            throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                            fileId=parentId, fields='name,parents,mimeType,driveId', supportsAllDrives=True)
          parentEntry['info']['name'] = _getParentName(result)
          parentEntry['info']['parents'] = result.get('parents', [])
        except (GAPI.fileNotFound, GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy):
          pass
      filePathInfo['ids'][parentId] = parentEntry['info']['name']
      parents = parentEntry['info'].get('parents', [])
    else:
      try:
        result = callGAPI(drive.files(), 'get',
                          throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                          fileId=parentId, fields='name,parents,mimeType,driveId', supportsAllDrives=True)
        filePathInfo['ids'][parentId] = _getParentName(result)
        parents = result.get('parents', [])
      except (GAPI.fileNotFound, GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy):
        return
    for lparentId in parents:
      if lparentId not in filePathInfo['allPaths']:
        _followParent(paths[parentId], lparentId)
        filePathInfo['allPaths'][lparentId] = paths[parentId][lparentId]
      else:
        paths[parentId][lparentId] = filePathInfo['allPaths'][lparentId]

  def _makeFilePaths(localPaths, fplist, filePaths, name, maxDepth):
    for k, v in iter(localPaths.items()):
      fplist.append(filePathInfo['ids'].get(k, ''))
      if not v:
        fp = fplist[:]
        if showDepth:
          depth = len(fp)
          if depth > maxDepth:
            maxDepth = depth-1
        fp.reverse()
        if initialMimeType == MIMETYPE_GA_FOLDER or not folderPathOnly:
          fp.append(name)
        filePaths.append(filePathInfo['delimiter'].join(fp))
      else:
        maxDepth = _makeFilePaths(v, fplist, filePaths, name, maxDepth)
      fplist.pop()
    return maxDepth

  filePaths = []
  parents = initialResult.get('parents', [])
  initialMimeType = initialResult['mimeType']
  if parents:
    filePathInfo['localPaths'] = {}
    for parentId in parents:
      if parentId not in filePathInfo['allPaths']:
        _followParent(filePathInfo['allPaths'], parentId)
      filePathInfo['localPaths'][parentId] = filePathInfo['allPaths'][parentId]
    fplist = []
    maxDepth = _makeFilePaths(filePathInfo['localPaths'], fplist, filePaths, initialResult['name'], -1)
  else:
    if (fullpath and initialMimeType == MIMETYPE_GA_FOLDER and
        ((initialResult['name'] == MY_DRIVE) or
         (initialResult.get('driveId') and initialResult['name'].startswith(SHARED_DRIVES)))):
      filePaths.append(initialResult['name'])
    maxDepth = 0
  return (_getEntityMimeType(initialResult), filePaths, maxDepth)

DRIVEFILE_ORDERBY_CHOICE_MAP = {
  'createddate': 'createdTime',
  'createdtime': 'createdTime',
  'folder': 'folder',
  'lastviewedbyme': 'viewedByMeTime',
  'lastviewedbymedate': 'viewedByMeTime',
  'lastviewedbymetime': 'viewedByMeTime',
  'lastviewedbyuser': 'viewedByMeTime',
  'modifiedbyme': 'modifiedByMeTime',
  'modifiedbymedate': 'modifiedByMeTime',
  'modifiedbymetime': 'modifiedByMeTime',
  'modifiedbyuser': 'modifiedByMeTime',
  'modifieddate': 'modifiedTime',
  'modifiedtime': 'modifiedTime',
  'name': 'name',
  'namenatural': 'name_natural',
  'quotabytesused': 'quotaBytesUsed',
  'quotaused': 'quotaBytesUsed',
  'recency': 'recency',
  'sharedwithmedate': 'sharedWithMeTime',
  'sharedwithmetime': 'sharedWithMeTime',
  'starred': 'starred',
  'title': 'name',
  'titlenatural': 'name_natural',
  'viewedbymedate': 'viewedByMeTime',
  'viewedbymetime': 'viewedByMeTime',
  }

def _mapDrive3TitlesToDrive2(titles, drive3TitlesMap):
  for i, title in enumerate(titles):
    if title in drive3TitlesMap:
      titles[i] = drive3TitlesMap[title]

def _mapDriveUser(field):
  if 'me' in field:
    field['isAuthenticatedUser'] = field.pop('me')
  if 'photoLink' in field:
    field['picture'] = {'url': field.pop('photoLink')}

def _mapDrivePermissionNames(permission):
  if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES]:
    if 'displayName' in permission:
      permission['name'] = permission.pop('displayName')
    if 'expirationTime' in permission:
      permission['expirationDate'] = formatLocalTime(permission.pop('expirationTime'))
    if 'allowFileDiscovery' in permission:
      permission['withLink'] = not permission.pop('allowFileDiscovery')
  emailAddress = permission.get('emailAddress')
  if emailAddress:
    _, permission['domain'] = splitEmailAddress(emailAddress)

def _mapDriveParents(f_file, parentsSubFields):
  if 'parents' in f_file:
    parents = f_file.pop('parents')
    if len(parents) == 1 and parents[0] == ORPHANS:
      return
    f_file['parents'] = []
    for parentId in parents:
      parent = {}
      if parentsSubFields['id']:
        parent['id'] = parentId
      if parentsSubFields['isRoot']:
        parent['isRoot'] = parentId == parentsSubFields['rootFolderId']
      f_file['parents'].append(parent)

def _mapDriveProperties(f_file):
  appProperties = f_file.pop('appProperties', [])
  properties = f_file.pop('properties', [])
  if appProperties:
    f_file.setdefault('properties', [])
    for key, value in sorted(iter(appProperties.items())):
      f_file['properties'].append({'key': key, 'value': value, 'visibility': 'PRIVATE'})
  if properties:
    f_file.setdefault('properties', [])
    for key, value in sorted(iter(properties.items())):
      f_file['properties'].append({'key': key, 'value': value, 'visibility': 'PUBLIC'})

def _mapDriveFieldNames(f_file, user, parentsSubFields, mapToLabels):
  if mapToLabels:
    for attrib, v2attrib in iter(API.DRIVE3_TO_DRIVE2_LABELS_MAP.items()):
      if attrib in f_file:
        f_file.setdefault('labels', {})
        f_file['labels'][v2attrib] = f_file.pop(attrib)
  for attrib, v2attrib in iter(API.DRIVE3_TO_DRIVE2_FILES_FIELDS_MAP.items()):
    if attrib in f_file:
      f_file[v2attrib] = f_file.pop(attrib)
  capabilities = f_file.get('capabilities')
  if capabilities:
    for attrib, v2attrib in iter(API.DRIVE3_TO_DRIVE2_CAPABILITIES_FIELDS_MAP.items()):
      if attrib in capabilities:
        f_file[v2attrib] = capabilities[attrib]
    for attrib, v2attrib in iter(API.DRIVE3_TO_DRIVE2_CAPABILITIES_NAMES_MAP.items()):
      if attrib in capabilities:
        capabilities[v2attrib] = capabilities.pop(attrib)
  if 'spaces' in f_file:
    f_file['appDataContents'] = 'appDataFolder' in f_file['spaces']
  if 'lastModifyingUser' in f_file:
    if 'displayName' in f_file['lastModifyingUser']:
      f_file['lastModifyingUserName'] = f_file['lastModifyingUser']['displayName']
    _mapDriveUser(f_file['lastModifyingUser'])
  if 'owners' in f_file:
    for owner in f_file['owners']:
      _mapDriveUser(owner)
      if 'displayName' in owner:
        f_file.setdefault('ownerNames', [])
        f_file['ownerNames'].append(owner['displayName'])
  _mapDriveUser(f_file.get('sharingUser', {}))
  _mapDriveParents(f_file, parentsSubFields)
  _mapDriveProperties(f_file)
  for permission in f_file.get('permissions', []):
    if (permission.get('type') == 'user') and (permission.get('emailAddress', '').lower() == user) and ('role' in permission):
      f_file['userPermission'] = {'id': 'me', 'role': permission['role'], 'type': permission['type']}
    _mapDrivePermissionNames(permission)

def _mapDriveRevisionNames(revision):
  for attrib, v2attrib in iter(API.DRIVE3_TO_DRIVE2_REVISIONS_FIELDS_MAP.items()):
    if attrib in revision:
      revision[v2attrib] = revision.pop(attrib)
  if 'lastModifyingUser' in revision:
    if 'displayName' in revision['lastModifyingUser']:
      revision['lastModifyingUserName'] = revision['lastModifyingUser']['displayName']
    _mapDriveUser(revision['lastModifyingUser'])

DRIVEFILE_BASIC_PERMISSION_FIELDS = [
  'displayName', 'id', 'emailAddress', 'domain', 'role', 'type',
  'allowFileDiscovery', 'expirationTime', 'deleted', 'permissionDetails' #permissionDetails must be last
  ]

DRIVE_FIELDS_CHOICE_MAP = {
  'alternatelink': 'webViewLink',
  'appdatacontents': 'spaces',
  'appproperties': 'appProperties',
  'basicpermissions': ['permissions.displayName', 'permissions.id', 'permissions.emailAddress', 'permissions.domain',
                       'permissions.role', 'permissions.type', 'permissions.allowFileDiscovery',
                       'permissions.expirationTime', 'permissions.deleted'],
  'cancomment': 'capabilities.canComment',
  'canreadrevisions': 'capabilities.canReadRevisions',
  'capabilities': 'capabilities',
  'contenthints': 'contentHints',
  'contentrestrictions': 'contentRestrictions',
  'copyable': 'capabilities.canCopy',
  'copyrequireswriterpermission': 'copyRequiresWriterPermission',
  'createddate': 'createdTime',
  'createdtime': 'createdTime',
  'description': 'description',
  'driveid': 'driveId',
  'drivename': 'driveId',
  'editable': 'capabilities.canEdit',
  'explicitlytrashed': 'explicitlyTrashed',
  'exportlinks': 'exportLinks',
  'fileextension': 'fileExtension',
  'filesize': 'size',
  'foldercolorrgb': 'folderColorRgb',
  'fullfileextension': 'fullFileExtension',
  'hasaugmentedpermissions': 'hasAugmentedPermissions',
  'hasthumbnail': 'hasThumbnail',
  'headrevisionid': 'headRevisionId',
  'iconlink': 'iconLink',
  'id': 'id',
  'imagemediametadata': 'imageMediaMetadata',
  'inheritedpermissionsdisabled': 'inheritedPermissionsDisabled',
  'isappauthorized': 'isAppAuthorized',
  'labelinfo': 'labelInfo',
  'labels': ['modifiedByMe', 'copyRequiresWriterPermission', 'starred', 'trashed', 'viewedByMe'],
  'lastmodifyinguser': 'lastModifyingUser',
  'lastmodifyingusername': 'lastModifyingUser.displayName',
  'lastviewedbyme': 'viewedByMeTime',
  'lastviewedbymedate': 'viewedByMeTime',
  'lastviewedbymetime': 'viewedByMeTime',
  'lastviewedbyuser': 'viewedByMeTime',
  'linksharemetadata': 'linkShareMetadata',
  'md5': 'md5Checksum',
  'md5checksum': 'md5Checksum',
  'md5sum': 'md5Checksum',
  'mime': 'mimeType',
  'mimetype': 'mimeType',
  'modifiedbymedate': 'modifiedByMeTime',
  'modifiedbymetime': 'modifiedByMeTime',
  'modifiedbyuser': 'modifiedByMeTime',
  'modifieddate': 'modifiedTime',
  'modifiedtime': 'modifiedTime',
  'name': 'name',
  'originalfilename': 'originalFilename',
  'ownedbyme': 'ownedByMe',
  'ownernames': 'owners.displayName',
  'owners': 'owners',
  'parents': 'parents',
  'permissiondetails': 'permissions.permissionDetails',
  'permissionids': 'permissionIds',
  'permissions': 'permissions',
  'properties': 'properties',
  'quotabytesused': 'quotaBytesUsed',
  'quotaused': 'quotaBytesUsed',
  'resourcekey': 'resourceKey',
  'shareable': 'capabilities.canShare',
  'shared': 'shared',
  'shareddriveid': 'driveId',
  'shareddrivename': 'driveId',
  'sharedwithmedate': 'sharedWithMeTime',
  'sharedwithmetime': 'sharedWithMeTime',
  'sharinguser': 'sharingUser',
  'shortcutdetails': 'shortcutDetails',
  'sha1checksum': 'sha1Checksum',
  'sha256checksum': 'sha256Checksum',
  'size': 'size',
  'spaces': 'spaces',
  'teamdriveid': 'driveId',
  'teamdrivename': 'driveId',
  'thumbnaillink': 'thumbnailLink',
  'thumbnailversion': 'thumbnailVersion',
  'title': 'name',
  'trasheddate': 'trashedTime',
  'trashedtime': 'trashedTime',
  'trashinguser': 'trashingUser',
  'userpermission': 'ownedByMe,capabilities.canEdit,capabilities.canComment',
  'version': 'version',
  'videomediametadata': 'videoMediaMetadata',
  'viewedbymedate': 'viewedByMeTime',
  'viewedbymetime': 'viewedByMeTime',
  'viewerscancopycontent': 'copyRequiresWriterPermission',
  'webcontentlink': 'webContentLink',
  'webviewlink': 'webViewLink',
  'writerscanshare': 'writersCanShare',
  }

DRIVE_CAPABILITIES_SUBFIELDS_CHOICE_MAP = {
  'canacceptownership': 'canAcceptOwnership',
  'canaddchildren': 'canAddChildren',
  'canaddfolderfromanotherdrive': 'canAddFolderFromAnotherDrive',
  'canaddmydriveparent': 'canAddMyDriveParent',
  'canchangecopyrequireswriterpermission': 'canChangeCopyRequiresWriterPermission',
  'canchangecopyrequireswriterpermissionrestriction': 'canChangeCopyRequiresWriterPermissionRestriction',
  'canchangedomainusersonlyrestriction': 'canChangeDomainUsersOnlyRestriction',
  'canchangedrivebackground': 'canChangeDriveBackground',
  'canchangedrivemembersonlyrestriction': 'canChangeDriveMembersOnlyRestriction',
  'canchangesecurityupdateenabled': 'canChangeSecurityUpdateEnabled',
  'canchangesharingfoldersrequiresorganizerpermissionrestriction': 'canChangeSharingFoldersRequiresOrganizerPermissionRestriction',
  'canchangeviewerscancopycontent': 'canChangeViewersCanCopyContent',
  'cancomment': 'canComment',
  'cancopy': 'canCopy',
  'candelete': 'canDelete',
  'candeletechildren': 'canDeleteChildren',
  'candeletedrive': 'canDeleteDrive',
  'candisableinheritedpermissions': 'canDisableInheritedPermissions',
  'candownload': 'canDownload',
  'canedit': 'canEdit',
  'canenableinheritedpermissions': 'canEnableInheritedPermissions',
  'canlistchildren': 'canListChildren',
  'canmanagemembers': 'canManageMembers',
  'canmodifycontent': 'canModifyContent',
  'canmodifycontentrestriction': 'canModifyContentRestriction',
  'canmodifyeditorcontentrestriction': 'canModifyEditorContentRestriction',
  'canmodifylabels': 'canModifyLabels',
  'canmodifyownercontentrestriction': 'canModifyOwnerContentRestriction',
  'canmovechildrenoutofdrive': 'canMoveChildrenOutOfDrive',
  'canmovechildrenoutofteamdrive': 'canMoveChildrenOutOfDrive',
  'canmovechildrenwithindrive': 'canMoveChildrenWithinDrive',
  'canmovechildrenwithinteamdrive': 'canMoveChildrenWithinDrive',
  'canmoveitemintodrive':  'canMoveItemIntoDrive',
  'canmoveitemintoteamdrive':  'canMoveItemIntoDrive',
  'canmoveitemoutofdrive': 'canMoveItemOutOfDrive',
  'canmoveitemoutofteamdrive': 'canMoveItemOutOfDrive',
  'canmoveitemwithindrive': 'canMoveItemWithinDrive',
  'canmoveitemwithinteamdrive': 'canMoveItemWithinDrive',
  'canmoveteamdriveitem': 'canMoveTeamDriveItem',
  'canreaddrive': 'canReadDrive',
  'canreadlabels': 'canReadLabels',
  'canreadrevisions': 'canReadRevisions',
  'canreadteamdrive': 'canReadDrive',
  'canremovechildren': 'canRemoveChildren',
  'canremovecontentrestriction': 'canRemoveContentRestriction',
  'canremovemydriveparent': 'canRemoveMyDriveParent',
  'canrename': 'canRename',
  'canrenamedrive': 'canRenameDrive',
  'canresetdriverestrictions': 'canResetDriveRestrictions',
  'canshare': 'canShare',
  'cantrash': 'canTrash',
  'cantrashchildren': 'canTrashChildren',
  'canuntrash': 'canUntrash',
  }

DRIVE_CONTENT_RESTRICTIONS_SUBFIELDS_CHOICE_MAP = {
  'ownerrestricted': 'ownerRestricted',
  'readonly': 'readOnly',
  'reason': 'reason',
  'restrictinguser': 'restrictingUser',
  'restrictiontime': 'restrictionTime',
  'type': 'type',
  }

DRIVE_LABELINFO_SUBFIELDS_CHOICE_MAP = {
  'id': 'labels(id)',
  'fields': 'labels(fields)',
  'revisionid': 'labels(revisionId)',
  }

DRIVE_OWNERS_SUBFIELDS_CHOICE_MAP = {
  'displayname': 'displayName',
  'emailaddress': 'emailAddress',
  'isauthenticateduser': 'me',
  'me': 'me',
  'permissionid': 'permissionId',
  'photolink': 'photoLink',
  'picture': 'photoLink',
  }

DRIVE_PARENTS_SUBFIELDS_CHOICE_MAP = {
  'id': 'id',
  'isroot': 'isRoot',
  }

DRIVE_PERMISSIONS_SUBFIELDS_CHOICE_MAP = {
  'additionalroles': 'role',
  'allowfilediscovery': 'allowFileDiscovery',
  'deleted': 'deleted',
  'displayname': 'displayName',
  'domain': 'domain',
  'emailaddress': 'emailAddress',
  'expirationdate': 'expirationTime',
  'expirationtime': 'expirationTime',
  'id': 'id',
  'inheritedpermissionsdisabled': 'inheritedPermissionsDisabled',
  'name': 'displayName',
  'pendingowner': 'pendingOwner',
  'permissiondetails': 'permissionDetails',
  'photolink': 'photoLink',
  'role': 'role',
  'shareddrivepermissiondetails': 'permissionDetails',
  'teamdrivepermissiondetails': 'permissionDetails',
  'type': 'type',
  'view': 'view',
  'withlink': 'allowFileDiscovery',
  }

DRIVE_SHARINGUSER_SUBFIELDS_CHOICE_MAP = {
  'displayname': 'displayName',
  'emailaddress': 'emailAddress',
  'isauthenticateduser': 'me',
  'me': 'me',
  'name': 'displayName',
  'permissionid': 'permissionId',
  'photolink': 'photoLink',
  'picture': 'photoLink',
  }

DRIVE_SHORTCUTDETAILS_SUBFIELDS_CHOICE_MAP = {
  'targetid': 'targetId',
  'targetmimetype': 'targetMimeType',
  'targetresourcekey': 'targetResourceKey',
  }

DRIVE_SUBFIELDS_CHOICE_MAP = {
  'capabilities': DRIVE_CAPABILITIES_SUBFIELDS_CHOICE_MAP,
  'contentrestrictions': DRIVE_CONTENT_RESTRICTIONS_SUBFIELDS_CHOICE_MAP,
  'labelinfo': DRIVE_LABELINFO_SUBFIELDS_CHOICE_MAP,
  'labels': DRIVE_LABEL_CHOICE_MAP,
  'lastmodifyinguser': DRIVE_SHARINGUSER_SUBFIELDS_CHOICE_MAP,
  'owners': DRIVE_OWNERS_SUBFIELDS_CHOICE_MAP,
  'parents': DRIVE_PARENTS_SUBFIELDS_CHOICE_MAP,
  'permissions': DRIVE_PERMISSIONS_SUBFIELDS_CHOICE_MAP,
  'sharinguser': DRIVE_SHARINGUSER_SUBFIELDS_CHOICE_MAP,
  'trashinguser': DRIVE_SHARINGUSER_SUBFIELDS_CHOICE_MAP,
  'shortcutdetails': DRIVE_SHORTCUTDETAILS_SUBFIELDS_CHOICE_MAP,
}

DRIVE_LIST_FIELDS = {'owners', 'parents', 'permissions', 'permissionIds', 'spaces'}

FILEINFO_FIELDS_TITLES = ['name', 'mimeType']
FILEPATH_FIELDS_TITLES = ['name', 'id', 'mimeType', 'ownedByMe', 'parents', 'sharedWithMeTime', 'driveId']
FILEPATH_FIELDS = ','.join(FILEPATH_FIELDS_TITLES)

def _getDriveTimeObjects():
  timeObjects = ['createdTime', 'viewedByMeTime', 'modifiedByMeTime', 'modifiedTime', 'restrictionTime', 'sharedWithMeTime', 'trashedTime']
  if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES]:
    _mapDrive3TitlesToDrive2(timeObjects, API.DRIVE3_TO_DRIVE2_FILES_FIELDS_MAP)
  return set(timeObjects)

def _getDriveFieldSubField(field, fieldsList, parentsSubFields):
  field, subField = field.split('.', 1)
  if field in DRIVE_SUBFIELDS_CHOICE_MAP:
    if field == 'parents':
      fieldsList.append(DRIVE_FIELDS_CHOICE_MAP[field])
      parentsSubFields[DRIVE_SUBFIELDS_CHOICE_MAP[field][subField]] = True
    elif subField in DRIVE_SUBFIELDS_CHOICE_MAP[field]:
      if field != 'labels':
        if not isinstance(DRIVE_SUBFIELDS_CHOICE_MAP[field][subField], list):
          fieldsList.append(f'{DRIVE_FIELDS_CHOICE_MAP[field]}.{DRIVE_SUBFIELDS_CHOICE_MAP[field][subField]}')
        else:
          for subSubField in DRIVE_SUBFIELDS_CHOICE_MAP[field][subField]:
            fieldsList.append(f'{DRIVE_FIELDS_CHOICE_MAP[field]}.{subSubField}')
      else:
        fieldsList.append(DRIVE_SUBFIELDS_CHOICE_MAP[field][subField])
    else:
      invalidChoiceExit(subField, list(DRIVE_SUBFIELDS_CHOICE_MAP[field]), True)
  else:
    invalidChoiceExit(field, list(DRIVE_SUBFIELDS_CHOICE_MAP), True)

class DriveFileFields():
  def __init__(self):
    self.showSharedDriveNames = False
    self.allFields = False
    self.OBY = OrderBy(DRIVEFILE_ORDERBY_CHOICE_MAP)
    self.fieldsList = []
    self.includeLabels = []
    self.parentsSubFields = {'id': False, 'isRoot': False, 'rootFolderId': None}

  def SetAllParentsSubFields(self):
    self.parentsSubFields['id'] = self.parentsSubFields['isRoot'] = True

  def ProcessArgument(self, myarg):
    if myarg == 'allfields':
      self.fieldsList = []
      self.allFields = True
    elif myarg in DRIVE_LABEL_CHOICE_MAP:
      addFieldToFieldsList(myarg, DRIVE_LABEL_CHOICE_MAP, self.fieldsList)
    elif myarg in DRIVE_FIELDS_CHOICE_MAP:
      addFieldToFieldsList(myarg, DRIVE_FIELDS_CHOICE_MAP, self.fieldsList)
      if myarg == 'parents':
        self.SetAllParentsSubFields()
      elif myarg in {'drivename', 'shareddrivename', 'teamdrivename'}:
        self.showSharedDriveNames = True
    elif myarg == 'fields':
      for field in _getFieldsList():
        if field in DRIVE_LABEL_CHOICE_MAP:
          addFieldToFieldsList(field, DRIVE_LABEL_CHOICE_MAP, self.fieldsList)
        elif field.find('.') == -1:
          if field in DRIVE_FIELDS_CHOICE_MAP:
            addFieldToFieldsList(field, DRIVE_FIELDS_CHOICE_MAP, self.fieldsList)
            if field == 'parents':
              self.SetAllParentsSubFields()
            elif field in {'drivename', 'shareddrivename', 'teamdrivename'}:
              self.showSharedDriveNames = True
          else:
            invalidChoiceExit(field, list(DRIVE_FIELDS_CHOICE_MAP)+list(DRIVE_LABEL_CHOICE_MAP), True)
        else:
          _getDriveFieldSubField(field, self.fieldsList, self.parentsSubFields)
    elif myarg == 'includelabels':
      labelIds = getEntityList(Cmd.OB_CLASSIFICATION_LABEL_ID, shlexSplit=True)
      for labelId in labelIds:
        self.includeLabels.append(normalizeDriveLabelID(labelId))
    elif myarg.find('.') != -1:
      _getDriveFieldSubField(myarg, self.fieldsList, self.parentsSubFields)
    elif myarg == 'orderby':
      self.OBY.GetChoice()
    elif myarg in {'showdrivename', 'showshareddrivename', 'showteamdrivename'}:
      self.showSharedDriveNames = True
    else:
      return False
    return True

  @property
  def orderBy(self):
    return self.OBY.orderBy

def _setSkipObjects(skipObjects, skipTitles, fieldsList):
  for field in skipTitles:
    if field != 'parents':
      if field not in fieldsList:
        skipObjects.add(field)
        fieldsList.append(field)
    else:
      for xfield in fieldsList:
        if xfield.startswith('parents'):
          break
      else:
        skipObjects.add(field)
      fieldsList.append('parents')

def _setGetPermissionsForSharedDrives(fieldsList):
  getPermissionsForSharedDrives = False
  permissionsFieldsList = []
  permissionsFields = None
  for field in fieldsList:
    if field.startswith('permissions'):
      getPermissionsForSharedDrives = True
      if field.find('.') != -1:
        field, subField = field.split('.', 1)
        permissionsFieldsList.append(subField)
  if getPermissionsForSharedDrives:
    permissionsFields = getItemFieldsFromFieldsList('permissions', permissionsFieldsList, True)
  return (getPermissionsForSharedDrives, permissionsFields)

SHOWLABELS_CHOICES = {'details', 'ids'}

def _formatFileDriveLabels(showLabels, labels, result, printMode, delimiter):
  if showLabels == 'details':
    result['labels'] = labels
  else:
    if printMode:
      result['labels'] = len(labels)
    result['labelsIds'] = delimiter.join([label['id'] for label in labels])

# gam <UserTypeEntity> info drivefile <DriveFileEntity>
#	[returnidonly]
#	[filepath|fullpath] [folderpathonly [<Boolean>]] [pathdelimiter <Character>]
#	[allfields|<DriveFieldName>*|(fields <DriveFieldNameList>)] [formatjson]
#	(orderby <DriveFileOrderByFieldName> [ascending|descending])*
#	[showdrivename] [showshareddrivepermissions]
#	[(showlabels details|ids)|(includelabels <DriveLabelIDList>)]
#	[showparentsidsaslist] [followshortcuts [<Boolean>]]
#	[stripcrsfromname] [formatjson]
# gam <UserTypeEntity> show fileinfo <DriveFileEntity>
#	[returnidonly]
#	[filepath|fullpath] [folderpathonly [<Boolean>]] [pathdelimiter <Character>]
#	[allfields|<DriveFieldName>*|(fields <DriveFieldNameList>)] [formatjson]
#	(orderby <DriveFileOrderByFieldName> [ascending|descending])*
#	[showdrivename] [showshareddrivepermissions]
#	[(showlabels details|ids)|(includelabels <DriveLabelIDList>)]
#	[showparentsidsaslist] [followshortcuts [<Boolean>]]
#	[stripcrsfromname] [formatjson]
def showFileInfo(users):
  def _setSelectionFields():
    _setSkipObjects(skipObjects, FILEINFO_FIELDS_TITLES, DFF.fieldsList)
    if filepath:
      _setSkipObjects(skipObjects, FILEPATH_FIELDS_TITLES, DFF.fieldsList)
    if getPermissionsForSharedDrives or DFF.showSharedDriveNames:
      _setSkipObjects(skipObjects, ['driveId'], DFF.fieldsList)
    if followShortcuts:
      _setSkipObjects(skipObjects, ['mimeType', 'shortcutDetails'], DFF.fieldsList)

  getPermissionsForSharedDrives = filepath = fullpath = folderPathOnly = followShortcuts = \
    returnIdOnly = showParentsIdsAsList = showNoParents = stripCRsFromName = False
  pathDelimiter = '/'
  showLabels = None
  simpleLists = []
  skipObjects = set()
  fileIdEntity = getDriveFileEntity()
  DFF = DriveFileFields()
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'filepath':
      filepath = True
    elif myarg == 'fullpath':
      filepath = fullpath = True
    elif myarg == 'folderpathonly':
      folderPathOnly = getBoolean()
    elif myarg == 'pathdelimiter':
      pathDelimiter = getCharacter()
    elif myarg == 'showparentsidsaslist':
      showParentsIdsAsList = True
      simpleLists.append('parentsIds')
    elif myarg == 'stripcrsfromname':
      stripCRsFromName = True
    elif myarg == 'showlabels':
      showLabels = getChoice(SHOWLABELS_CHOICES)
    elif myarg == 'showshareddrivepermissions':
      getPermissionsForSharedDrives = True
      permissionsFields = f'nextPageToken,permissions({",".join(DRIVEFILE_BASIC_PERMISSION_FIELDS)})'
    elif myarg == 'returnidonly':
      returnIdOnly = True
    elif myarg == 'followshortcuts':
      followShortcuts = getBoolean()
    elif DFF.ProcessArgument(myarg):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  if DFF.fieldsList:
    if not getPermissionsForSharedDrives:
      getPermissionsForSharedDrives, permissionsFields = _setGetPermissionsForSharedDrives(DFF.fieldsList)
    _setSelectionFields()
    if followShortcuts:
      DFF.fieldsList.extend(['mimeType', 'shortcutDetails'])
    fields = getFieldsFromFieldsList(DFF.fieldsList)
    showNoParents = 'parents' in DFF.fieldsList
  else:
    fields = '*'
    DFF.SetAllParentsSubFields()
    skipObjects = skipObjects.union(DEFAULT_SKIP_OBJECTS)
    showNoParents = True
  includeLabels = ','.join(DFF.includeLabels)
  pathFields = FILEPATH_FIELDS
  timeObjects = _getDriveTimeObjects()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    if returnIdOnly:
      GC.Values[GC.SHOW_GETTINGS] = False
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity,
                                                  entityType=[Ent.DRIVE_FILE_OR_FOLDER, None][FJQC.formatJSON or returnIdOnly],
                                                  orderBy=DFF.orderBy)
    if jcount == 0:
      continue
    if returnIdOnly:
      for fileId in fileIdEntity['list']:
        writeStdout(f'{fileId}\n')
      continue
    if not showParentsIdsAsList and DFF.parentsSubFields['isRoot']:
      try:
        DFF.parentsSubFields['rootFolderId'] = callGAPI(drive.files(), 'get',
                                                        throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                                                        fileId=ROOT, fields='id')['id']
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        continue
    if filepath:
      filePathInfo = initFilePathInfo(pathDelimiter)
      if fullpath:
        fileTree, status = initFileTree(drive, fileIdEntity.get('shareddrive'), None, [], True, user, i, count)
        if not status:
          continue
      else:
        fileTree =  None
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      try:
        result = callGAPI(drive.files(), 'get',
                          throwReasons=GAPI.DRIVE_GET_THROW_REASONS+[GAPI.INVALID],
                          fileId=fileId, includeLabels=includeLabels, fields=fields, supportsAllDrives=True)
        if followShortcuts and result['mimeType'] == MIMETYPE_GA_SHORTCUT:
          fileId = result['shortcutDetails']['targetId']
          result = callGAPI(drive.files(), 'get',
                            throwReasons=GAPI.DRIVE_GET_THROW_REASONS+[GAPI.INVALID],
                            fileId=fileId, includeLabels=includeLabels, fields=fields, supportsAllDrives=True)
        if stripCRsFromName:
          result['name'] = _stripControlCharsFromName(result['name'])
        driveId = result.get('driveId')
        if driveId:
          if result['mimeType'] == MIMETYPE_GA_FOLDER and result['name'] == TEAM_DRIVE:
            result['name'] = _getSharedDriveNameFromId(drive, driveId)
          if DFF.showSharedDriveNames:
            result['driveName'] = _getSharedDriveNameFromId(drive, driveId)
        if showNoParents:
          result.setdefault('parents', [])
        if getPermissionsForSharedDrives and driveId and 'permissions' not in result:
          try:
            result['permissions'] = callGAPIpages(drive.permissions(), 'list', 'permissions',
                                                  throwReasons=GAPI.DRIVE3_GET_ACL_REASONS,
                                                  retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                                  fileId=fileId, fields=permissionsFields, supportsAllDrives=True)
            for permission in result['permissions']:
              permission.pop('teamDrivePermissionDetails', None)
          except (GAPI.insufficientAdministratorPrivileges, GAPI.insufficientFilePermissions) as e:
            if fields != '*':
              entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER_ID, fileId], str(e), j, jcount)
              continue
        if showLabels is not None:
          labels = callGAPIitems(drive.files(), 'listLabels', 'labels',
                                 throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS+[GAPI.UNKNOWN_ERROR],
                                 fileId=fileId)
          _formatFileDriveLabels(showLabels, labels, result, False, ' ')
        if not FJQC.formatJSON:
          printEntity([_getEntityMimeType(result), f'{result["name"]} ({fileId})'], j, jcount)
          Ind.Increment()
        if filepath:
          if fullpath:
            extendFileTree(fileTree, [result], None, False)
            extendFileTreeParents(drive, fileTree, pathFields)
          if not FJQC.formatJSON:
            _, paths, _ = getFilePaths(drive, fileTree, result, filePathInfo, addParentsToTree=True,
                                       fullpath=fullpath, folderPathOnly=folderPathOnly)
            kcount = len(paths)
            printKeyValueList(['paths', kcount])
            Ind.Increment()
            for path in sorted(paths):
              printKeyValueList(['path', path])
            Ind.Decrement()
          else:
            addFilePathsToInfo(drive, fileTree, result, filePathInfo,
                               addParentsToTree=True, folderPathOnly=folderPathOnly)
        if fullpath:
          # Save simple parents list as mappings turn it into a list of dicts
          fpparents = result['parents'][:]
        if showParentsIdsAsList and 'parents' in result:
          result['parentsIds'] = result.pop('parents')
        if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES]:
          _mapDriveFieldNames(result, user, DFF.parentsSubFields, True)
        else:
          _mapDriveParents(result, DFF.parentsSubFields)
          _mapDriveProperties(result)
          for permission in result.get('permissions', []):
            _mapDrivePermissionNames(permission)
        if not FJQC.formatJSON:
          showJSON(None, result, skipObjects=skipObjects, timeObjects=timeObjects, simpleLists=simpleLists,
                   dictObjectsKey={'owners': 'displayName', 'fields': 'id', 'labels': 'id', 'user': 'emailAddress', 'parents': 'id',
                                   'permissions': ['name', 'displayName'][GC.Values[GC.DRIVE_V3_NATIVE_NAMES]]})
          Ind.Decrement()
        else:
          printLine(json.dumps(cleanJSON(result, skipObjects=skipObjects, timeObjects=timeObjects), ensure_ascii=False, sort_keys=True))
        if fullpath:
          # Restore simple parents list
          fileTree[fileId]['info']['parents'] = fpparents[:]
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError,
              GAPI.insufficientFilePermissions, GAPI.unknownError, GAPI.invalid) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER_ID, fileId], str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()

def getRevisionsEntity():
  revisionsEntity = {'list': [], 'dict': None, 'count': None, 'time': None, 'range': None}
  startEndTime = StartEndTime()
  entitySelector = getEntitySelector()
  if entitySelector:
    entityList = getEntitySelection(entitySelector, False)
    if isinstance(entityList, dict):
      revisionsEntity['dict'] = entityList
    else:
      revisionsEntity['list'] = entityList
  else:
    myarg = getString(Cmd.OB_DRIVE_FILE_REVISION_ID, checkBlank=True)
    mycmd = myarg.lower()
    if mycmd == 'id':
      revisionsEntity['list'] = getStringReturnInList(Cmd.OB_DRIVE_FILE_REVISION_ID)
    elif mycmd[:3] == 'id:':
      revisionsEntity['list'] = [myarg[3:]]
    elif mycmd == 'ids':
      revisionsEntity['list'] = getString(Cmd.OB_DRIVE_FILE_REVISION_ID).replace(',', ' ').split()
    elif mycmd[:4] == 'ids:':
      revisionsEntity['list'] = myarg[4:].replace(',', ' ').split()
    elif mycmd in {'first', 'last', 'allexceptfirst', 'allexceptlast'}:
      revisionsEntity['count'] = (mycmd, getInteger(minVal=1))
    elif mycmd in {'before', 'after'}:
      dateTime, _, _ = getTimeOrDeltaFromNow(True)
      revisionsEntity['time'] = (mycmd, dateTime)
    elif mycmd == 'range':
      startEndTime.Get(mycmd)
      revisionsEntity['range'] = (mycmd, startEndTime.startDateTime, startEndTime.endDateTime)
    else:
      revisionsEntity['list'] = [myarg]
  return revisionsEntity

def _selectRevisionIds(drive, fileId, origUser, user, i, count, j, jcount, revisionsEntity):
  if revisionsEntity['list']:
    return revisionsEntity['list']
  if revisionsEntity['dict']:
    if not GM.Globals[GM.CSV_SUBKEY_FIELD]:
      return revisionsEntity['dict'][fileId]
    return revisionsEntity['dict'][origUser][fileId]
  try:
    results = callGAPIpages(drive.revisions(), 'list', 'revisions',
                            throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST, GAPI.REVISIONS_NOT_SUPPORTED],
                            fileId=fileId, fields='nextPageToken,revisions(id,modifiedTime)',
                            pageSize=GC.Values[GC.DRIVE_MAX_RESULTS])
  except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
          GAPI.badRequest, GAPI.revisionsNotSupported) as e:
    entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER_ID, fileId], str(e), j, jcount)
    return []
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
    userDriveServiceNotEnabledWarning(user, str(e), i, count)
    return []
  numRevisions = len(results)
  if numRevisions == 0:
    return []
  if revisionsEntity['count']:
    countType = revisionsEntity['count'][0]
    count = revisionsEntity['count'][1]
    revisionIds = [revision['id'] for revision in results]
    if countType == 'first':
      if count >= numRevisions:
        return revisionIds
      return revisionIds[:count]
    if countType == 'last':
      if count >= numRevisions:
        return revisionIds
      return revisionIds[-count:]
    if countType == 'allexceptfirst':
      if count >= numRevisions:
        return []
      return revisionIds[count:]
# count: allexceptlast
    if count >= numRevisions:
      return []
    return revisionIds[:-count]
  revisionIds = []
  if revisionsEntity['time']:
    dateTime = revisionsEntity['time'][1]
    count = 0
    if revisionsEntity['time'][0] == 'before':
      for revision in results:
        modifiedDateTime, _ = iso8601.parse_date(revision['modifiedTime'])
        if modifiedDateTime >= dateTime:
          break
        revisionIds.append(revision['id'])
        count += 1
      if count >= numRevisions:
        return revisionIds[:-1]
      return revisionIds
# time: after
    for revision in results:
      modifiedDateTime, _ = iso8601.parse_date(revision['modifiedTime'])
      if modifiedDateTime >= dateTime:
        revisionIds.append(revision['id'])
        count += 1
    if count >= numRevisions:
      return revisionIds[1:]
    return revisionIds
# range
  startDateTime = revisionsEntity['range'][1]
  endDateTime = revisionsEntity['range'][2]
  count = 0
  for revision in results:
    modifiedDateTime, _ = iso8601.parse_date(revision['modifiedTime'])
    if modifiedDateTime >= startDateTime:
      if modifiedDateTime >= endDateTime:
        break
      revisionIds.append(revision['id'])
      count += 1
  if count >= numRevisions:
    return revisionIds[1:]
  return revisionIds

# gam <UserTypeEntity> delete filerevisions <DriveFileEntity> select <DriveFileRevisionIdEntity> [previewdelete]
#	[showtitles] [doit] [max_to_delete <Number>]
def deleteFileRevisions(users):
  fileIdEntity = getDriveFileEntity()
  revisionsEntity = None
  previewDelete = showTitles = doIt = False
  maxToProcess = 1
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'select':
      revisionsEntity = getRevisionsEntity()
    elif myarg == 'previewdelete':
      previewDelete = True
    elif myarg == 'showtitles':
      showTitles = True
    elif myarg == 'doit':
      doIt = True
    elif myarg in {'maxtodelete', 'maxtoprocess'}:
      maxToProcess = getInteger(minVal=0)
    else:
      unknownArgumentExit()
  if not revisionsEntity:
    missingArgumentExit('select <DriveFileRevisionIdEntity>')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity)
    if jcount == 0:
      continue
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      fileName = fileId
      entityType = Ent.DRIVE_FILE_OR_FOLDER_ID
      if showTitles:
        fileName, entityType, _ = _getDriveFileNameFromId(drive, fileId)
      revisionIds = _selectRevisionIds(drive, fileId, origUser, user, i, count, j, jcount, revisionsEntity)
      kcount = len(revisionIds)
      if kcount == 0:
        entityNumEntitiesActionNotPerformedWarning([Ent.USER, user, entityType, fileName], Ent.DRIVE_FILE_REVISION, kcount,
                                                   Msg.NO_ENTITIES_MATCHED.format(Ent.Plural(Ent.DRIVE_FILE_REVISION)), j, jcount)
        setSysExitRC(NO_ENTITIES_FOUND_RC)
        continue
      if not previewDelete:
        if maxToProcess and kcount > maxToProcess:
          entityNumEntitiesActionNotPerformedWarning([Ent.USER, user, entityType, fileName], Ent.DRIVE_FILE_REVISION, kcount,
                                                     Msg.COUNT_N_EXCEEDS_MAX_TO_PROCESS_M.format(kcount, Act.ToPerform(), maxToProcess), j, jcount)
          continue
        if not doIt:
          entityNumEntitiesActionNotPerformedWarning([Ent.USER, user, entityType, fileName], Ent.DRIVE_FILE_REVISION, kcount,
                                                     Msg.USE_DOIT_ARGUMENT_TO_PERFORM_ACTION, j, jcount)
          continue
        entityPerformActionNumItems([Ent.USER, user, entityType, fileName], kcount, Ent.DRIVE_FILE_REVISION, j, jcount)
      else:
        entityPerformActionNumItemsModifier([Ent.USER, user, entityType, fileName], kcount, Ent.DRIVE_FILE_REVISION, Msg.PREVIEW_ONLY, j, jcount)
      Ind.Increment()
      k = 0
      for revisionId in revisionIds:
        k += 1
        if not previewDelete:
          try:
            callGAPI(drive.revisions(), 'delete',
                     throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST, GAPI.REVISION_NOT_FOUND, GAPI.REVISION_DELETION_NOT_SUPPORTED,
                                                                   GAPI.CANNOT_DELETE_ONLY_REVISION, GAPI.REVISIONS_NOT_SUPPORTED],
                     fileId=fileId, revisionId=revisionId)
            entityActionPerformed([Ent.USER, user, entityType, fileName, Ent.DRIVE_FILE_REVISION, revisionId], k, kcount)
          except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
                  GAPI.badRequest, GAPI.revisionDeletionNotSupported, GAPI.cannotDeleteOnlyRevision, GAPI.revisionsNotSupported) as e:
            entityActionFailedWarning([Ent.USER, user, entityType, fileName], str(e), j, jcount)
          except GAPI.revisionNotFound:
            entityDoesNotHaveItemWarning([Ent.USER, user, entityType, fileName, Ent.DRIVE_FILE_REVISION, revisionId], k, kcount)
          except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
            userDriveServiceNotEnabledWarning(user, str(e), i, count)
            break
        else:
          entityActionNotPerformedWarning([Ent.USER, user, entityType, fileName, Ent.DRIVE_FILE_REVISION, revisionId], Msg.PREVIEW_ONLY, k, kcount)
      Ind.Decrement()

REVISIONS_FIELDS_CHOICE_MAP = {
  'keepforever': 'keepForever',
  'published': 'published',
  'publishauto': 'publishAuto',
  'publishedoutsidedomain': 'publishedOutsideDomain'
  }
# gam <UserTypeEntity> update filerevisions <DriveFileEntity> select <DriveFileRevisionIdEntity> [previewupdate]
#	[published [<Boolean>]] [publishauto [<Boolean>]] [publishedoutsidedomain [<Boolean>]]
#	[keepforever [<Boolean>]}
#	[showtitles] [doit] [max_to_update <Number>]
def updateFileRevisions(users):
  fileIdEntity = getDriveFileEntity()
  revisionsEntity = None
  previewUpdate = showTitles = doIt = False
  maxToProcess = 1
  body = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'select':
      revisionsEntity = getRevisionsEntity()
    elif myarg in REVISIONS_FIELDS_CHOICE_MAP:
      body[REVISIONS_FIELDS_CHOICE_MAP[myarg]] = getBoolean()
    elif myarg == 'previewupdate':
      previewUpdate = True
    elif myarg == 'showtitles':
      showTitles = True
    elif myarg == 'doit':
      doIt = True
    elif myarg in {'maxtoupdate', 'maxtoprocess'}:
      maxToProcess = getInteger(minVal=0)
    else:
      unknownArgumentExit()
  if not revisionsEntity:
    missingArgumentExit('select <DriveFileRevisionIdEntity>')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity)
    if jcount == 0:
      continue
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      fileName = fileId
      entityType = Ent.DRIVE_FILE_OR_FOLDER_ID
      if showTitles:
        fileName, entityType, _ = _getDriveFileNameFromId(drive, fileId)
      revisionIds = _selectRevisionIds(drive, fileId, origUser, user, i, count, j, jcount, revisionsEntity)
      kcount = len(revisionIds)
      if kcount == 0:
        entityNumEntitiesActionNotPerformedWarning([Ent.USER, user, entityType, fileName], Ent.DRIVE_FILE_REVISION, kcount,
                                                   Msg.NO_ENTITIES_MATCHED.format(Ent.Plural(Ent.DRIVE_FILE_REVISION)), j, jcount)
        setSysExitRC(NO_ENTITIES_FOUND_RC)
        continue
      if not previewUpdate:
        if maxToProcess and kcount > maxToProcess:
          entityNumEntitiesActionNotPerformedWarning([Ent.USER, user, entityType, fileName], Ent.DRIVE_FILE_REVISION, kcount,
                                                     Msg.COUNT_N_EXCEEDS_MAX_TO_PROCESS_M.format(kcount, Act.ToPerform(), maxToProcess), j, jcount)
          continue
        if not doIt:
          entityNumEntitiesActionNotPerformedWarning([Ent.USER, user, entityType, fileName], Ent.DRIVE_FILE_REVISION, kcount,
                                                     Msg.USE_DOIT_ARGUMENT_TO_PERFORM_ACTION, j, jcount)
          continue
        entityPerformActionNumItems([Ent.USER, user, entityType, fileName], kcount, Ent.DRIVE_FILE_REVISION, j, jcount)
      else:
        entityPerformActionNumItemsModifier([Ent.USER, user, entityType, fileName], kcount, Ent.DRIVE_FILE_REVISION, Msg.PREVIEW_ONLY, j, jcount)
      Ind.Increment()
      k = 0
      for revisionId in revisionIds:
        k += 1
        if not previewUpdate:
          try:
            callGAPI(drive.revisions(), 'update',
                     throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST, GAPI.REVISION_NOT_FOUND, GAPI.REVISIONS_NOT_SUPPORTED],
                     fileId=fileId, revisionId=revisionId, body=body)
            entityActionPerformed([Ent.USER, user, entityType, fileName, Ent.DRIVE_FILE_REVISION, revisionId], k, kcount)
          except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
                  GAPI.badRequest, GAPI.revisionsNotSupported) as e:
            entityActionFailedWarning([Ent.USER, user, entityType, fileName], str(e), j, jcount)
          except GAPI.revisionNotFound:
            entityDoesNotHaveItemWarning([Ent.USER, user, entityType, fileName, Ent.DRIVE_FILE_REVISION, revisionId], k, kcount)
          except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
            userDriveServiceNotEnabledWarning(user, str(e), i, count)
            break
        else:
          entityActionNotPerformedWarning([Ent.USER, user, entityType, fileName, Ent.DRIVE_FILE_REVISION, revisionId], Msg.PREVIEW_ONLY, k, kcount)
      Ind.Decrement()

def _selectRevisionResults(results, fileId, origUser, revisionsEntity, previewDelete):
  numRevisions = len(results)
  if numRevisions == 0:
    return results
  if revisionsEntity['count']:
    countType = revisionsEntity['count'][0]
    count = revisionsEntity['count'][1]
    if countType == 'first':
      if count >= numRevisions:
        if previewDelete:
          return results[:-1]
        return results
      return results[:count]
    if countType == 'last':
      if count >= numRevisions:
        if previewDelete:
          return results[1:]
        return results
      return results[-count:]
    if countType == 'allexceptfirst':
      if count >= numRevisions:
        return []
      return results[count:]
# count: allexceptlast
    if count >= numRevisions:
      return []
    return results[:-count]
  if revisionsEntity['time']:
    dateTime = revisionsEntity['time'][1]
    count = 0
    if revisionsEntity['time'][0] == 'before':
      for revision in results:
        modifiedDateTime, _ = iso8601.parse_date(revision['modifiedTime'])
        if modifiedDateTime >= dateTime:
          break
        count += 1
      if count >= numRevisions:
        if previewDelete:
          return results[:-1]
        return results
      return results[:count]
# time: after
    for revision in results:
      modifiedDateTime, _ = iso8601.parse_date(revision['modifiedTime'])
      if modifiedDateTime >= dateTime:
        break
      count += 1
    if count == 0:
      if previewDelete:
        return results[1:]
      return results
    if count >= numRevisions:
      return []
    return results[count:]
  if revisionsEntity['range']:
    startDateTime = revisionsEntity['range'][1]
    endDateTime = revisionsEntity['range'][2]
    count = 0
    selectedResults = []
    for revision in  results:
      modifiedDateTime, _ = iso8601.parse_date(revision['modifiedTime'])
      if modifiedDateTime >= startDateTime:
        if modifiedDateTime >= endDateTime:
          break
        selectedResults.append(revision)
        count += 1
    if count >= numRevisions:
      if previewDelete:
        return selectedResults[1:]
    return selectedResults
# revisionsIds
  selectedResults = []
  if revisionsEntity['dict']:
    if not GM.Globals[GM.CSV_SUBKEY_FIELD]:
      revisionIds = revisionsEntity['dict'][fileId]
    else:
      revisionIds = revisionsEntity['dict'][origUser][fileId]
  else:
    revisionIds = revisionsEntity['list']
  return [revision for revision in results if revision['id'] in revisionIds]

FILEREVISIONS_FIELDS_CHOICE_MAP = {
  'filesize': 'size',
  'id': 'id',
  'keepforever': 'keepForever',
  'lastmodifyinguser': 'lastModifyingUser',
  'lastmodifyingusername': 'lastModifyingUser.displayName',
  'md5checksum': 'md5Checksum',
  'mimetype': 'mimeType',
  'modifieddate': 'modifiedTime',
  'modifiedtime': 'modifiedTime',
  'originalfilename': 'originalFilename',
  'pinned': 'keepForever',
  'published': 'published',
  'publishauto': 'publishAuto',
  'publishedoutsidedomain': 'publishedOutsideDomain',
  'size': 'size',
  }

def _getFileRevisionsTimeObjects():
  timeObjects = ['modifiedTime']
  if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES]:
    _mapDrive3TitlesToDrive2(timeObjects, API.DRIVE3_TO_DRIVE2_FILES_FIELDS_MAP)
  return set(timeObjects)

def _showRevision(revision, timeObjects, i=0, count=0):
  printEntity([Ent.DRIVE_FILE_REVISION, revision['id']], i, count)
  Ind.Increment()
  if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES]:
    _mapDriveRevisionNames(revision)
  showJSON(None, revision, ['id'], timeObjects)
  Ind.Decrement()

DRIVE_REVISIONS_INDEXED_TITLES = ['revisions']

# gam <UserTypeEntity> show filerevisions <DriveFileEntity>
#	[select <DriveFileRevisionIDEntity>]
#	[previewdelete] [showtitles]
#	[<RevisionsFieldName>*|(fields <RevisionsFieldNameList>)]
#	(orderby <DriveFileOrderByFieldName> [ascending|descending])*
#	[stripcrsfromname]
# gam <UserTypeEntity> print filerevisions <DriveFileEntity> [todrive <ToDriveAttribute>*]
#	[select <DriveFileRevisionIDEntity>]
#	[previewdelete] [showtitles] [oneitemperrow]
#	[<RevisionsFieldName>*|(fields <RevisionsFieldNameList>)]
#	(orderby <DriveFileOrderByFieldName> [ascending|descending])*
#	[stripcrsfromname]
def printShowFileRevisions(users):
  csvPF = CSVPrintFile(['Owner', 'id']) if Act.csvFormat() else None
  fieldsList = []
  fileIdEntity = getDriveFileEntity()
  revisionsEntity = None
  oneItemPerRow = previewDelete = showTitles = stripCRsFromName = False
  OBY = OrderBy(DRIVEFILE_ORDERBY_CHOICE_MAP)
  fileNameTitle = 'title' if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES] else 'name'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'select':
      revisionsEntity = getRevisionsEntity()
    elif csvPF and myarg == 'oneitemperrow':
      oneItemPerRow = True
      if csvPF:
        csvPF.AddTitles('revision.id')
    elif myarg == 'orderby':
      OBY.GetChoice()
    elif myarg == 'previewdelete':
      previewDelete = True
    elif myarg == 'showtitles':
      showTitles = True
      if csvPF:
        csvPF.AddTitles(fileNameTitle)
    elif myarg == 'stripcrsfromname':
      stripCRsFromName = True
    elif getFieldsList(myarg, FILEREVISIONS_FIELDS_CHOICE_MAP, fieldsList, initialField='id'):
      pass
    else:
      unknownArgumentExit()
  if fieldsList:
    fields = getItemFieldsFromFieldsList('revisions', fieldsList)
  else:
    fields = '*'
  timeObjects = _getFileRevisionsTimeObjects()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity,
                                                  entityType=[Ent.DRIVE_FILE_OR_FOLDER, None][csvPF is not None],
                                                  orderBy=OBY.orderBy)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      fileName = fileId
      entityType = Ent.DRIVE_FILE_OR_FOLDER_ID
      if showTitles:
        fileName, entityType, _ = _getDriveFileNameFromId(drive, fileId, not csvPF)
        if stripCRsFromName:
          fileName = _stripControlCharsFromName(fileName)
      try:
        results = callGAPIpages(drive.revisions(), 'list', 'revisions',
                                throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST, GAPI.REVISIONS_NOT_SUPPORTED],
                                fileId=fileId, fields=fields, pageSize=GC.Values[GC.DRIVE_MAX_RESULTS])
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
              GAPI.badRequest, GAPI.revisionsNotSupported) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER_ID, fileId], str(e), j, jcount)
        continue
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
      if revisionsEntity:
        results = _selectRevisionResults(results, fileId, origUser, revisionsEntity, previewDelete)
      if not csvPF:
        kcount = len(results)
        entityPerformActionNumItems([entityType, fileName], kcount, Ent.DRIVE_FILE_REVISION, j, jcount)
        Ind.Increment()
        k = 0
        for revision in results:
          k += 1
          _showRevision(revision, timeObjects, k, kcount)
        Ind.Decrement()
      elif results:
        if oneItemPerRow:
          for revision in results:
            row = {'Owner': user, 'id': fileId}
            if showTitles:
              row[fileNameTitle] = fileName
            if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES]:
              _mapDriveRevisionNames(revision)
            csvPF.WriteRowTitles(flattenJSON({'revision': revision}, flattened=row, timeObjects=timeObjects))
        else:
          if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES]:
            for revision in results:
              _mapDriveRevisionNames(revision)
          if showTitles:
            csvPF.WriteRowTitles(flattenJSON({'revisions': results}, flattened={'Owner': user, 'id': fileId, fileNameTitle: fileName}, timeObjects=timeObjects))
          else:
            csvPF.WriteRowTitles(flattenJSON({'revisions': results}, flattened={'Owner': user, 'id': fileId}, timeObjects=timeObjects))
    Ind.Decrement()
  if csvPF:
    if oneItemPerRow:
      csvPF.SetSortTitles(['Owner', 'id', fileNameTitle, 'revision.id'])
    else:
      csvPF.SetSortTitles(['Owner', 'id', 'revisions'])
      csvPF.SetIndexedTitles(DRIVE_REVISIONS_INDEXED_TITLES)
    csvPF.writeCSVfile('Drive File Revisions')

def _stripMeInOwners(query):
  if not query:
    return query
  query = query.replace(ME_IN_OWNERS_AND, '')
  query = query.replace(AND_ME_IN_OWNERS, '')
  return query.replace(ME_IN_OWNERS, '').strip()

def _stripNotMeInOwners(query):
  if not query:
    return query
  query = query.replace(NOT_ME_IN_OWNERS_AND, '')
  query = query.replace(AND_NOT_ME_IN_OWNERS, '')
  return query.replace(NOT_ME_IN_OWNERS, '').strip()

def _updateAnyOwnerQuery(query):
  query = _stripNotMeInOwners(query)
  return _stripMeInOwners(query)

def initFileTree(drive, shareddrive, DLP, shareddriveFields, showParent, user, i, count):
  fileTree = {
    ORPHANS: {'info': {'id': ORPHANS, 'name': ORPHANS, 'mimeType': MIMETYPE_GA_FOLDER, 'ownedByMe': True, 'parents': []},
              'noParents': True, 'children': []},
    SHARED_WITHME: {'info': {'id': SHARED_WITHME, 'name': SHARED_WITHME, 'mimeType': MIMETYPE_GA_FOLDER, 'ownedByMe': False, 'parents': []},
                    'noParents': True, 'children': []},
    SHARED_DRIVES: {'info': {'id': SHARED_DRIVES, 'name': SHARED_DRIVES, 'mimeType': MIMETYPE_GA_FOLDER, 'ownedByMe': False, 'parents': [], 'driveId': SHARED_DRIVES},
                    'noParents': True, 'children': []},
    }
  try:
    if not shareddrive:
      fileId = ROOT
      f_file = callGAPI(drive.files(), 'get',
                        throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                        fileId=fileId, fields=FILEPATH_FIELDS)
      f_file['parents'] = []
      fileTree[f_file['id']] = {'info': f_file, 'noParents': True, 'children': []}
    elif 'driveId' in shareddrive:
      fileId = shareddrive['driveId']
      f_file = callGAPI(drive.files(), 'get',
                        throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FILE_NOT_FOUND],
                        fileId=fileId, supportsAllDrives=True, fields=FILEPATH_FIELDS)
      f_file['parents'] = []
      fileTree[f_file['id']] = {'info': f_file, 'noParents': True, 'children': []}
      name = callGAPI(drive.drives(), 'get',
                      throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND],
                      driveId=fileId, fields='name')['name']
      fileTree[f_file['id']]['info']['name'] = f'{SHARED_DRIVES}/{name}'
    else:
      fileId = None
    if DLP and (DLP.getSharedDriveNames or DLP.checkLocation in {LOCATION_ALL_DRIVES, LOCATION_ONLY_SHARED_DRIVES}):
      tdrives = callGAPIpages(drive.drives(), 'list', 'drives',
                              throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID, GAPI.NO_LIST_TEAMDRIVES_ADMINISTRATOR_PRIVILEGE],
                              fields='nextPageToken,drives(id,name)', pageSize=100)
      for tdrive in tdrives:
        fileId = tdrive['id']
        if fileId not in fileTree:
          fileTree[fileId] = {'info': {'id': fileId, 'name': f'{SHARED_DRIVES}/{tdrive["name"]}', 'mimeType': MIMETYPE_GA_FOLDER},
                              'noParents': True, 'children': []}
        for field in shareddriveFields:
          if field in tdrive:
            fileTree[fileId]['info'][field] = tdrive[field]
        if showParent:
          fileTree[fileId]['info']['driveId'] = fileId
        fileTree[SHARED_DRIVES]['children'].append(fileId)
  except (GAPI.notFound, GAPI.fileNotFound, GAPI.invalid, GAPI.noListTeamDrivesAdministratorPrivilege) as e:
    entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE, fileId], str(e), i, count)
    return (None, False)
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
    userDriveServiceNotEnabledWarning(user, str(e), i, count)
    return (None, False)
  return (fileTree, True)

def extendFileTree(fileTree, feed, DLP, stripCRsFromName):
  for f_file in feed:
    if DLP and (not DLP.CheckOnlySharedDrives(f_file) or not DLP.CheckExcludeTrashed(f_file)):
      continue
    if stripCRsFromName:
      f_file['name'] = _stripControlCharsFromName(f_file['name'])
    fileId = f_file['id']
    if not f_file.get('parents', []):
      if not f_file.get('driveId'):
        if f_file['mimeType'] == MIMETYPE_GA_FOLDER and f_file['name'] == MY_DRIVE:
          f_file['parents'] = []
        else:
          f_file['parents'] = [ORPHANS] if f_file.get('ownedByMe', False) and 'sharedWithMeTime' not in f_file else [SHARED_WITHME]
      else:
        f_file['parents'] = [SHARED_DRIVES] if 'sharedWithMeTime' not in f_file else [SHARED_WITHME]
    if fileId not in fileTree:
      fileTree[fileId] = {'info': f_file, 'children': []}
    else:
      fileTree[fileId]['info'] = f_file
    for parentId in f_file['parents']:
      if parentId not in fileTree:
        fileTree[parentId] = {'info': {'id': parentId, 'name': parentId, 'mimeType': MIMETYPE_GA_FOLDER}, 'children': []}
      fileTree[parentId]['children'].append(fileId)

def extendFileTreeParents(drive, fileTree, fields):
  def _followParent(fileId):
    try:
      result = callGAPI(drive.files(), 'get',
                        throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                        fileId=fileId, fields=fields, supportsAllDrives=True)
      if not result.get('parents', []):
        if not result.get('driveId'):
          result['parents'] = [ORPHANS] if result.get('ownedByMe', False) and 'sharedWithMeTime' not in result else [SHARED_WITHME]
        else:
          if result['name'] == TEAM_DRIVE:
            result['name'] = _getSharedDriveNameFromId(drive, result['driveId'])
          result['parents'] = [SHARED_DRIVES] if 'sharedWithMeTime' not in result else [SHARED_WITHME]
      fileTree[fileId]['info'] = result
      fileTree[fileId]['info']['noDisplay'] = True
      for parentId in result['parents']:
        if parentId not in fileTree:
          fileTree[parentId] = {'info': {'id': parentId, 'name': parentId, 'mimeType': MIMETYPE_GA_FOLDER}, 'children': []}
          _followParent(parentId)
        fileTree[parentId]['children'].append(fileId)
    except (GAPI.fileNotFound, GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy):
      fileTree[fileId] = {'info': {'id': fileId, 'name': fileId, 'mimeType': MIMETYPE_GA_FOLDER, 'parents': [], 'noDisplay': True},
                          'children': []}

  for fileId in list(fileTree):
    f_file = fileTree[fileId]
    if 'parents' not in f_file['info'] and not f_file.get('noParents', False):
      f_file['info']['noDisplay'] = True
      _followParent(fileId)

def buildFileTree(feed, drive):
  fileTree = {
    ORPHANS: {'info': {'id': ORPHANS, 'name': ORPHANS, 'mimeType': MIMETYPE_GA_FOLDER, 'ownedByMe': True}, 'children': []},
    }
  try:
    f_file = callGAPI(drive.files(), 'get',
                      throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                      fileId=ROOT, fields=FILEPATH_FIELDS)
    fileTree[f_file['id']] = {'info': f_file, 'children': []}
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy,
          GAPI.notFound, GAPI.invalid, GAPI.noListTeamDrivesAdministratorPrivilege):
    pass
  for f_file in feed:
    if 'parents' not in f_file:
      f_file['parents'] = []
    fileId = f_file['id']
    if fileId not in fileTree:
      fileTree[fileId] = {'info': f_file, 'children': []}
    else:
      fileTree[fileId]['info'] = f_file
    parents = f_file['parents']
    if not parents:
      parents = [ORPHANS]
    for parentId in parents:
      if parentId not in fileTree:
        fileTree[parentId] = {'info': {'id': parentId, 'name': parentId, 'mimeType': MIMETYPE_GA_FOLDER}, 'children': []}
      fileTree[parentId]['children'].append(fileId)
  return fileTree

def addFilePathsToRow(drive, fileTree, fileEntryInfo, filePathInfo, csvPF, row,
                      fullpath=False, showDepth=False, folderPathOnly=False):
  _, paths, maxDepth = getFilePaths(drive, fileTree, fileEntryInfo, filePathInfo,
                                    fullpath=fullpath, showDepth=showDepth, folderPathOnly=folderPathOnly)
  kcount = len(paths)
  if showDepth:
    row['depth'] = maxDepth
  row['paths'] = kcount
  k = 0
  for path in sorted(paths):
    key = f'path{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{k}'
    csvPF.AddTitles(key)
    if GC.Values[GC.CSV_OUTPUT_CONVERT_CR_NL] and (path.find('\n') >= 0 or path.find('\r') >= 0):
      row[key] = escapeCRsNLs(path)
    else:
      row[key] = path
    k += 1

def addFilePathsToInfo(drive, fileTree, fileEntryInfo, filePathInfo, addParentsToTree=False, folderPathOnly=False):
  _, paths, _ = getFilePaths(drive, fileTree, fileEntryInfo, filePathInfo, addParentsToTree=addParentsToTree,
                             showDepth=False, folderPathOnly=folderPathOnly)
  fileEntryInfo['paths'] = []
  for path in sorted(paths):
    if GC.Values[GC.CSV_OUTPUT_CONVERT_CR_NL] and (path.find('\n') >= 0 or path.find('\r') >= 0):
      fileEntryInfo['paths'].append(escapeCRsNLs(path))
    else:
      fileEntryInfo['paths'].append(path)

def _validateACLOwnerType(location, body):
  if body.get('role', '') == 'owner' and body['type'] != 'user':
    Cmd.SetLocation(location)
    usageErrorExit(Msg.INVALID_PERMISSION_ATTRIBUTE_TYPE.format(f'role {body["role"]}', body['type']))

def _validateACLAttributes(myarg, location, body, field, validTypes):
  if field in body and body['type'] not in validTypes:
    Cmd.SetLocation(location-1)
    usageErrorExit(Msg.INVALID_PERMISSION_ATTRIBUTE_TYPE.format(myarg, body['type']))

def _validatePermissionOwnerType(location, body):
  if 'role' in body:
    badTypes = body['type']-{'user'}
    if 'owner' in body['role'] and badTypes:
      Cmd.SetLocation(location)
      usageErrorExit(Msg.INVALID_PERMISSION_ATTRIBUTE_TYPE.format('role owner', ','.join(badTypes)))

def _validatePermissionAttributes(myarg, location, body, field, validTypes):
  if field in body:
    badTypes = body['type']-validTypes
    if badTypes:
      Cmd.SetLocation(location-1)
      usageErrorExit(Msg.INVALID_PERMISSION_ATTRIBUTE_TYPE.format(myarg, ','.join(badTypes)))

DRIVEFILE_ACL_ROLES_MAP = {
  'commenter': 'commenter',
  'contentmanager': 'fileOrganizer',
  'contributor': 'writer',
  'editor': 'writer',
  'fileorganizer': 'fileOrganizer',
  'fileorganiser': 'fileOrganizer',
  'manager': 'organizer',
  'organizer': 'organizer',
  'organiser': 'organizer',
  'owner': 'owner',
  'read': 'reader',
  'reader': 'reader',
  'viewer': 'reader',
  'writer': 'writer',
  }

DRIVEFILE_ACL_PERMISSION_TYPES = ['anyone', 'domain', 'group', 'user'] # anyone must be first element
DRIVEFILE_ACL_PERMISSION_DETAILS_TYPES = ['file', 'member']

class PermissionMatch():
  _PERMISSION_MATCH_ACTION_MAP = {'process': True, 'skip': False}
  _PERMISSION_MATCH_MODE_MAP = {'or': True, 'and': False}

  def __init__(self):
    self.permissionMatches = []
    self.permissionMatchKeep = self.permissionMatchOr = True
    self.permissionFields = set()
    self.clearDefaultMatch = False

  def GetMatch(self):
    startEndTime = StartEndTime('expirationstart', 'expirationend')
    roleLocation = withLinkLocation = expirationStartLocation = expirationEndLocation = deletedLocation = None
    requiredMatch = not checkArgumentPresent('not')
    body = {}
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg in {'type', 'nottype'}:
        body[myarg] = set()
        body[myarg].add(getChoice(DRIVEFILE_ACL_PERMISSION_TYPES))
        self.permissionFields.add('type')
      elif myarg in {'typelist', 'nottypelist'}:
        arg = 'type' if myarg == 'typelist' else 'nottype'
        body[arg] = set()
        for ptype in getString(Cmd.OB_PERMISSION_TYPE_LIST).lower().replace(',', ' ').split():
          if ptype in DRIVEFILE_ACL_PERMISSION_TYPES:
            body[arg].add(ptype)
          else:
            invalidChoiceExit(ptype, DRIVEFILE_ACL_PERMISSION_TYPES, True)
        self.permissionFields.add('type')
      elif myarg in {'role', 'notrole'}:
        roleLocation = Cmd.Location()
        body[myarg] = set()
        body[myarg].add(getChoice(DRIVEFILE_ACL_ROLES_MAP, mapChoice=True))
        self.permissionFields.add('role')
      elif myarg in {'rolelist', 'notrolelist'}:
        arg = 'role' if myarg == 'rolelist' else 'notrole'
        body[arg] = set()
        roleLocation = Cmd.Location()
        for prole in getString(Cmd.OB_PERMISSION_ROLE_LIST).lower().replace(',', ' ').split():
          if prole in DRIVEFILE_ACL_ROLES_MAP:
            body[arg].add(DRIVEFILE_ACL_ROLES_MAP[prole])
          else:
            invalidChoiceExit(prole, DRIVEFILE_ACL_ROLES_MAP, True)
        self.permissionFields.add('role')
      elif myarg == 'emailaddress':
        body['emailAddress'] = getREPattern(re.IGNORECASE)
        self.permissionFields.add('emailAddress')
      elif myarg == 'emailaddresslist':
        body[myarg] = set(getString(Cmd.OB_EMAIL_ADDRESS_LIST).replace(',', ' ').lower().split())
        self.permissionFields.add('emailAddress')
      elif myarg == 'permissionidlist':
        body[myarg] = set(getString(Cmd.OB_PERMISSION_ID_LIST).replace(',', ' ').split())
        self.permissionFields.add('id')
      elif myarg in {'domain', 'notdomain'}:
        body[myarg] = getREPattern(re.IGNORECASE)
        self.permissionFields.add('domain')
        self.permissionFields.add('emailAddress')
      elif myarg in {'domainlist', 'notdomainlist'}:
        body[myarg] = set(getString(Cmd.OB_DOMAIN_NAME_LIST).replace(',', ' ').lower().split())
        self.permissionFields.add('domain')
        self.permissionFields.add('emailAddress')
      elif myarg == 'withlink':
        withLinkLocation = Cmd.Location()
        body['allowFileDiscovery'] = not getBoolean()
        self.permissionFields.add('allowFileDiscovery')
      elif myarg in {'allowfilediscovery', 'discoverable'}:
        withLinkLocation = Cmd.Location()
        body['allowFileDiscovery'] = getBoolean()
        self.permissionFields.add('allowFileDiscovery')
      elif myarg in {'name', 'displayname'}:
        body['displayName'] = getREPattern(re.IGNORECASE)
        self.permissionFields.add('displayName')
      elif myarg == 'expirationstart':
        expirationStartLocation = Cmd.Location()
        startEndTime.Get(myarg)
        body[myarg] = startEndTime.startDateTime
        self.permissionFields.add('expirationTime')
      elif myarg == 'expirationend':
        expirationEndLocation = Cmd.Location()
        startEndTime.Get(myarg)
        body[myarg] = startEndTime.endDateTime
        self.permissionFields.add('expirationTime')
      elif myarg == 'deleted':
        deletedLocation = Cmd.Location()
        body[myarg] = getBoolean()
        self.permissionFields.add('deleted')
      elif myarg == 'inherited':
        body[myarg] = getBoolean()
        self.permissionFields.add('permissionDetails')
      elif myarg == 'permtype':
        body['permissionType'] = getChoice(DRIVEFILE_ACL_PERMISSION_DETAILS_TYPES)
        self.permissionFields.add('permissionDetails')
      elif myarg in {'em', 'endmatch'}:
        break
      else:
        unknownArgumentExit()
    if self.clearDefaultMatch:
      self.permissionMatches = []
      self.clearDefaultMatch = False
    if body:
      if 'type' in body:
        _validatePermissionOwnerType(roleLocation, body)
        _validatePermissionAttributes('allowfilediscovery/withlink', withLinkLocation, body, 'allowFileDiscovery', {'anyone', 'domain'})
        _validatePermissionAttributes('expirationstart', expirationStartLocation, body, 'expirationstart', {'user', 'group'})
        _validatePermissionAttributes('expirationend', expirationEndLocation, body, 'expirationend', {'user', 'group'})
        _validatePermissionAttributes('deleted', deletedLocation, body, 'deleted', {'user', 'group'})
      self.permissionMatches.append((requiredMatch, body))

  def SetDefaultMatch(self, requiredMatch, body):
    self.clearDefaultMatch = True
    self.permissionMatches.append((requiredMatch, body))

  def ProcessArgument(self, myarg):
    if myarg in {'pm', 'permissionmatch'}:
      self.GetMatch()
    elif myarg in {'pma', 'permissionmatchaction'}:
      self.permissionMatchKeep = getChoice(PermissionMatch._PERMISSION_MATCH_ACTION_MAP, mapChoice=True)
    elif myarg in {'pmm', 'permissionmatchmode'}:
      self.permissionMatchOr = getChoice(PermissionMatch._PERMISSION_MATCH_MODE_MAP, mapChoice=True)
    else:
      return False
    return True

  @staticmethod
  def CheckPermissionMatch(permission, permissionMatch):
    match = False
    for field, value in iter(permissionMatch[1].items()):
      if field in {'type', 'role'}:
        if permission.get(field, '') not in value:
          break
      elif field == 'nottype':
        if permission.get('type', '') in value:
          break
      elif field == 'notrole':
        if permission.get('role', '') in value:
          break
      elif field in {'allowFileDiscovery', 'deleted'}:
        if value != permission.get(field, False):
          break
      elif field == 'inherited':
        if 'permissionDetails' in permission:
          if value != permission['permissionDetails'][0].get(field, False):
            break
        else:
          break
      elif field == 'permissionType':
        if 'permissionDetails' in permission:
          if value != permission['permissionDetails'][0].get(field, ''):
            break
        else:
          break
      elif field in {'expirationstart', 'expirationend'}:
        if 'expirationTime' in permission:
          expirationDateTime, _ = iso8601.parse_date(permission['expirationTime'])
          if field == 'expirationstart':
            if expirationDateTime < value:
              break
          else:
            if expirationDateTime > value:
              break
        else:
          break
      elif field == 'emailaddresslist':
        emailAddress = permission.get('emailAddress')
        if emailAddress:
          if emailAddress.lower() not in value:
            break
        else:
          break
      elif field == 'permissionidlist':
        permissionId = permission.get('id')
        if permissionId:
          if permissionId not in value:
            break
        else:
          break
      elif field not in {'domain', 'notdomain', 'domainlist', 'notdomainlist'}:
        if not value.match(permission.get(field, '')):
          break
      else:
        if 'domain' in permission:
          domain = permission['domain'].lower()
        elif 'emailAddress' in permission and permission['emailAddress']:
          _, domain = splitEmailAddress(permission['emailAddress'].lower())
        else:
          break
        if ((field == 'domain' and not value.match(domain)) or
            (field == 'notdomain' and value.match(domain)) or
            (field == 'domainlist' and domain not in value) or
            (field == 'notdomainlist' and domain in value)):
          break
    else:
      match = True
    return match == permissionMatch[0]

  def GetMatchingPermissions(self, permissions):
    if not self.permissionMatches:
      return permissions
    matchingPermissions = []
    for permission in permissions:
      requiredMatches = 1 if self.permissionMatchOr else len(self.permissionMatches)
      for permissionMatch in self.permissionMatches:
        if self.CheckPermissionMatch(permission, permissionMatch):
          requiredMatches -= 1
          if requiredMatches == 0:
            if self.permissionMatchKeep:
              matchingPermissions.append(permission)
            break
      else:
        if not self.permissionMatchKeep:
          matchingPermissions.append(permission)
    return matchingPermissions

  def CheckPermissionMatches(self, permissions):
    if not self.permissionMatches:
      return True
    requiredMatches = 1 if self.permissionMatchOr else len(self.permissionMatches)
    for permission in permissions:
      for permissionMatch in self.permissionMatches:
        if self.CheckPermissionMatch(permission, permissionMatch):
          requiredMatches -= 1
          if requiredMatches == 0:
            return self.permissionMatchKeep
    return not self.permissionMatchKeep

def noFileSelectFileIdEntity(fileIdEntity):
  return (not fileIdEntity
          or (not fileIdEntity['dict']
              and not fileIdEntity['query']
              and not fileIdEntity['shareddrivefilequery']
              and not fileIdEntity['list']))

SHOW_OWNED_BY_CHOICE_MAP = {'any': None, 'me': True, 'others': False}

class DriveListParameters():
  def __init__(self, myargOptions):
    self.PM = PermissionMatch()
    self.myargOptions = myargOptions
    self.checkLocation = None
    self.excludeTrashed = False
    self.filenameMatchPattern = None
    self.getSharedDriveNames = False
    self.kwargs = {}
    self.fileIdEntity = {}
    self.locationFileIds = []
    self.locationSet = False
    self.maxItems = 0
    self.mimeTypeCheck = MimeTypeCheck()
    self.maximumFileSize = None
    self.minimumFileSize = None
    self.onlySharedDrives = False
    self.queryTimes = {}
    self.showOwnedBy = True
    self.showSharedByMe = None

  SHOW_SHARED_BY_ME_CHOICE_MAP = {'any': None, 'true': True, 'false': False}

  def ProcessArgument(self, myarg, fileIdEntity):
    if myarg == 'maxfiles':
      self.maxItems = getInteger(minVal=0)
    elif myarg == 'maximumfilesize':
      self.maximumFileSize = getInteger(minVal=0)
    elif myarg == 'minimumfilesize':
      self.minimumFileSize = getInteger(minVal=0)
    elif myarg == 'showsharedbyme':
      self.showSharedByMe = getChoice(self.SHOW_SHARED_BY_ME_CHOICE_MAP, mapChoice=True)
    elif myarg == 'filenamematchpattern':
      self.filenameMatchPattern = getREPattern(re.IGNORECASE)
    elif self.PM.ProcessArgument(myarg):
      pass
    elif myarg == 'anyowner':
      self.showOwnedBy = None
      self.UpdateAnyOwnerQuery()
    elif myarg == 'showownedby':
      self.showOwnedBy = getChoice(SHOW_OWNED_BY_CHOICE_MAP, mapChoice=True)
      self.UpdateQueryWithShowOwnedBy()
    elif myarg == 'showmimetype':
      self.mimeTypeCheck.Get()
      if self.myargOptions['mimeTypeInQuery']:
        self.AppendToQuery(self.mimeTypeCheck.AddMimeTypeToQuery(self.fileIdEntity.get('query', '')))
    elif myarg == 'excludetrashed':
      self.excludeTrashed = True
    elif myarg.startswith('querytime'):
      self.queryTimes[myarg] = getTimeOrDeltaFromNow()
    elif noFileSelectFileIdEntity(fileIdEntity):
      if self.myargOptions['allowQuery'] and myarg == 'query':
        self.AppendToQuery(getString(Cmd.OB_QUERY))
      elif self.myargOptions['allowQuery'] and myarg.startswith('query:'):
        self.AppendToQuery(Cmd.Previous().strip()[6:])
      elif self.myargOptions['allowQuery'] and myarg == 'fullquery':
        self.SetQuery(getString(Cmd.OB_QUERY, minLen=0))
      elif self.myargOptions['allowQuery'] and myarg in QUERY_SHORTCUTS_MAP:
        self.UpdateAnyOwnerQuery()
        self.AppendToQuery(QUERY_SHORTCUTS_MAP[myarg])
      elif self.myargOptions['allowChoose'] and myarg == 'choose':
        myarg = checkGetArgument()
        if myarg in DRIVE_BY_NAME_CHOICE_MAP:
          self.SetQuery(DRIVE_BY_NAME_CHOICE_MAP[myarg].format(getEscapedDriveFileName()))
        elif myarg in LOCATION_CHOICE_MAP:
          self.locationSet = True
          self.SetLocation(LOCATION_CHOICE_MAP[myarg])
        elif myarg.find(':') > 0:
          kw, value = myarg.split(':', 1)
          if kw in DRIVE_BY_NAME_CHOICE_MAP:
            self.SetQuery(DRIVE_BY_NAME_CHOICE_MAP[kw].format(escapeDriveFileName(value)))
          else:
            invalidChoiceExit(myarg, list(DRIVE_BY_NAME_CHOICE_MAP), True)
        else:
          invalidChoiceExit(myarg, list(DRIVE_BY_NAME_CHOICE_MAP)+list(LOCATION_CHOICE_MAP), True)
      elif self.myargOptions['allowCorpora'] and myarg == 'corpora':
        corpora = getChoice(CORPORA_CHOICE_MAP)
        self.kwargs['corpora'] = CORPORA_CHOICE_MAP[corpora]
        self.kwargs['includeItemsFromAllDrives'] = self.kwargs['supportsAllDrives'] = True
        self.onlySharedDrives = corpora in {'onlyshareddrives', 'onlyteamdrives'}
        self.getSharedDriveNames = True
        self.UpdateAnyOwnerQuery()
        self.SetLocationFileIDsList(LOCATION_CHOICE_MAP['alldrives' if not self.onlySharedDrives else 'onlyshareddrives'])
      else:
        return False
    else:
      if (myarg == 'query' or
          myarg.startswith('query:') or
          myarg == 'fullquery' or
          myarg in QUERY_SHORTCUTS_MAP or
          myarg in DRIVE_BY_NAME_CHOICE_MAP):
        usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format('select', myarg))
      return False
    return True

  def InitDriveIdEntity(self):
    if not self.fileIdEntity:
      self.fileIdEntity = initDriveFileEntity()
      self.fileIdEntity['query'] = ME_IN_OWNERS

  def AppendToQuery(self, query):
    self.InitDriveIdEntity()
    if self.fileIdEntity['query']:
      self.fileIdEntity['query'] += ' and ('+query+')'
    else:
      self.fileIdEntity['query'] = query

  def SetQuery(self, query):
    self.InitDriveIdEntity()
    self.fileIdEntity['query'] = query

  def UpdateAnyOwnerQuery(self):
    self.InitDriveIdEntity()
    self.fileIdEntity['query'] = _updateAnyOwnerQuery(self.fileIdEntity['query'])

  def UpdateQueryWithShowOwnedBy(self):
    self.InitDriveIdEntity()
    if self.showOwnedBy is None:
      self.fileIdEntity['query'] = _updateAnyOwnerQuery(self.fileIdEntity['query'])
    elif not self.showOwnedBy:
      if self.fileIdEntity['query'].find(NOT_ME_IN_OWNERS) >= 0:
        pass
      else:
        self.fileIdEntity['query'] = _stripMeInOwners(self.fileIdEntity['query'])
        if self.fileIdEntity['query']:
          self.fileIdEntity['query'] = NOT_ME_IN_OWNERS_AND+self.fileIdEntity['query']
        else:
          self.fileIdEntity['query'] = NOT_ME_IN_OWNERS
    else:
      if self.fileIdEntity['query'].find(ME_IN_OWNERS) >= 0:
        pass
      else:
        self.fileIdEntity['query'] = _stripNotMeInOwners(self.fileIdEntity['query'])
        if self.fileIdEntity['query']:
          self.fileIdEntity['query'] = ME_IN_OWNERS_AND+self.fileIdEntity['query']
        else:
          self.fileIdEntity['query'] = ME_IN_OWNERS

  def CheckShowOwnedBy(self, fileInfo):
    return self.showOwnedBy is None or fileInfo.get('ownedByMe', self.showOwnedBy) == self.showOwnedBy

  def CheckShowSharedByMe(self, fileInfo):
    return self.showSharedByMe is None or (fileInfo.get('shared', self.showSharedByMe) == self.showSharedByMe and fileInfo.get('ownedByMe', False))

  def SetLocationFileIDsList(self, location):
    self.locationFileIds = location['fileids']
    self.checkLocation = location['location']
    self.InitDriveIdEntity()
    cleanFileIDsList(self.fileIdEntity, self.locationFileIds)

  def SetLocation(self, location):
    self.SetLocationFileIDsList(location)
    if self.checkLocation in {LOCATION_ALL_DRIVES, LOCATION_ONLY_SHARED_DRIVES}:
      self.kwargs = {'corpora': 'allDrives', 'includeItemsFromAllDrives': True, 'supportsAllDrives': True}
      self.showOwnedBy = None
      self.fileIdEntity['query'] = ''
      self.onlySharedDrives = self.checkLocation == LOCATION_ONLY_SHARED_DRIVES
    elif location['setShowOwnedBy']:
      self.showOwnedBy = location['owner']
      if self.showOwnedBy is None:
        self.fileIdEntity['query'] = ''
      elif self.showOwnedBy:
        self.fileIdEntity['query'] = ME_IN_OWNERS
      else:
        self.fileIdEntity['query'] = NOT_ME_IN_OWNERS
    else:
      if self.fileIdEntity['query'].find(NOT_ME_IN_OWNERS) >= 0:
        self.fileIdEntity['query'] = NOT_ME_IN_OWNERS
      elif self.fileIdEntity['query'].find(ME_IN_OWNERS) >= 0:
        self.fileIdEntity['query'] = ME_IN_OWNERS

  def SetShowOwnedBy(self, showOwnedBy):
    self.showOwnedBy = showOwnedBy

  def GetFileIdEntity(self):
    if not self.fileIdEntity:
      self.fileIdEntity = initDriveFileEntity()
      self.fileIdEntity['query'] = ME_IN_OWNERS
    return self.fileIdEntity

  def AddMimeTypeToQuery(self):
    if not self.fileIdEntity:
      self.fileIdEntity = initDriveFileEntity()
    if self.mimeTypeCheck.mimeTypes or self.mimeTypeCheck.category:
      self.fileIdEntity['query'] = self.mimeTypeCheck.AddMimeTypeToQuery(self.fileIdEntity['query'])

  def Finalize(self, fileIdEntity):
    self.fileIdEntity.setdefault('query', '')
    if self.excludeTrashed:
      self.AppendToQuery('trashed=false')
    if self.fileIdEntity['query']:
      for queryTimeName, queryTimeValue in iter(self.queryTimes.items()):
        self.fileIdEntity['query'] = self.fileIdEntity['query'].replace(f'#{queryTimeName}#', queryTimeValue)
      self.fileIdEntity['query'] = _mapDrive2QueryToDrive3(self.fileIdEntity['query'])
    if not fileIdEntity.get('shareddrive'):
      if self.fileIdEntity['query']:
        if self.fileIdEntity['query'].find(NOT_ME_IN_OWNERS) >= 0 or (not self.showOwnedBy and self.showOwnedBy is not None):
          if not self.locationFileIds:
            self.SetLocationFileIDsList(LOCATION_CHOICE_MAP['ownedbyothers'])
          self.SetShowOwnedBy(False)
        elif self.fileIdEntity['query'].find(ME_IN_OWNERS) >= 0 or self.showOwnedBy:
          if not self.locationFileIds:
            self.SetLocationFileIDsList(LOCATION_CHOICE_MAP['ownedbyme'])
          self.SetShowOwnedBy(True)
        else:
          if not self.locationFileIds:
            self.SetLocationFileIDsList(LOCATION_CHOICE_MAP['ownedbyany'])
          self.SetShowOwnedBy(None)
      else:
        if not self.locationFileIds:
          self.SetLocationFileIDsList(LOCATION_CHOICE_MAP['ownedbyany'])
        self.SetShowOwnedBy(None)
    else:
      self.UpdateAnyOwnerQuery()
#      if not self.locationFileIds:
#        self.SetLocationFileIDsList(LOCATION_CHOICE_MAP['onlyshareddrives'])
      self.SetShowOwnedBy(None)

  def GetLocationFileIdsFromTree(self, fileTree, fileIdEntity):
    cleanList = []
    for fileId in self.locationFileIds:
      if fileId == ROOT or (fileId in fileTree and fileTree[fileId]['children']):
        cleanList.append(fileId)
    cleanFileIDsList(fileIdEntity, cleanList)

  def CheckExcludeTrashed(self, fileInfo):
    return not self.excludeTrashed or not fileInfo.get('trashed', False)

  def CheckFilenameMatch(self, fileInfo):
    return not self.filenameMatchPattern or self.filenameMatchPattern.match(fileInfo['name'])

  def CheckMimeType(self, fileInfo):
    return self.mimeTypeCheck.Check(fileInfo)

  def CheckFileSize(self, fileInfo, sizeField):
    size = int(fileInfo.get(sizeField, '0'))
    if self.minimumFileSize is not None and size < self.minimumFileSize:
      return False
    if self.maximumFileSize is not None and size > self.maximumFileSize:
      return False
    return True

  def CheckOnlySharedDrives(self, fileInfo):
    return not self.onlySharedDrives or fileInfo.get('driveId') is not None

  def CheckFilePermissionMatches(self, fileInfo):
    return self.PM.CheckPermissionMatches(fileInfo.get('permissions', []))

  def GetFileMatchingPermission(self, fileInfo):
    return self.PM.GetMatchingPermissions(fileInfo.get('permissions', []))

def _getGettingEntity(user, fileIdEntity):
  driveId = fileIdEntity.get('shareddrive', {}).get('driveId', None)
  if not driveId:
    return user
  return f"{user} on {Ent.Singular(Ent.SHAREDDRIVE_ID)}: {driveId}"

OWNED_BY_ME_FIELDS_TITLES = ['ownedByMe']
FILELIST_FIELDS_TITLES = ['id', 'name', 'mimeType', 'parents']
DRIVE_INDEXED_TITLES = ['parents', 'path', 'permissions']
CHECK_LOCATION_FIELDS_TITLES = ['driveId', 'id', 'mimeType', 'ownedByMe', 'parents', 'sharedWithMeTime', 'shared']

FILECOUNT_SUMMARY_NONE = 0
FILECOUNT_SUMMARY_ONLY = -1
FILECOUNT_SUMMARY_PLUS = 1
FILECOUNT_SUMMARY_CHOICE_MAP = {
  'none': FILECOUNT_SUMMARY_NONE,
  'only': FILECOUNT_SUMMARY_ONLY,
  'plus': FILECOUNT_SUMMARY_PLUS
  }
FILECOUNT_SUMMARY_USER = 'Summary'

SIZE_FIELD_CHOICE_MAP = {
  'size': 'size',
  'quotabytesused': 'quotaBytesUsed'
  }

# gam <UserTypeEntity> print filelist [todrive <ToDriveAttribute>*]
#	[((query <QueryDriveFile>) | (fullquery <QueryDriveFile>) | <DriveFileQueryShortcut>) (querytime<String> <Time>)*]
#	[continueoninvalidquery [<Boolean>]]
#	[choose <DriveFileNameEntity>|<DriveFileEntityShortcut>]
#	[corpora <CorporaAttribute>]
#	[select <DriveFileEntity> [selectsubquery <QueryDriveFile>]
#	    [(norecursion [<Boolean>])|(depth <Number>)] [showparent]]
#	[anyowner|(showownedby any|me|others)]
#	[showmimetype [not] <MimeTypeList>] [showmimetype category <MimeTypeNameList>] [mimetypeinquery [<Boolean>]]
#	[sizefield quotabytesused|size] [minimumfilesize <Integer>] [maximumfilesize <Integer>]
#	[filenamematchpattern <REMatchPattern>]
#	<PermissionMatch>* [<PermissionMatchMode>] [<PermissionMatchAction>] [pmfilter] [oneitemperrow]
#	[excludetrashed]
#	[maxfiles <Integer>] [nodataheaders <String>]
#	[countsonly [summary none|only|plus] [summaryuser <String>]
#		    [showsource] [showsize] [showmimetypesize]]
#	[countsrowfilter]
#	[filepath|fullpath [folderpathonly [<Boolean>]] [pathdelimiter <Character>] [addpathstojson] [showdepth]] [buildtree]
#	[allfields|<DriveFieldName>*|(fields <DriveFieldNameList>)]
#	[showdrivename] [showshareddrivepermissions]
#	(showlabels details|ids)|(includelabels <DriveLabelIDList>)]
#	[showparentsidsaslist] [showpermissionslast]
#	(orderby <DriveFileOrderByFieldName> [ascending|descending])* [delimiter <Character>]
#	[stripcrsfromname]
#	(addcsvdata <FieldName> <String>)*
#	[formatjson [quotechar <Character>]]
def printFileList(users):
  def _setSelectionFields():
    if fileIdEntity or filepath:
      _setSkipObjects(skipObjects, FILELIST_FIELDS_TITLES, DFF.fieldsList)
    if DLP.showOwnedBy is not None:
      _setSkipObjects(skipObjects, OWNED_BY_ME_FIELDS_TITLES, DFF.fieldsList)
    if DLP.showSharedByMe is not None or DLP.checkLocation is not None:
      _setSkipObjects(skipObjects, CHECK_LOCATION_FIELDS_TITLES, DFF.fieldsList)
    if DLP.mimeTypeCheck.mimeTypes:
      _setSkipObjects(skipObjects, ['mimeType'], DFF.fieldsList)
    if countsOnly:
      skipObjects.discard('mimeType')
      if 'mimeType' not in DFF.fieldsList:
        DFF.fieldsList.append('mimeType')
      skipObjects.discard(sizeField)
      if showSize and sizeField not in DFF.fieldsList:
        DFF.fieldsList.append(sizeField)
    if (DLP.minimumFileSize is not None) or (DLP.maximumFileSize is not None):
      _setSkipObjects(skipObjects, [sizeField], DFF.fieldsList)
    if DLP.filenameMatchPattern or showParent:
      _setSkipObjects(skipObjects, ['name'], DFF.fieldsList)
    if DLP.excludeTrashed:
      _setSkipObjects(skipObjects, ['trashed'], DFF.fieldsList)
    if DLP.PM.permissionMatches:
      for field in DFF.fieldsList:
        if field.startswith('permissions'):
          break
      else:
        skipObjects.add('permissions')
      if 'permissions' not in DFF.fieldsList:
        for field in DLP.PM.permissionFields:
          permfield = 'permissions.'+field
          if permfield not in DFF.fieldsList:
            DFF.fieldsList.append(permfield)
    if DLP.onlySharedDrives or getPermissionsForSharedDrives or DFF.showSharedDriveNames:
      _setSkipObjects(skipObjects, ['driveId'], DFF.fieldsList)

  def _printFileInfoRow(baserow, fileInfo):
    row = baserow.copy()
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(flattenJSON(fileInfo, flattened=row, skipObjects=skipObjects, timeObjects=timeObjects,
                                       simpleLists=simpleLists, delimiter=delimiter))
    else:
      row['JSON'] = json.dumps(cleanJSON(fileInfo, skipObjects=skipObjects, timeObjects=timeObjects),
                               ensure_ascii=False, sort_keys=True)
      csvPF.WriteRowTitlesJSONNoFilter(row)

  def _printFileInfo(drive, user, f_file, cleanFileName):
    nonlocal getSharedDriveACLsCount, getSharedDriveACLsCountMsg
    driveId = f_file.get('driveId')
    checkSharedDrivePermissions = getPermissionsForSharedDrives and driveId and 'permissions' not in f_file
    if (f_file.get('noDisplay', False) or
        not DLP.CheckShowOwnedBy(f_file) or
        not DLP.CheckShowSharedByMe(f_file) or
        not DLP.CheckExcludeTrashed(f_file) or
        not DLP.CheckMimeType(f_file) or
        not DLP.CheckFileSize(f_file, sizeField) or
        not DLP.CheckFilenameMatch(f_file) or
        (not checkSharedDrivePermissions and not DLP.CheckFilePermissionMatches(f_file)) or
        (DLP.onlySharedDrives and not driveId)):
      return
    if checkSharedDrivePermissions:
      if not incrementalPrint:
        getSharedDriveACLsCount += 1
        if getSharedDriveACLsCount % 100 == 0:
          writeStderr(f'{Msg.GOT} {getSharedDriveACLsCount} {getSharedDriveACLsCountMsg}')
      try:
        f_file['permissions'] = callGAPIpages(drive.permissions(), 'list', 'permissions',
                                              throwReasons=GAPI.DRIVE3_GET_ACL_REASONS,
                                              retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                              fileId=f_file['id'], fields=permissionsFields, supportsAllDrives=True)
        if not DLP.CheckFilePermissionMatches(f_file):
          return
        for permission in f_file['permissions']:
          permission.pop('teamDrivePermissionDetails', None)
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError,
              GAPI.insufficientAdministratorPrivileges, GAPI.insufficientFilePermissions,
              GAPI.unknownError, GAPI.invalid,
              GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy):
        pass
    row = {'Owner': user}
    fileInfo = f_file.copy()
    if cleanFileName:
      fileInfo['name'] = _stripControlCharsFromName(fileInfo['name'])
    if not pmselect and 'permissions' in fileInfo:
      fileInfo['permissions'] = DLP.GetFileMatchingPermission(fileInfo)
    if DFF.showSharedDriveNames and driveId:
      fileInfo['driveName'] = _getSharedDriveNameFromId(drive, driveId)
    if filepath:
      if not FJQC.formatJSON or not addPathsToJSON:
        addFilePathsToRow(drive, fileTree, fileInfo, filePathInfo, csvPF, row,
                          fullpath=fullpath, showDepth=showDepth, folderPathOnly=folderPathOnly)
      else:
        addFilePathsToInfo(drive, fileTree, fileInfo, filePathInfo, folderPathOnly=folderPathOnly)
    if showParentsIdsAsList and 'parents' in fileInfo:
      fileInfo['parentsIds'] = fileInfo.pop('parents')
    if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES]:
      _mapDriveFieldNames(fileInfo, user, DFF.parentsSubFields, False)
    else:
      _mapDriveParents(fileInfo, DFF.parentsSubFields)
      _mapDriveProperties(fileInfo)
      for permission in fileInfo.get('permissions', []):
        _mapDrivePermissionNames(permission)
    if showParentsIdsAsList and 'parentsIds' in fileInfo:
      fileInfo['parents'] = len(fileInfo['parentsIds'])
    if addCSVData:
      fileInfo.update(addCSVData)
    if not countsOnly:
      if not oneItemPerRow or 'permissions' not in fileInfo:
        if not FJQC.formatJSON:
          csvPF.WriteRowTitles(flattenJSON(fileInfo, flattened=row, skipObjects=skipObjects, timeObjects=timeObjects,
                                           simpleLists=simpleLists, delimiter=delimiter))
        else:
          if 'id' in fileInfo:
            row['id'] = fileInfo['id']
          if fileNameTitle in fileInfo:
            row[fileNameTitle] = fileInfo[fileNameTitle]
          if 'owners' in fileInfo:
            flattenJSON({'owners': fileInfo['owners']}, flattened=row, skipObjects=skipObjects)
          row['JSON'] = json.dumps(cleanJSON(fileInfo, skipObjects=skipObjects, timeObjects=timeObjects),
                                   ensure_ascii=False, sort_keys=True)
          csvPF.WriteRowTitlesJSONNoFilter(row)
      else:
        baserow = row.copy()
        if 'id' in fileInfo:
          baserow['id'] = fileInfo['id']
        if fileNameTitle in fileInfo:
          baserow[fileNameTitle] = fileInfo[fileNameTitle]
        if 'owners' in fileInfo:
          flattenJSON({'owners': fileInfo['owners']}, flattened=baserow, skipObjects=skipObjects)
        for permission in fileInfo.pop('permissions'):
          fileInfo['permission'] = permission
          pdetails = fileInfo['permission'].pop('permissionDetails', [])
          if not pdetails:
            _printFileInfoRow(baserow, fileInfo)
          else:
            for pdetail in pdetails:
              fileInfo['permission']['permissionDetails'] = pdetail
              _printFileInfoRow(baserow, fileInfo)
    else:
      if not countsRowFilter:
        csvPFco.UpdateMimeTypeCounts(flattenJSON(fileInfo, flattened=row, skipObjects=skipObjects, timeObjects=timeObjects,
                                                 simpleLists=simpleLists, delimiter=delimiter), mimeTypeInfo, sizeField)
      else:
        mimeTypeInfo.setdefault(fileInfo['mimeType'], {'count': 0, 'size': 0})
        mimeTypeInfo[fileInfo['mimeType']]['count'] += 1
        mimeTypeInfo[fileInfo['mimeType']]['size'] += int(fileInfo.get(sizeField, '0'))

  def _printChildDriveFolderContents(drive, fileEntry, user, i, count, depth):
    parentFileEntry = fileTree.get(fileEntry['id'])
    if parentFileEntry and 'children' in parentFileEntry:
      for childFileId in parentFileEntry['children']:
        childEntry = fileTree.get(childFileId)
        if childEntry:
          if not DLP.CheckExcludeTrashed(childEntry['info']):
            continue
          if childFileId not in filesPrinted:
            filesPrinted.add(childFileId)
            # Don't show My Drive/Shared Drive unless asked when parent is 'SharedDrives'
            if showParent or parentFileEntry['info']['id'] != SHARED_DRIVES:
              _printFileInfo(drive, user, childEntry['info'].copy(), False)
          if childEntry['info']['mimeType'] == MIMETYPE_GA_FOLDER and (maxdepth == -1 or depth < maxdepth):
            _printChildDriveFolderContents(drive, childEntry['info'], user, i, count, depth+1)
      return
    q = WITH_PARENTS.format(fileEntry['id'])
    if selectSubQuery:
      q += ' and ('+selectSubQuery+')'
    if depth == 0:
      printGettingAllEntityItemsForWhom(Ent.DRIVE_FILE_OR_FOLDER, user, i, count, query=q)
      pageMessage = getPageMessageForWhom()
    else:
      setGettingAllEntityItemsForWhom(Ent.DRIVE_FILE_OR_FOLDER, user, query=q)
      pageMessage = getPageMessageForWhom(clearLastGotMsgLen=False)
    try:
      children = callGAPIpages(drive.files(), 'list', 'files',
                               pageMessage=pageMessage, noFinalize=True,
                               throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID,
                                                                           GAPI.BAD_REQUEST],
                               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS+[GAPI.UNKNOWN_ERROR],
                               q=q, orderBy=DFF.orderBy, includeLabels=includeLabels, fields=pagesFields,
                               pageSize=GC.Values[GC.DRIVE_MAX_RESULTS], includeItemsFromAllDrives=True, supportsAllDrives=True)
      for childEntryInfo in children:
        childFileId = childEntryInfo['id']
        if showLabels is not None:
          labels = callGAPIitems(drive.files(), 'listLabels', 'labels',
                                 throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS+[GAPI.UNKNOWN_ERROR],
                                 fileId=childFileId)
          _formatFileDriveLabels(showLabels, labels, childEntryInfo, True, delimiter)
        if filepath:
          fileTree.setdefault(childFileId, {'info': childEntryInfo})
        if childFileId not in filesPrinted:
          filesPrinted.add(childFileId)
          _printFileInfo(drive, user, childEntryInfo.copy(), stripCRsFromName)
        if childEntryInfo['mimeType'] == MIMETYPE_GA_FOLDER and (maxdepth == -1 or depth < maxdepth):
          _printChildDriveFolderContents(drive, childEntryInfo, user, i, count, depth+1)
    except (GAPI.invalidQuery, GAPI.invalid, GAPI.badRequest):
      entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE, None], invalidQuery(selectSubQuery), i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)

  def writeMimeTypeCountsRow(user, sourceId, sourceName, mimeTypeInfo):
    countTotal = sizeTotal = 0
    for mtinfo in iter(mimeTypeInfo.values()):
      countTotal += mtinfo['count']
      sizeTotal += mtinfo['size']
    row = {'Owner': user, 'Total': countTotal}
    if showSource:
      row['Source'] = sourceId
      row['Name'] = sourceName
    if showSize:
      row['Size'] = sizeTotal
    if addCSVData:
      row.update(addCSVData)
    for mimeType, mtinfo in sorted(iter(mimeTypeInfo.items())):
      row[f'{mimeType}'] = mtinfo['count']
      if showMimeTypeSize:
        row[f'{mimeType}:Size'] = mtinfo['size']
    if not countsRowFilter:
      csvPFco.WriteRowTitlesNoFilter(row)
    else:
      csvPFco.WriteRowTitles(row)

  csvPF = CSVPrintFile('Owner', indexedTitles=DRIVE_INDEXED_TITLES)
  csvPFco = None
  FJQC = FormatJSONQuoteChar(csvPF)
  addPathsToJSON = continueOnInvalidQuery = countsRowFilter = buildTree = countsOnly = filepath = fullpath = folderPathOnly = \
    getPermissionsForSharedDrives = mimeTypeInQuery = noRecursion = oneItemPerRow = stripCRsFromName = \
    showParentsIdsAsList = showDepth = showParent = showSize = showMimeTypeSize = showSource = False
  sizeField = 'quotaBytesUsed'
  pathDelimiter = '/'
  pmselect = True
  showLabels = None
  rootFolderId = ROOT
  rootFolderName = MY_DRIVE
  maxdepth = -1
  nodataFields = []
  simpleLists = ['permissionIds', 'spaces']
  skipObjects = set()
  fileIdEntity = {}
  getSharedDriveACLsCountMsg = selectSubQuery = ''
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  DLP = DriveListParameters({'allowChoose': True, 'allowCorpora': True, 'allowQuery': True, 'mimeTypeInQuery': False})
  DFF = DriveFileFields()
  summary = FILECOUNT_SUMMARY_NONE
  summaryUser = FILECOUNT_SUMMARY_USER
  summaryMimeTypeInfo = {}
  addCSVData = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif DLP.ProcessArgument(myarg, fileIdEntity):
      pass
    elif DFF.ProcessArgument(myarg):
      pass
    elif myarg == 'select':
      if fileIdEntity:
        usageErrorExit(Msg.CAN_NOT_BE_SPECIFIED_MORE_THAN_ONCE.format('select'))
      if not DLP.fileIdEntity:
        fileIdEntity = getDriveFileEntity(DLP=DLP)
      else:
        fileIdEntity = getDriveFileEntitySharedDriveOnly()
    elif myarg == 'selectsubquery':
      selectSubQuery = getString(Cmd.OB_QUERY, minLen=0)
    elif myarg == 'norecursion':
      noRecursion = getBoolean()
    elif myarg == 'mimetypeinquery':
      mimeTypeInQuery = getBoolean()
    elif myarg == 'depth':
      maxdepth = getInteger(minVal=-1)
    elif myarg == 'showdepth':
      showDepth = True
    elif myarg == 'showparent':
      showParent = getBoolean()
    elif myarg == 'nodataheaders':
      nodataFields = getString(Cmd.OB_FIELD_NAME_LIST).replace('_', '').replace(',', ' ').split()
    elif myarg in {'filepath', 'fullpath'}:
      filepath = True
      fullpath = myarg == 'fullpath'
    elif myarg == 'folderpathonly':
      folderPathOnly = getBoolean()
    elif myarg == 'pathdelimiter':
      pathDelimiter = getCharacter()
    elif myarg == 'addpathstojson':
      addPathsToJSON = True
    elif myarg == 'buildtree':
      buildTree = True
    elif myarg == 'countsrowfilter':
      countsRowFilter = True
    elif myarg == 'countsonly':
      countsOnly = True
      csvPFco = CSVPrintFile(['Owner', 'Total'], 'sortall')
      csvPFco.SetZeroBlankMimeTypeCounts(True)
    elif myarg == 'summary':
      summary = getChoice(FILECOUNT_SUMMARY_CHOICE_MAP, mapChoice=True)
    elif myarg == 'summaryuser':
      summaryUser = getString(Cmd.OB_STRING)
    elif myarg == 'showsource':
      showSource = True
      if countsOnly:
        if not showSize:
          csvPFco.SetTitles(['Owner', 'Source', 'Name', 'Total'])
        else:
          csvPFco.SetTitles(['Owner', 'Source', 'Name', 'Total', 'Size'])
        csvPFco.SetSortAllTitles()
    elif myarg in {'showsize', 'showmimetypesize'}:
      showSize = True
      if countsOnly:
        if not showSource:
          csvPFco.SetTitles(['Owner', 'Total', 'Size'])
        else:
          csvPFco.SetTitles(['Owner', 'Source', 'Name', 'Total', 'Size'])
        csvPFco.SetSortAllTitles()
        if myarg == 'showmimetypesize':
          showMimeTypeSize = True
    elif myarg == 'sizefield':
      sizeField = getChoice(SIZE_FIELD_CHOICE_MAP, mapChoice=True)
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    elif myarg == 'showparentsidsaslist':
      showParentsIdsAsList = True
      simpleLists.append('parentsIds')
    elif myarg == 'showpermissionslast':
      csvPF.SetShowPermissionsLast(True)
    elif myarg == 'stripcrsfromname':
      stripCRsFromName = True
    elif myarg == 'showlabels':
      showLabels = getChoice(SHOWLABELS_CHOICES)
    elif myarg == 'showshareddrivepermissions':
      getPermissionsForSharedDrives = True
      permissionsFields = f'nextPageToken,permissions({",".join(DRIVEFILE_BASIC_PERMISSION_FIELDS)})'
      getSharedDriveACLsCountMsg = f'{Ent.Plural(Ent.DRIVE_FILE_OR_FOLDER_ACL)} {Msg.FOR} {Ent.Plural(Ent.SHAREDDRIVE)}\n'
    elif myarg == 'pmfilter':
      pmselect = False
    elif myarg == 'oneitemperrow':
      oneItemPerRow = True
      csvPF.RemoveIndexedTitles('permissions')
    elif myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg == 'continueoninvalidquery':
      continueOnInvalidQuery = getBoolean()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg)
  if not filepath and not fullpath:
    showDepth = False
  noSelect = noFileSelectFileIdEntity(fileIdEntity)
  if noSelect:
    buildTree = True
    if maxdepth != -1 or filepath:
      if not fileIdEntity:
        fileIdEntity = initDriveFileEntity()
        DLP.GetFileIdEntity()
      if not fileIdEntity['shareddrive']:
        cleanFileIDsList(fileIdEntity, [ROOT, ORPHANS])
      if maxdepth != -1:
        noSelect = False
    elif not fileIdEntity:
      fileIdEntity = DLP.GetFileIdEntity()
  elif not buildTree:
    buildTree = (not noRecursion
                 and not fileIdEntity['dict']
                 and not fileIdEntity['query']
                 and not fileIdEntity['shareddrivefilequery']
                 and _simpleFileIdEntityList(fileIdEntity['list']))
  incrementalPrint = buildTree and (not filepath) and noSelect and not DLP.locationSet and not showParent
  if buildTree and ((not filepath) or mimeTypeInQuery) and noSelect and not DLP.locationSet and not showParent:
    DLP.AddMimeTypeToQuery()
  if buildTree:
    if not fileIdEntity.get('shareddrive'):
      btkwargs = DLP.kwargs
    else:
      btkwargs = fileIdEntity['shareddrive']
    DLP.Finalize(fileIdEntity)
  if DLP.PM.permissionMatches:
    getPermissionsForSharedDrives = True
    permissionsFields = f'nextPageToken,permissions({",".join(DRIVEFILE_BASIC_PERMISSION_FIELDS)})'
  elif DFF.fieldsList:
    if not getPermissionsForSharedDrives:
      getPermissionsForSharedDrives, permissionsFields = _setGetPermissionsForSharedDrives(DFF.fieldsList)
  if DFF.fieldsList:
    _setSelectionFields()
    fields = getFieldsFromFieldsList(DFF.fieldsList)
    pagesFields = getItemFieldsFromFieldsList('files', DFF.fieldsList)
  elif not DFF.allFields:
    _setSelectionFields()
    if not countsOnly and not set(DFF.fieldsList)-skipObjects:
      for field in ['name', 'webviewlink']:
        skipObjects.discard(DRIVE_FIELDS_CHOICE_MAP[field])
        csvPF.AddField(field, DRIVE_FIELDS_CHOICE_MAP, DFF.fieldsList)
    fields = getFieldsFromFieldsList(DFF.fieldsList)
    pagesFields = getItemFieldsFromFieldsList('files', DFF.fieldsList)
  else:
    fields = pagesFields = '*'
    DFF.SetAllParentsSubFields()
    skipObjects = skipObjects.union(DEFAULT_SKIP_OBJECTS)
  if stripCRsFromName:
    if (countsOnly and not showSource) or (fields != '*' and 'name' not in DFF.fieldsList):
      stripCRsFromName = False
  shareddriveFields = []
  for field in ['capabilities', 'createdTime']:
    if fields == '*' or field in DFF.fieldsList:
      shareddriveFields.append(field)
  if filepath and not countsOnly:
    csvPF.AddTitles('paths')
    csvPF.SetFixPaths(True)
  includeLabels = ','.join(DFF.includeLabels)
  timeObjects = _getDriveTimeObjects()
  if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES]:
    fileNameTitle = 'title'
    csvPF.SetMapDrive3Titles(True)
  else:
    fileNameTitle = 'name'
  csvPF.RemoveTitles(['capabilities'])
  if DLP.queryTimes and selectSubQuery:
    for queryTimeName, queryTimeValue in iter(DLP.queryTimes.items()):
      selectSubQuery = selectSubQuery.replace(f'#{queryTimeName}#', queryTimeValue)
    selectSubQuery = _mapDrive2QueryToDrive3(selectSubQuery)
  if addCSVData:
    if not countsOnly:
      csvPF.AddTitles(sorted(addCSVData.keys()))
    else:
      csvPFco.AddTitles(sorted(addCSVData.keys()))
      if showSize:
        csvPFco.MoveTitlesToEnd(['Size'])
      csvPFco.MoveTitlesToEnd(['Total'])
      csvPFco.SetSortAllTitles()
  if not nodataFields:
    if DFF.fieldsList:
      if not FJQC.formatJSON:
        nodataFields = ['Owner']+list(set(DFF.fieldsList)-skipObjects)
      else:
        nodataFields = ['Owner', 'id', fileNameTitle, 'owners.emailAddress']
    else:
      nodataFields = ['Owner', 'id', fileNameTitle, 'owners.emailAddress']
      if not FJQC.formatJSON:
        nodataFields.append('permissions')
    if filepath:
      nodataFields.append('paths')
    if FJQC.formatJSON:
      nodataFields.append('JSON')
    csvPF.SetNodataFields(True, nodataFields, DRIVE_LIST_FIELDS, DRIVE_SUBFIELDS_CHOICE_MAP, oneItemPerRow)
  else:
    csvPF.SetNodataFields(False, nodataFields, None, None, False)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, drive = _validateUserSharedDrive(user, i, count, fileIdEntity)
    if not drive:
      continue
    if ((incrementalPrint and countsOnly) or
        (not showParentsIdsAsList and DFF.parentsSubFields['isRoot'])):
      try:
        if not fileIdEntity['shareddrive']:
          result = callGAPI(drive.files(), 'get',
                            throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                            fileId=ROOT, fields='id,name')
          rootFolderId = result['id']
          rootFolderName = result['name']
        else:
          rootFolderId = fileIdEntity['shareddrive']['driveId']
          if not fileIdEntity['shareddrivename']:
            fileIdEntity['shareddrivename'] = callGAPI(drive.drives(), 'get',
                                                     throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FILE_NOT_FOUND],
                                                     driveId=rootFolderId, fields='name')['name']
          rootFolderName = fileIdEntity['shareddrivename']
        if not showParentsIdsAsList and DFF.parentsSubFields['isRoot']:
          DFF.parentsSubFields['rootFolderId'] = rootFolderId
      except (GAPI.notFound, GAPI.fileNotFound) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, fileIdEntity['shareddrive']['driveId']], str(e), i, count)
        continue
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        continue
    if filepath:
      filePathInfo = initFilePathInfo(pathDelimiter)
    filesPrinted = set()
    mimeTypeInfo = {}
    getSharedDriveACLsCount = 0
    if buildTree:
      gettingEntity = _getGettingEntity(user, fileIdEntity)
      printGettingAllEntityItemsForWhom(Ent.DRIVE_FILE_OR_FOLDER, gettingEntity, i, count, query=DLP.fileIdEntity['query'])
      if not incrementalPrint:
        fileTree, status = initFileTree(drive, fileIdEntity.get('shareddrive'), DLP, shareddriveFields, showParent, user, i, count)
        if not status:
          continue
      try:
        feed = yieldGAPIpages(drive.files(), 'list', 'files',
                              pageMessage=getPageMessageForWhom(), maxItems=DLP.maxItems,
                              throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID,
                                                                          GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND,
                                                                          GAPI.NOT_FOUND, GAPI.TEAMDRIVE_MEMBERSHIP_REQUIRED],
                              retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS+[GAPI.UNKNOWN_ERROR],
                              q=DLP.fileIdEntity['query'], orderBy=DFF.orderBy, includeLabels=includeLabels,
                              fields=pagesFields, pageSize=GC.Values[GC.DRIVE_MAX_RESULTS], **btkwargs)
        for files in feed:
          if showLabels is not None:
            for f_file in files:
              labels = callGAPIitems(drive.files(), 'listLabels', 'labels',
                                     throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                                     retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS+[GAPI.UNKNOWN_ERROR],
                                     fileId=f_file['id'])
              _formatFileDriveLabels(showLabels, labels, f_file, True, delimiter)
          if not incrementalPrint:
            extendFileTree(fileTree, files, DLP, stripCRsFromName)
          else:
            for f_file in files:
              if stripCRsFromName:
                f_file['name'] = _stripControlCharsFromName(f_file['name'])
              _printFileInfo(drive, user, f_file, False)
        if incrementalPrint:
          if countsOnly:
            if summary != FILECOUNT_SUMMARY_NONE:
              for mimeType, mtinfo in iter(mimeTypeInfo.items()):
                summaryMimeTypeInfo.setdefault(mimeType, {'count': 0, 'size': 0})
                summaryMimeTypeInfo[mimeType]['count'] += mtinfo['count']
                summaryMimeTypeInfo[mimeType]['size'] += mtinfo['size']
            if summary != FILECOUNT_SUMMARY_ONLY:
              writeMimeTypeCountsRow(user, rootFolderId, rootFolderName, mimeTypeInfo)
          continue
        extendFileTreeParents(drive, fileTree, fields)
        DLP.GetLocationFileIdsFromTree(fileTree, fileIdEntity)
      except (GAPI.invalidQuery, GAPI.invalid, GAPI.badRequest) as e:
        errMsg = str(e)
        if 'Invalid field selection' in errMsg:
          entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER, None], errMsg, i, count)
          break
        entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER, None], invalidQuery(DLP.fileIdEntity['query']), i, count)
        if not continueOnInvalidQuery:
          break
        continue
      except GAPI.fileNotFound:
        printGotEntityItemsForWhom(0)
        continue
      except (GAPI.notFound, GAPI.teamDriveMembershipRequired) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, fileIdEntity['shareddrive']['driveId']], str(e), i, count)
        continue
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        continue
    else:
      fileTree = {}
    user, drive, jcount = _validateUserGetFileIDs(origUser, i, count, fileIdEntity, drive=drive)
    if jcount == 0:
      continue
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      if showSource:
        mimeTypeInfo = {}
      fileEntry = fileTree.get(fileId)
      if fileEntry:
        fileEntryInfo = fileEntry['info']
      else:
        try:
          fileEntryInfo = callGAPI(drive.files(), 'get',
                                   throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                                   fileId=fileId, includeLabels=includeLabels, fields=fields, supportsAllDrives=True)
          if stripCRsFromName:
            fileEntryInfo['name'] = _stripControlCharsFromName(fileEntryInfo['name'])
          if showLabels is not None:
            labels = callGAPIitems(drive.files(), 'listLabels', 'labels',
                                   throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                                   retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS+[GAPI.UNKNOWN_ERROR],
                                   fileId=fileId)
            _formatFileDriveLabels(showLabels, labels, fileEntryInfo, True, delimiter)
          if filepath:
            fileTree[fileId] = {'info': fileEntryInfo}
        except GAPI.fileNotFound:
          entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER, fileId], Msg.NOT_FOUND, j, jcount)
          continue
        except (GAPI.notFound, GAPI.teamDriveMembershipRequired) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, fileIdEntity['shareddrive']['driveId']], str(e), j, jcount)
          continue
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          userDriveServiceNotEnabledWarning(user, str(e), i, count)
          break
      if fullpath:
        getFilePaths(drive, fileTree, fileEntryInfo, filePathInfo, addParentsToTree=True,
                     fullpath=fullpath, showDepth=showDepth, folderPathOnly=folderPathOnly)
      if ((showParent and (fileEntryInfo['id'] not in {ORPHANS, SHARED_WITHME, SHARED_DRIVES})) or
          fileEntryInfo['mimeType'] != MIMETYPE_GA_FOLDER or noRecursion):
        if fileId not in filesPrinted:
          filesPrinted.add(fileId)
          _printFileInfo(drive, user, fileEntryInfo.copy(), False)
      if fileEntryInfo['mimeType'] == MIMETYPE_GA_FOLDER and not noRecursion:
        _printChildDriveFolderContents(drive, fileEntryInfo, user, i, count, 0)
        if GC.Values[GC.SHOW_GETTINGS] and not GC.Values[GC.SHOW_GETTINGS_GOT_NL]:
          writeStderr('\r\n')
          flushStderr()
      if countsOnly:
        if showSource:
          if summary != FILECOUNT_SUMMARY_NONE:
            for mimeType, mtinfo in iter(mimeTypeInfo.items()):
              summaryMimeTypeInfo.setdefault(mimeType, {'count': 0, 'size': 0})
              summaryMimeTypeInfo[mimeType]['count'] += mtinfo['count']
              summaryMimeTypeInfo[mimeType]['size'] += mtinfo['size']
          if summary != FILECOUNT_SUMMARY_ONLY:
            writeMimeTypeCountsRow(user, fileId, fileEntryInfo['name'], mimeTypeInfo)
    if countsOnly:
      if not showSource:
        if summary != FILECOUNT_SUMMARY_NONE:
          for mimeType, mtinfo in iter(mimeTypeInfo.items()):
            summaryMimeTypeInfo.setdefault(mimeType, {'count': 0, 'size': 0})
            summaryMimeTypeInfo[mimeType]['count'] += mtinfo['count']
            summaryMimeTypeInfo[mimeType]['size'] += mtinfo['size']
        if summary != FILECOUNT_SUMMARY_ONLY:
          writeMimeTypeCountsRow(user, 'Various', 'Various', mimeTypeInfo)
  titlePrefix = f'{Cmd.Argument(GM.Globals[GM.ENTITY_CL_START])} {Cmd.Argument(GM.Globals[GM.ENTITY_CL_START]+1)} ' if GM.Globals[GM.CSVFILE][GM.REDIRECT_QUEUE] is None else ''
  if not countsOnly:
    if not csvPF.rows:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
    if not FJQC.formatJSON:
      csvPF.SetSortTitles(['Owner', 'id', fileNameTitle])
    else:
      if 'JSON' in csvPF.JSONtitlesList:
        csvPF.MoveJSONTitlesToEnd(['JSON'])
    csvPF.writeCSVfile(f'{titlePrefix}Drive Files')
  else:
    if not csvPFco.rows:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
    if summary != FILECOUNT_SUMMARY_NONE:
      writeMimeTypeCountsRow(summaryUser, 'Various', 'Various', summaryMimeTypeInfo)
    csvPFco.todrive = csvPF.todrive
    csvPFco.writeCSVfile(f'{titlePrefix}Drive File Counts', not countsRowFilter)

FILECOMMENTS_FIELDS_CHOICE_MAP = {
  'action': 'action',
  'author': 'author',
  'content': 'content',
  'createddate': 'createdTime',
  'createdtime': 'createdTime',
  'deleted': 'deleted',
  'htmlcontent': 'htmlContent',
  'id': 'id',
  'modifieddate': 'modifiedTime',
  'modifiedtime': 'modifiedTime',
  'quotedfilecontent': 'quotedFileContent',
  'reply': 'replies',
  'replies': 'replies',
  'resolved': 'resolved',
  }

FILECOMMENTS_AUTHOR_SUBFIELDS_CHOICE_MAP = {
  'displayname': 'displayName',
  'emailaddress': 'emailAddress',
  'me': 'me',
  'permissionid': 'permissionId',
  'photolink': 'photoLink',
  }

FILECOMMENTS_REPLIES_SUBFIELDS_CHOICE_MAP = {
  'action': 'action',
  'author': 'author',
  'content': 'content',
  'createddate': 'createdTime',
  'createdtime': 'createdTime',
  'deleted': 'deleted',
  'htmlcontent': 'htmlContent',
  'id': 'id',
  'modifieddate': 'modifiedTime',
  'modifiedtime': 'modifiedTime',
  }

FILECOMMENTS_SUBFIELDS_CHOICE_MAP = {
  'author': FILECOMMENTS_AUTHOR_SUBFIELDS_CHOICE_MAP,
  'reply': FILECOMMENTS_REPLIES_SUBFIELDS_CHOICE_MAP,
  'replies': FILECOMMENTS_REPLIES_SUBFIELDS_CHOICE_MAP,
  }

def _getCommentFields(fieldsList):
  for field in _getFieldsList():
    if field.find('.') == -1:
      if field in FILECOMMENTS_FIELDS_CHOICE_MAP:
        addFieldToFieldsList(field, FILECOMMENTS_FIELDS_CHOICE_MAP, fieldsList)
      else:
        invalidChoiceExit(field, FILECOMMENTS_FIELDS_CHOICE_MAP, True)
    else:
      field, subField = field.split('.', 1)
      if field in FILECOMMENTS_SUBFIELDS_CHOICE_MAP:
        if subField.find('.') == -1:
          if subField in FILECOMMENTS_SUBFIELDS_CHOICE_MAP[field]:
            fieldsList.append(f'{FILECOMMENTS_FIELDS_CHOICE_MAP[field]}.{FILECOMMENTS_SUBFIELDS_CHOICE_MAP[field][subField]}')
          else:
            invalidChoiceExit(subField, FILECOMMENTS_SUBFIELDS_CHOICE_MAP[field], True)
        else:
          subField, subSubField = subField.split('.', 1)
          if subField in FILECOMMENTS_SUBFIELDS_CHOICE_MAP[field]:
            if subSubField in FILECOMMENTS_SUBFIELDS_CHOICE_MAP[subField]:
              fieldsList.append(f'{FILECOMMENTS_FIELDS_CHOICE_MAP[field]}.{FILECOMMENTS_SUBFIELDS_CHOICE_MAP[field][subField]}.{FILECOMMENTS_SUBFIELDS_CHOICE_MAP[subField][subSubField]}')
            else:
              invalidChoiceExit(subSubField, FILECOMMENTS_SUBFIELDS_CHOICE_MAP[subField], True)
          else:
            invalidChoiceExit(subField, FILECOMMENTS_SUBFIELDS_CHOICE_MAP[field], True)
      else:
        invalidChoiceExit(field, FILECOMMENTS_SUBFIELDS_CHOICE_MAP, True)

FILECOMMENTS_INDEXED_TITLES = ['replies']
FILECOMMENTS_TIME_OBJECTS = {'createdTime', 'modifiedTime'}

def _stripCommentPhotoLinks(comment):
  if 'author' in comment:
    comment['author'].pop('photoLink', None)
  for reply in comment.get('replies', []):
    if 'author' in reply:
      reply['author'].pop('photoLink', None)

def _showComment(comment, stripPhotoLinks, timeObjects, i=0, count=0, FJQC=None):
  if stripPhotoLinks:
    _stripCommentPhotoLinks(comment)
  if FJQC is not None and FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(comment, timeObjects=FILECOMMENTS_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.DRIVE_FILE_COMMENT, comment['id']], i, count)
  Ind.Increment()
  showJSON(None, comment, ['id'], timeObjects)
  Ind.Decrement()

# gam <UserTypeEntity> show filecomments <DriveFileEntity>
#	[showdeleted] [start <Date>|<Time>] [countsonly|positivecountsonly]
#	[fields <CommentsFieldNameList>] [showphotolinks]
#	[countsonly]
#	[formatjson]
# gam <UserTypeEntity> print filecomments <DriveFileEntity> [todrive <ToDriveAttribute>*]
#	[showdeleted] [start <Date>|<Time>]
#	[fields <CommentsFieldNameList>] [showphotolinks]
#	[countsonly|positivecountsonly]
#	(addcsvdata <FieldName> <String>)*
#	[formatjson [quotechar <Character>]]
def printShowFileComments(users):
  def _printComment(comment, commentId, replyId, baserow):
    row = flattenJSON(comment, flattened=baserow.copy(), timeObjects=timeObjects)
    row['commentId'] = commentId
    row['replyId'] = replyId
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      row = baserow.copy()
      row['commentId'] = commentId
      comment['id'] = commentId
      row['replyId'] = replyId
      if replyId:
        comment['reply']['id'] = replyId
      row['JSON'] = json.dumps(cleanJSON(comment, timeObjects=timeObjects),
                               ensure_ascii=False, sort_keys=True)
      csvPF.WriteRowNoFilter(row)

  csvPF = CSVPrintFile(['User', 'fileId']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  fieldsList = []
  fileIdEntity = getDriveFileEntity()
  countsOnly = positiveCountsOnly = False
  stripPhotoLinks = True
  kwargs = {}
  addCSVData = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'showdeleted':
      kwargs['includeDeleted'] = True
    elif myarg == 'start':
      kwargs['startModifiedTime'] = getTimeOrDeltaFromNow()
    elif myarg == 'showphotolinks':
      stripPhotoLinks = False
    elif myarg == 'fields':
      _getCommentFields(fieldsList)
    elif csvPF and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg == 'countsonly':
      countsOnly = True
    elif myarg == 'positivecountsonly':
      countsOnly = positiveCountsOnly = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg)
  if csvPF:
    if addCSVData:
      csvPF.AddTitles(sorted(addCSVData.keys()))
    if not countsOnly:
      csvPF.AddTitles(['commentId', 'replyId'])
    else:
      csvPF.AddTitles(['comments', 'replies'])
    if FJQC.formatJSON:
      csvPF.AddTitles(['JSON'])
      csvPF.SetJSONTitles(csvPF.titlesList)
  if fieldsList:
    if 'id' not in fieldsList:
      fieldsList.append('id')
    if 'replies' not in fieldsList:
      for field in fieldsList.copy():
        if field.startswith('replies.'):
          fieldsList.append('replies.id')
          break
    fields = getItemFieldsFromFieldsList('comments', fieldsList)
  else:
    fields = '*'
  timeObjects = FILECOMMENTS_TIME_OBJECTS
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity,
                                                  entityType=[Ent.DRIVE_FILE_COMMENT, None][csvPF is not None])
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      try:
        comments = callGAPIpages(drive.comments(), 'list', 'comments',
                                 throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST],
                                 fileId=fileId, fields=fields, **kwargs)
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
              GAPI.badRequest) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_ID, fileId], str(e), j, jcount)
        continue
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
      kcount = len(comments)
      if countsOnly:
        numReplies = 0
        for comment in comments:
          numReplies += len(comment['replies'])
      if not csvPF:
        if countsOnly:
          if not positiveCountsOnly or kcount > 0:
            printKeyValueList([Ent.Singular(Ent.DRIVE_FILE_ID), fileId, 'comments', kcount, 'replies', numReplies])
        else:
          if not FJQC.formatJSON:
            entityPerformActionNumItems([Ent.DRIVE_FILE_ID, fileId], kcount, Ent.DRIVE_FILE_COMMENT, j, jcount)
          Ind.Increment()
          if not countsOnly:
            k = 0
            for comment in comments:
              k += 1
              _showComment(comment, stripPhotoLinks, timeObjects, k, kcount, FJQC)
        Ind.Decrement()
      elif countsOnly:
        if not positiveCountsOnly or kcount > 0:
          row = {'User': user, 'fileId': fileId}
          if addCSVData:
            row.update(addCSVData)
          row['comments'] = kcount
          row['replies'] = numReplies
          csvPF.WriteRowTitles(row)
      elif comments:
        baserow = {'User': user, 'fileId': fileId}
        if addCSVData:
          baserow.update(addCSVData)
        for comment in comments:
          if stripPhotoLinks:
            _stripCommentPhotoLinks(comment)
          commentId = comment.pop('id')
          replies = comment.pop('replies')
          if not replies:
            _printComment(comment, commentId, '', baserow)
          else:
            for reply in replies:
              replyId = reply.pop('id')
              baserow['replyId'] = replyId
              comment['reply'] = reply
              _printComment(comment, commentId, replyId, baserow)
    Ind.Decrement()
  if csvPF:
    csvPF.SetIndexedTitles(FILECOMMENTS_INDEXED_TITLES)
    csvPF.writeCSVfile('Drive File Comments')

# gam <UserTypeEntity> print filepaths <DriveFileEntity> [todrive <ToDriveAttribute>*]
#	(orderby <DriveFileOrderByFieldName> [ascending|descending])*
#	[stripcrsfromname] [oneitemperrow]
#	[fullpath] [folderpathonly [<Boolean>]] [pathdelimiter <Character>]
#	[followshortcuts [<Boolean>]]
# gam <UserTypeEntity> show filepaths <DriveFileEntity>
#	[returnpathonly]
#	(orderby <DriveFileOrderByFieldName> [ascending|descending])*
#	[stripcrsfromname]
#	[fullpath] [folderpathonly [<Boolean>]] [pathdelimiter <Character>]
#	[followshortcuts [<Boolean>]]
def printShowFilePaths(users):
  fileNameTitle = 'title' if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES] else 'name'
  csvPF = CSVPrintFile(['Owner', 'id', fileNameTitle, 'paths'], 'sortall', ['paths']) if Act.csvFormat() else None
  fileIdEntity = getDriveFileEntity()
  fullpath = folderPathOnly = followShortcuts = oneItemPerRow = returnPathOnly = stripCRsFromName = False
  pathDelimiter = '/'
  OBY = OrderBy(DRIVEFILE_ORDERBY_CHOICE_MAP)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'fullpath':
      fullpath = True
    elif myarg == 'folderpathonly':
      folderPathOnly = getBoolean()
    elif myarg == 'pathdelimiter':
      pathDelimiter = getCharacter()
    elif myarg == 'stripcrsfromname':
      stripCRsFromName = True
    elif csvPF is None and myarg == 'returnpathonly':
      returnPathOnly = True
    elif myarg == 'followshortcuts':
      followShortcuts = getBoolean()
    elif csvPF and myarg == 'oneitemperrow':
      oneItemPerRow = True
      csvPF.RemoveTitles('paths')
      csvPF.AddTitles('path')
      csvPF.SetSortAllTitles()
      csvPF.SetIndexedTitles([])
    elif myarg == 'orderby':
      OBY.GetChoice()
    else:
      unknownArgumentExit()
  fieldsList = FILEPATH_FIELDS_TITLES
  if followShortcuts:
    fieldsList.append('shortcutDetails')
  pathFields = ','.join(fieldsList)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    if returnPathOnly:
      GC.Values[GC.SHOW_GETTINGS] = False
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity,
                                                  entityType=[Ent.DRIVE_FILE_OR_FOLDER, None][(csvPF is not None) or returnPathOnly],
                                                  orderBy=OBY.orderBy)
    if jcount == 0:
      continue
    filePathInfo = initFilePathInfo(pathDelimiter)
    if fullpath:
      fileTree, status = initFileTree(drive, fileIdEntity.get('shareddrive'), None, [], True, user, i, count)
      if not status:
        continue
    else:
      fileTree =  None
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      try:
        result = callGAPI(drive.files(), 'get',
                          throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                          fileId=fileId, fields=pathFields, supportsAllDrives=True)
        if followShortcuts and result['mimeType'] == MIMETYPE_GA_SHORTCUT:
          fileId = result['shortcutDetails']['targetId']
          result = callGAPI(drive.files(), 'get',
                            throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                            fileId=fileId, fields=pathFields, supportsAllDrives=True)
        if returnPathOnly and result['mimeType'] == MIMETYPE_GA_FOLDER and result['name'] == MY_DRIVE and not result.get('parents'):
          writeStdout(f'{MY_DRIVE}\n')
          continue
        if stripCRsFromName:
          result['name'] = _stripControlCharsFromName(result['name'])
        driveId = result.get('driveId')
        if driveId:
          if result['mimeType'] == MIMETYPE_GA_FOLDER and result['name'] == TEAM_DRIVE:
            result['name'] = _getSharedDriveNameFromId(drive, driveId)
            if returnPathOnly:
              if fullpath:
                writeStdout(f'{SHARED_DRIVES}/{result["name"]}\n')
              else:
                writeStdout(f'{result["name"]}\n')
              continue
        if fullpath:
          extendFileTree(fileTree, [result], None, False)
          extendFileTreeParents(drive, fileTree, pathFields)
        entityType, paths, _ = getFilePaths(drive, fileTree, result, filePathInfo, addParentsToTree=True,
                                            fullpath=fullpath, folderPathOnly=folderPathOnly)
        if returnPathOnly:
          for path in paths:
            writeStdout(f'{path}\n')
        elif not csvPF:
          kcount = len(paths)
          entityPerformActionNumItems([entityType, f'{result["name"]} ({fileId})'], kcount, Ent.DRIVE_PATH, j, jcount)
          Ind.Increment()
          k = 0
          for path in paths:
            k += 1
            printEntity([Ent.DRIVE_PATH, path], k, kcount)
          Ind.Decrement()
        else:
          if oneItemPerRow:
            if paths:
              for path in paths:
                csvPF.WriteRow({'Owner': user, 'id': fileId, fileNameTitle: result['name'], 'path': path})
            else:
              csvPF.WriteRow({'Owner': user, 'id': fileId, fileNameTitle: result['name']})
          else:
            csvPF.WriteRowTitles(flattenJSON({'paths': paths}, flattened={'Owner': user, 'id': fileId, fileNameTitle: result['name']}))
      except GAPI.fileNotFound:
        entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER_ID, fileId], Msg.DOES_NOT_EXIST, j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('Drive File Paths')

# gam <UserTypeEntity> print fileparenttree <DriveFileEntity> [todrive <ToDriveAttribute>*]
#	[stripcrsfromname]
def printFileParentTree(users):
  fileNameTitle = 'title' if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES] else 'name'
  csvPF = CSVPrintFile(['Owner', 'isBase', 'baseId', 'id', fileNameTitle, 'parentId', 'depth', 'isRoot'], 'sortall')
  fileIdEntity = getDriveFileEntity()
  stripCRsFromName = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'stripcrsfromname':
      stripCRsFromName = True
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity, entityType=Ent.FILE_PARENT_TREE)
    if jcount == 0:
      continue
    try:
      rootId = callGAPI(drive.files(), 'get',
                              throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                              fileId=ROOT, fields='id')['id']
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      continue
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      fileList = []
      baseId = fileId
      while True:
        try:
          result = callGAPI(drive.files(), 'get',
                            throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                            fileId=fileId, fields='id,name,mimeType,parents,driveId', supportsAllDrives=True)
          if stripCRsFromName:
            result['name'] = _stripControlCharsFromName(result['name'])
          result['isRoot'] = False
          if not result.get('parents', []):
            if fileId == rootId:
              result['isRoot'] = True
            else:
              driveId = result.get('driveId')
              if driveId:
                if result['mimeType'] == MIMETYPE_GA_FOLDER and result['name'] == TEAM_DRIVE:
                  result['name'] = _getSharedDriveNameFromId(drive, driveId)
                  result['isRoot'] = True
            result['parents'] = ['']
            fileList.append(result)
            break
          fileList.append(result)
          fileId = result['parents'][0]
        except GAPI.fileNotFound:
          entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER_ID, fileId], Msg.DOES_NOT_EXIST, j, jcount)
          break
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          userDriveServiceNotEnabledWarning(user, str(e), i, count)
          break
      kcount = len(fileList)
      isBase = True
      for result in fileList:
        csvPF.WriteRow({'Owner': user, 'isBase': isBase, 'baseId': baseId, 'id': result['id'], fileNameTitle: result['name'],
                        'parentId': result['parents'][0], 'depth': kcount, 'isRoot': result['isRoot']})
        isBase = False
        kcount -= 1
  csvPF.writeCSVfile('Drive File Parent Tree')

# Last file modification utilities
def _initLastModification():
  return {'lastModifiedFileId': '', 'lastModifiedFileName': '',
          'lastModifiedFileMimeType': '', 'lastModifiedFilePath': '',
          'lastModifyingUser': '', 'lastModifiedTime': NEVER_TIME,
          'fileEntryInfo': {}}

def _checkUpdateLastModifiction(f_file, userLastModification):
  if f_file.get('modifiedTime', NEVER_TIME) > userLastModification['lastModifiedTime'] and 'lastModifyingUser' in f_file:
    userLastModification['lastModifiedFileId'] = f_file['id']
    userLastModification['lastModifiedFileName'] = _stripControlCharsFromName(f_file['name'])
    userLastModification['lastModifiedFileMimeType'] = f_file['mimeType']
    userLastModification['lastModifiedTime'] = f_file['modifiedTime']
    userLastModification['lastModifyingUser'] = f_file['lastModifyingUser'].get('emailAddress',
                                                                                f_file['lastModifyingUser'].get('displayName', UNKNOWN))
    userLastModification['fileEntryInfo'] = f_file.copy()

def _getLastModificationPath(drive, userLastModification, pathDelimiter):
  if userLastModification['fileEntryInfo']:
    filePathInfo = initFilePathInfo(pathDelimiter)
    _, paths, _ = getFilePaths(drive, {}, userLastModification['fileEntryInfo'], filePathInfo)
    userLastModification['lastModifiedFilePath'] = paths[0] if paths else UNKNOWN

def _showLastModification(lastModification):
  printKeyValueList(['lastModifiedFileId', lastModification['lastModifiedFileId']])
  printKeyValueList(['lastModifiedFileName', lastModification['lastModifiedFileName']])
  printKeyValueList(['lastModifiedFileMimeType', lastModification['lastModifiedFileMimeType']])
  printKeyValueList(['lastModifiedFilePath', lastModification['lastModifiedFilePath']])
  printKeyValueList(['lastModifyingUser', lastModification['lastModifyingUser']])
  printKeyValueList(['lastModifiedTime', formatLocalTime(lastModification['lastModifiedTime'])])

def _updateLastModificationRow(row, lastModification):
  row.update({'lastModifiedFileId': lastModification['lastModifiedFileId'],
              'lastModifiedFileName': lastModification['lastModifiedFileName'],
              'lastModifiedFileMimeType': lastModification['lastModifiedFileMimeType'],
              'lastModifiedFilePath': lastModification['lastModifiedFilePath'],
              'lastModifyingUser': lastModification['lastModifyingUser'],
              'lastModifiedTime': formatLocalTime(lastModification['lastModifiedTime'])})

# gam <UserTypeEntity> print filecounts [todrive <ToDriveAttribute>*]
#	[((query <QueryDriveFile>) | (fullquery <QueryDriveFile>) | <DriveFileQueryShortcut>) (querytime<String> <Time>)*]
#	[continueoninvalidquery [<Boolean>]]
#	[corpora <CorporaAttribute>]
#	[select <SharedDriveEntity>]
#	[anyowner|(showownedby any|me|others)]
#	[showmimetype [not] <MimeTypeList>] [showmimetype category <MimeTypeNameList>]
#	[sizefield quotabytesused|size] [minimumfilesize <Integer>] [maximumfilesize <Integer>]
#	[filenamematchpattern <REMatchPattern>]
#	<PermissionMatch>* [<PermissionMatchMode>] [<PermissionMatchAction>]
#	[excludetrashed] (addcsvdata <FieldName> <String>)*
#	[showsize] [showmimetypesize]
#	[showlastmodification] [pathdelimiter <Character>]
#	(addcsvdata <FieldName> <String>)*
#	[summary none|only|plus] [summaryuser <String>]
# gam <UserTypeEntity> show filecounts
#	[((query <QueryDriveFile>) | (fullquery <QueryDriveFile>) | <DriveFileQueryShortcut>) (querytime<String> <Time>)*]
#	[continueoninvalidquery [<Boolean>]]
#	[corpora <CorporaAttribute>]
#	[select <SharedDriveEntity>]
#	[anyowner|(showownedby any|me|others)]
#	[showmimetype [not] <MimeTypeList>] [showmimetype category <MimeTypeNameList>]
#	[sizefield quotabytesused|size] [minimumfilesize <Integer>] [maximumfilesize <Integer>]
#	[filenamematchpattern <REMatchPattern>]
#	<PermissionMatch>* [<PermissionMatchMode>] [<PermissionMatchAction>]
#	[excludetrashed]
#	[showsize] [showmimetypesize]
#	[showlastmodification] [pathdelimiter <Character>]
#	[summary none|only|plus] [summaryuser <String>]
def printShowFileCounts(users):
  def _setSelectionFields():
    if DLP.showOwnedBy is not None:
      fieldsList.extend(OWNED_BY_ME_FIELDS_TITLES)
    if showSize or (DLP.minimumFileSize is not None) or (DLP.maximumFileSize is not None):
      fieldsList.append(sizeField)
    if showLastModification:
      fieldsList.extend(['id,name,modifiedTime,lastModifyingUser(me, displayName, emailAddress),parents'])
    if DLP.filenameMatchPattern:
      fieldsList.append('name')
    if DLP.excludeTrashed:
      fieldsList.append('trashed')
    if DLP.PM.permissionMatches:
      fieldsList.extend(['id', 'permissions'])
    if DLP.onlySharedDrives or getPermissionsForSharedDrives:
      fieldsList.append('driveId')

  def showMimeTypeInfo(user, mimeTypeInfo, sharedDriveId, sharedDriveName, lastModification, i, count):
    if summary != FILECOUNT_SUMMARY_NONE:
      if count != 0:
        for mimeType, mtinfo in iter(mimeTypeInfo.items()):
          summaryMimeTypeInfo.setdefault(mimeType, {'count': 0, 'size': 0})
          summaryMimeTypeInfo[mimeType]['count'] += mtinfo['count']
          summaryMimeTypeInfo[mimeType]['size'] += mtinfo['size']
        if summary == FILECOUNT_SUMMARY_ONLY:
          return
    countTotal = sizeTotal = 0
    for mtinfo in iter(mimeTypeInfo.values()):
      countTotal += mtinfo['count']
      sizeTotal += mtinfo['size']
    if not csvPF:
      if sharedDriveId:
        kvList = [Ent.USER, user, Ent.SHAREDDRIVE, f'{sharedDriveName} ({sharedDriveId})']
      else:
        kvList = [Ent.USER, user]
      dataList = [Ent.Choose(Ent.DRIVE_FILE_OR_FOLDER, countTotal), countTotal]
      if showSize:
        dataList.extend([Ent.Singular(Ent.SIZE), sizeTotal])
      if sharedDriveId:
        dataList.extend(['Item cap', f"{countTotal/SHARED_DRIVE_MAX_FILES_FOLDERS:.2%}"])
      printEntityKVList(kvList, dataList, i, count)
      Ind.Increment()
      if showLastModification:
        _showLastModification(lastModification)
      for mimeType, mtinfo in sorted(iter(mimeTypeInfo.items())):
        if not showMimeTypeSize:
          printKeyValueList([mimeType, mtinfo['count']])
        else:
          printKeyValueList([mimeType, f"{mtinfo['count']}, {mtinfo['size']}"])
      Ind.Decrement()
    else:
      if sharedDriveId:
        row = {'User': user, 'id': sharedDriveId, 'name': sharedDriveName, 'Total': countTotal, 'Item cap': f"{countTotal/SHARED_DRIVE_MAX_FILES_FOLDERS:.2%}"}
      else:
        row = {'User': user, 'Total': countTotal}
      if showSize:
        row['Size'] = sizeTotal
      if showLastModification:
        _updateLastModificationRow(row, lastModification)
      if addCSVData:
        row.update(addCSVData)
      for mimeType, mtinfo in sorted(iter(mimeTypeInfo.items())):
        row[f'{mimeType}'] = mtinfo['count']
        if showMimeTypeSize:
          row[f'{mimeType}:Size'] = mtinfo['size']
      csvPF.WriteRowTitles(row)

  csvPF = CSVPrintFile() if Act.csvFormat() else None
  if csvPF:
    csvPF.SetZeroBlankMimeTypeCounts(True)
  fieldsList = ['mimeType']
  DLP = DriveListParameters({'allowChoose': False, 'allowCorpora': True, 'allowQuery': True, 'mimeTypeInQuery': True})
  pathDelimiter = '/'
  sharedDriveId = sharedDriveName = ''
  continueOnInvalidQuery = showSize = showLastModification = showMimeTypeSize = False
  sizeField = 'quotaBytesUsed'
  summary = FILECOUNT_SUMMARY_NONE
  summaryUser = FILECOUNT_SUMMARY_USER
  summaryMimeTypeInfo = {}
  fileIdEntity = {}
  addCSVData = {}
  summaryLastModification = _initLastModification()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif DLP.ProcessArgument(myarg, fileIdEntity):
      pass
    elif myarg == 'select':
      if fileIdEntity:
        usageErrorExit(Msg.CAN_NOT_BE_SPECIFIED_MORE_THAN_ONCE.format('select'))
      fileIdEntity = getSharedDriveEntity()
    elif myarg == 'showsize':
      showSize = True
    elif myarg == 'sizefield':
      sizeField = getChoice(SIZE_FIELD_CHOICE_MAP, mapChoice=True)
    elif myarg == 'showlastmodification':
      showLastModification = True
    elif myarg == 'showmimetypesize':
      showMimeTypeSize = showSize = True
    elif myarg == 'summary':
      summary = getChoice(FILECOUNT_SUMMARY_CHOICE_MAP, mapChoice=True)
    elif myarg == 'summaryuser':
      summaryUser = getString(Cmd.OB_STRING)
    elif myarg == 'pathdelimiter':
      pathDelimiter = getCharacter()
    elif csvPF and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg == 'continueoninvalidquery':
      continueOnInvalidQuery = getBoolean()
    else:
      unknownArgumentExit()
  if not fileIdEntity:
    fileIdEntity = DLP.GetFileIdEntity()
  if not fileIdEntity.get('shareddrive'):
    btkwargs = DLP.kwargs
  else:
    btkwargs = fileIdEntity['shareddrive']
    fieldsList.append('driveId')
  DLP.Finalize(fileIdEntity)
  if DLP.PM.permissionMatches:
    getPermissionsForSharedDrives = True
    permissionsFields = 'nextPageToken,permissions'
  else:
    getPermissionsForSharedDrives = False
  _setSelectionFields()
  if csvPF:
    sortTitles = ['User', 'id', 'name', 'Total', 'Item cap'] if fileIdEntity.get('shareddrive') else ['User', 'Total']
    if showSize:
      sortTitles.insert(sortTitles.index('Total')+1, 'Size')
    if showLastModification:
      sortTitles.extend(['lastModifiedFileId', 'lastModifiedFileName',
                         'lastModifiedFileMimeType', 'lastModifiedFilePath',
                         'lastModifyingUser', 'lastModifiedTime'])
    if addCSVData:
      sortTitles.extend(sorted(addCSVData.keys()))
    csvPF.SetTitles(sortTitles)
    csvPF.SetSortAllTitles()
  pagesFields = getItemFieldsFromFieldsList('files', fieldsList)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = _validateUserSharedDrive(user, i, count, fileIdEntity)
    if not drive:
      continue
    sharedDriveId = fileIdEntity.get('shareddrive', {}).get('driveId', '')
    sharedDriveName = _getSharedDriveNameFromId(drive, sharedDriveId) if sharedDriveId else ''
    mimeTypeInfo = {}
    userLastModification = _initLastModification()
    gettingEntity = _getGettingEntity(user, fileIdEntity)
    printGettingAllEntityItemsForWhom(Ent.DRIVE_FILE_OR_FOLDER, gettingEntity, i, count, query=DLP.fileIdEntity['query'])
    try:
      feed = yieldGAPIpages(drive.files(), 'list', 'files',
                            pageMessage=getPageMessageForWhom(),
                            throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID,
                                                                        GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND,
                                                                        GAPI.NOT_FOUND, GAPI.TEAMDRIVE_MEMBERSHIP_REQUIRED],
                            retryReasons=[GAPI.UNKNOWN_ERROR],
                            q=DLP.fileIdEntity['query'],
                            fields=pagesFields, pageSize=GC.Values[GC.DRIVE_MAX_RESULTS], **btkwargs)
      for files in feed:
        for f_file in files:
          driveId = f_file.get('driveId')
          checkSharedDrivePermissions = getPermissionsForSharedDrives and driveId and 'permissions' not in f_file
          if (not DLP.CheckShowOwnedBy(f_file) or
              not DLP.CheckShowSharedByMe(f_file) or
              not DLP.CheckExcludeTrashed(f_file) or
              not DLP.CheckFileSize(f_file, sizeField) or
              not DLP.CheckFilenameMatch(f_file) or
              (not checkSharedDrivePermissions and not DLP.CheckFilePermissionMatches(f_file)) or
              (DLP.onlySharedDrives and not driveId)):
            continue
          if checkSharedDrivePermissions:
            try:
              f_file['permissions'] = callGAPIpages(drive.permissions(), 'list', 'permissions',
                                                    throwReasons=GAPI.DRIVE3_GET_ACL_REASONS+[GAPI.BAD_REQUEST],
                                                    retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                                    fileId=f_file['id'], fields=permissionsFields, supportsAllDrives=True)
              if not DLP.CheckFilePermissionMatches(f_file):
                continue
              for permission in f_file['permissions']:
                permission.pop('teamDrivePermissionDetails', None)
            except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError,
                    GAPI.insufficientAdministratorPrivileges, GAPI.insufficientFilePermissions,
                    GAPI.unknownError, GAPI.invalid, GAPI.badRequest,
                    GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy):
              continue
          mimeTypeInfo.setdefault(f_file['mimeType'], {'count': 0, 'size': 0})
          mimeTypeInfo[f_file['mimeType']]['count'] += 1
          mimeTypeInfo[f_file['mimeType']]['size'] += int(f_file.get(sizeField, '0'))
          if showLastModification:
            _checkUpdateLastModifiction(f_file, userLastModification)
      _getLastModificationPath(drive, userLastModification, pathDelimiter)
      showMimeTypeInfo(user, mimeTypeInfo, sharedDriveId, sharedDriveName, userLastModification, i, count)
      if showLastModification and userLastModification['lastModifiedTime'] > summaryLastModification['lastModifiedTime']:
        summaryLastModification = userLastModification.copy()
    except (GAPI.invalidQuery, GAPI.invalid, GAPI.badRequest):
      entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER, None], invalidQuery(DLP.fileIdEntity['query']), i, count)
      if not continueOnInvalidQuery:
        break
      continue
    except GAPI.fileNotFound:
      printGotEntityItemsForWhom(0)
    except (GAPI.notFound, GAPI.teamDriveMembershipRequired) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, sharedDriveId], str(e), i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      continue
  if summary != FILECOUNT_SUMMARY_NONE:
    showMimeTypeInfo(summaryUser, summaryMimeTypeInfo,
                     '' if count > 1 else sharedDriveId,
                     '' if count > 1 else sharedDriveName,
                     summaryLastModification, 0, 0)
  if csvPF:
    csvPF.writeCSVfile('Drive File Counts')

# gam <UserTypeEntity> print drivelastmodification [todrive <ToDriveAttribute>*]
#	[select <SharedDriveEntity>]
#	[pathdelimiter <Character>]
#	(addcsvdata <FieldName> <String>)*
# gam <UserTypeEntity> show drivelastmodification
#	[select <SharedDriveEntity>]
#	[pathdelimiter <Character>]
def printShowDrivelastModifications(users):
  def showLastModificationInfo(user, sharedDriveId, sharedDriveName, lastModification, i, count):
    if not csvPF:
      if sharedDriveId:
        kvList = [Ent.USER, user, Ent.SHAREDDRIVE, f'{sharedDriveName} ({sharedDriveId})']
      else:
        kvList = [Ent.USER, user]
      printEntity(kvList, i, count)
      Ind.Increment()
      _showLastModification(lastModification)
      Ind.Decrement()
    else:
      if sharedDriveId:
        row = {'User': user, 'id': sharedDriveId, 'name': sharedDriveName}
      else:
        row = {'User': user}
      _updateLastModificationRow(row, lastModification)
      if addCSVData:
        row.update(addCSVData)
      csvPF.WriteRowTitles(row)

  csvPF = CSVPrintFile() if Act.csvFormat() else None
  fieldsList = ['id', 'driveId', 'name', 'mimeType', 'lastModifyingUser', 'modifiedTime', 'parents']
  DLP = DriveListParameters({'allowChoose': False, 'allowCorpora': False, 'allowQuery': False, 'mimeTypeInQuery': True})
  pathDelimiter = '/'
  sharedDriveId = sharedDriveName = ''
  fileIdEntity = {}
  addCSVData = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'select':
      if fileIdEntity:
        usageErrorExit(Msg.CAN_NOT_BE_SPECIFIED_MORE_THAN_ONCE.format('select'))
      fileIdEntity = getSharedDriveEntity()
    elif myarg == 'pathdelimiter':
      pathDelimiter = getCharacter()
    elif csvPF and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    else:
      unknownArgumentExit()
  if not fileIdEntity:
    fileIdEntity = DLP.GetFileIdEntity()
  if not fileIdEntity.get('shareddrive'):
    btkwargs = DLP.kwargs
  else:
    btkwargs = fileIdEntity['shareddrive']
    fieldsList.append('driveId')
  DLP.Finalize(fileIdEntity)
  if csvPF:
    sortTitles = ['User', 'id', 'name'] if fileIdEntity.get('shareddrive') else ['User']
    sortTitles.extend(['lastModifiedFileId', 'lastModifiedFileName',
                       'lastModifiedFileMimeType', 'lastModifiedFilePath',
                       'lastModifyingUser', 'lastModifiedTime'])
    if addCSVData:
      sortTitles.extend(sorted(addCSVData.keys()))
    csvPF.SetTitles(sortTitles)
    csvPF.SetSortAllTitles()
  pagesFields = getItemFieldsFromFieldsList('files', fieldsList)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = _validateUserSharedDrive(user, i, count, fileIdEntity)
    if not drive:
      continue
    sharedDriveId = fileIdEntity.get('shareddrive', {}).get('driveId', '')
    sharedDriveName = _getSharedDriveNameFromId(drive, sharedDriveId) if sharedDriveId else ''
    userLastModification = _initLastModification()
    gettingEntity = _getGettingEntity(user, fileIdEntity)
    printGettingAllEntityItemsForWhom(Ent.DRIVE_FILE_OR_FOLDER, gettingEntity, i, count)
    try:
      feed = yieldGAPIpages(drive.files(), 'list', 'files',
                            pageMessage=getPageMessageForWhom(),
                            throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND,
                                                                        GAPI.NOT_FOUND, GAPI.TEAMDRIVE_MEMBERSHIP_REQUIRED],
                            retryReasons=[GAPI.UNKNOWN_ERROR],
                            fields=pagesFields, pageSize=GC.Values[GC.DRIVE_MAX_RESULTS], **btkwargs)
      for files in feed:
        for f_file in files:
          _checkUpdateLastModifiction(f_file, userLastModification)
      _getLastModificationPath(drive, userLastModification, pathDelimiter)
      showLastModificationInfo(user, sharedDriveId, sharedDriveName, userLastModification, i, count)
    except (GAPI.invalid, GAPI.badRequest) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER, None], str(e), i, count)
      continue
    except GAPI.fileNotFound:
      printGotEntityItemsForWhom(0)
    except (GAPI.notFound, GAPI.teamDriveMembershipRequired) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, sharedDriveId], str(e), i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      continue
  if csvPF:
    csvPF.writeCSVfile('Drive File Last Modification')

DISKUSAGE_SHOW_CHOICES = {'all', 'summary', 'summaryandtrash'}

# gam <UserTypeEntity> print diskusage <DriveFileEntity> [todrive <ToDriveAttribute>*]
#	[anyowner|(showownedby any|me|others)]
#	[sizefield quotabytesused|size]
#	[pathdelimiter <Character>] [excludetrashed] [stripcrsfromname]
#	(addcsvdata <FieldName> <String>)*
#	[noprogress] [show all|summary|summaryandtrash]
def printDiskUsage(users):
  def _getChildDriveFolderInfo(drive, fileEntry, user, i, count, depth):
    fileEntry['depth'] = depth
    q = WITH_PARENTS.format(fileEntry['id'])
    try:
      children = callGAPIpages(drive.files(), 'list', 'files',
                               throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID],
                               retryReasons=[GAPI.UNKNOWN_ERROR],
                               q=q, orderBy=orderBy, fields=pagesFields,
                               pageSize=GC.Values[GC.DRIVE_MAX_RESULTS], supportsAllDrives=True, includeItemsFromAllDrives=True)
    except (GAPI.invalidQuery, GAPI.invalid):
      entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FOLDER, None], invalidQuery(q), i, count)
      return
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      return
    Ind.Increment()
    if showProgress:
      entityActionPerformed([Ent.USER, user, Ent.DRIVE_FOLDER, fileEntry['path']])
    for childEntryInfo in children:
      trashed = childEntryInfo['trashed']
      if trashed and excludeTrashed:
        continue
      mimeType = childEntryInfo.pop('mimeType')
      if mimeType == MIMETYPE_GA_FOLDER:
        fileEntry['directFolderCount'] += 1
        fileEntry['totalFolderCount'] += 1
        if trashed:
          trashFolder['totalFolderCount'] += 1
          if childEntryInfo['explicitlyTrashed']:
            trashFolder['directFolderCount'] += 1
        childEntryInfo['User'] = user
        if includeOwner:
          owners = childEntryInfo.pop('owners', [])
          if owners:
            childEntryInfo['Owner'] = owners[0].get('emailAddress', 'Unknown')
        childEntryInfo.update(zeroFolderInfo)
        if stripCRsFromName:
          childEntryInfo['name'] = _stripControlCharsFromName(childEntryInfo['name'])
        childEntryInfo['path'] = fileEntry['path']+pathDelimiter+childEntryInfo['name']
        childEntryInfo.pop(sizeField, None)
        foldersList.append(childEntryInfo)
        _getChildDriveFolderInfo(drive, childEntryInfo, user, i, count, depth+1)
        fileEntry['totalFileCount'] += childEntryInfo['totalFileCount']
        fileEntry['totalFileSize'] += childEntryInfo['totalFileSize']
        fileEntry['totalFolderCount'] += childEntryInfo['totalFolderCount']
      elif mimeType != MIMETYPE_GA_SHORTCUT:
        if includeOwner and showOwnedBy is not None and childEntryInfo['ownedByMe'] != showOwnedBy:
          continue
        fsize = int(childEntryInfo.get(sizeField, '0'))
        fileEntry['directFileCount'] += 1
        fileEntry['directFileSize'] += fsize
        fileEntry['totalFileCount'] += 1
        fileEntry['totalFileSize'] += fsize
        if trashed:
          trashFolder['totalFileCount'] += 1
          trashFolder['totalFileSize'] += fsize
          if childEntryInfo['explicitlyTrashed']:
            trashFolder['directFileCount'] += 1
            trashFolder['directFileSize'] += fsize
    Ind.Decrement()

  csvPF = CSVPrintFile(['User', 'Owner', 'id', 'name', 'ownedByMe', 'trashed', 'explicitlyTrashed',
                        'directFileCount', 'directFileSize', 'directFolderCount',
                        'totalFileCount', 'totalFileSize', 'totalFolderCount', 'depth', 'path'])
  excludeTrashed = stripCRsFromName = False
  includeOwner = True
  orderBy = 'folder,name'
  zeroFolderInfo = {'directFileCount': 0, 'directFileSize': 0, 'directFolderCount': 0,
                    'totalFileCount': 0, 'totalFileSize': 0, 'totalFolderCount': 0}
  sizeField = 'quotaBytesUsed'
  showOwnedBy = showProgress = True
  pathDelimiter = '/'
  fileIdEntity = getDriveFileEntity()
  addCSVData = {}
  showResults = 'all'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'anyowner':
      showOwnedBy = None
    elif myarg == 'showownedby':
      showOwnedBy = getChoice(SHOW_OWNED_BY_CHOICE_MAP, mapChoice=True)
    elif myarg == 'sizefield':
      sizeField = getChoice(SIZE_FIELD_CHOICE_MAP, mapChoice=True)
    elif myarg == 'pathdelimiter':
      pathDelimiter = getCharacter()
    elif myarg == 'excludetrashed':
      excludeTrashed = True
    elif myarg == 'stripcrsfromname':
      stripCRsFromName = True
    elif myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg == 'show':
      showResults = getChoice(DISKUSAGE_SHOW_CHOICES)
    elif myarg == 'noprogress':
      showProgress = False
    else:
      unknownArgumentExit()
  if addCSVData:
    csvPF.AddTitles(sorted(addCSVData.keys()))
  fieldsList = ['id', 'name', 'mimeType', sizeField, 'trashed', 'explicitlyTrashed', 'owners(emailAddress)', 'ownedByMe']
  pagesFields = getItemFieldsFromFieldsList('files', fieldsList)
  topFieldsList = fieldsList[:]
  topFieldsList.extend(['driveId', 'parents'])
  topFields = getFieldsFromFieldsList(topFieldsList)
  i, count, users = getEntityArgument(users)
  i = 0
  for user in users:
    i += 1
    origUser = user
    user, drive, jcount = _validateUserGetFileIDs(origUser, i, count, fileIdEntity, entityType=Ent.DRIVE_DISK_USAGE)
    if jcount == 0:
      continue
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      foldersList = []
      trashFolder = {'User': user, 'id': 'Trash', 'name': 'Trash', 'path': 'Trash'}
      trashFolder.update(zeroFolderInfo)
      try:
        topFolder = callGAPI(drive.files(), 'get',
                                 throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                                 fileId=fileId, fields=topFields, supportsAllDrives=True)
        if stripCRsFromName:
          topFolder['name'] = _stripControlCharsFromName(topFolder['name'])
        mimeType = topFolder.pop('mimeType')
        if mimeType != MIMETYPE_GA_FOLDER:
          entityValueList = [Ent.USER, user, _getEntityMimeType(topFolder), topFolder['name']]
          entityActionNotPerformedWarning(entityValueList, Msg.INVALID_MIMETYPE.format(mimeType, MIMETYPE_GA_FOLDER), i, count)
          continue
        if topFolder['trashed']:
          if excludeTrashed:
            entityValueList = [Ent.USER, user, Ent.DRIVE_FOLDER, topFolder['name']]
            entityActionNotPerformedWarning(entityValueList, Msg.IN_TRASH_AND_EXCLUDE_TRASHED, i, count)
            continue
          trashFolder['totalFolderCount'] += 1
          if topFolder['explicitlyTrashed']:
            trashFolder['directFolderCount'] += 1
        driveId = topFolder.pop('driveId', None)
        if driveId:
          includeOwner = False
          csvPF.RemoveTitles(['Owner', 'ownedByMe'])
          if topFolder['name'] == TEAM_DRIVE and not topFolder.get('parents'):
            topFolder['name'] = _getSharedDriveNameFromId(drive, driveId)
            topFolder['path'] = f'{SHARED_DRIVES}{pathDelimiter}{topFolder["name"]}'
          else:
            topFolder['path'] = topFolder['name']
        elif topFolder['name'] == MY_DRIVE and not topFolder.get('parents'):
          topFolder['path'] = MY_DRIVE
        else:
          topFolder['path'] = topFolder['name']
        topFolder['User'] = user
        if includeOwner:
          owners = topFolder.pop('owners', [])
          if owners:
            topFolder['Owner'] = owners[0].get('emailAddress', 'Unknown')
            trashFolder['Owner'] = topFolder['Owner']
        topFolder.pop('ownedByMe', None)
        topFolder.pop('parents', None)
        topFolder.update(zeroFolderInfo)
        topFolder.pop(sizeField, None)
        foldersList.append(topFolder)
        _getChildDriveFolderInfo(drive, topFolder, user, i, count, -1)
      except GAPI.fileNotFound:
        entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FOLDER, fileId], Msg.NOT_FOUND, j, jcount)
        continue
      except (GAPI.notFound, GAPI.teamDriveMembershipRequired) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, fileIdEntity['shareddrive']['driveId']], str(e), j, jcount)
        continue
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
      if showResults == 'all':
        for folder in foldersList:
          if addCSVData:
            folder.update(addCSVData)
          csvPF.WriteRow(folder)
      else:
        folder = foldersList[0]
        if addCSVData:
          folder.update(addCSVData)
        csvPF.WriteRow(folder)
      if showResults != 'summary' and not excludeTrashed:
        trashFolder['trashed'] = trashFolder['totalFileCount']+trashFolder['totalFolderCount'] > 0
        trashFolder['explicitlyTrashed'] = trashFolder['directFileCount']+trashFolder['directFolderCount'] > 0
        if addCSVData:
          trashFolder.update(addCSVData)
        trashFolder['depth'] = -1
        csvPF.WriteRow(trashFolder)
  csvPF.writeCSVfile('Drive Disk Usage')

FILESHARECOUNTS_OWNER = 'Owner'
FILESHARECOUNTS_TOTAL = 'Total'
FILESHARECOUNTS_SHARED = 'Shared'
FILESHARECOUNTS_SHARED_EXTERNAL = 'Shared External'
FILESHARECOUNTS_SHARED_INTERNAL = 'Shared Internal'

FILESHARECOUNTS_ZEROCOUNTS = {
  FILESHARECOUNTS_TOTAL: 0,
  FILESHARECOUNTS_SHARED: 0,
  FILESHARECOUNTS_SHARED_EXTERNAL: 0,
  FILESHARECOUNTS_SHARED_INTERNAL: 0,
  'anyone': 0, 'anyoneWithLink': 0,
  'externalDomain': 0, 'externalDomainWithLink': 0,
  'internalDomain': 0, 'internalDomainWithLink': 0,
  'externalGroup': 0, 'internalGroup': 0,
  'externalUser': 0, 'internalUser': 0,
  'deletedGroup': 0, 'deletedUser': 0,
  }

FILESHARECOUNTS_CATEGORIES = {
  'anyone': {False: 'anyone', True: 'anyoneWithLink'},
  'domain': {False: {False: 'externalDomain', True: 'externalDomainWithLink'}, True: {False: 'internalDomain', True: 'internalDomainWithLink'}},
  'group': {False: 'externalGroup', True: 'internalGroup'},
  'user': {False: 'externalUser', True: 'internalUser'},
  'deleted': {'group': 'deletedGroup', 'user': 'deletedUser'},
  }

# gam <UserTypeEntity> print filesharecounts [todrive <ToDriveAttribute>*]
#	[excludetrashed]
#	[internaldomains <DomainNameList>]
#	[summary none|only|plus] [summaryuser <String>]
# gam <UserTypeEntity> show filesharecounts
#	[excludetrashed]
#	[internaldomains <DomainNameList>]
#	[summary none|only|plus] [summaryuser <String>]
def printShowFileShareCounts(users):
  def incrementCounter(counter):
    if not counterSet[counter]:
      userShareCounts[counter] += 1
      counterSet[counter] = True

  def showShareCounts(user, shareCounts, i, count):
    if summary != FILECOUNT_SUMMARY_NONE:
      if count != 0:
        for field, shareCount in iter(shareCounts.items()):
          summaryShareCounts[field] += shareCount
        if summary == FILECOUNT_SUMMARY_ONLY:
          return
    if not csvPF:
      printEntity([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER, shareCounts[FILESHARECOUNTS_TOTAL]], i, count)
      Ind.Increment()
      for field, shareCount in iter(shareCounts.items()):
        printKeyValueList([field, shareCount])
      Ind.Decrement()
    else:
      row = {FILESHARECOUNTS_OWNER: user}
      row.update(shareCounts)
      csvPF.WriteRow(row)

  csvPF = CSVPrintFile([FILESHARECOUNTS_OWNER]+list(FILESHARECOUNTS_ZEROCOUNTS.keys())) if Act.csvFormat() else None
  query =  ME_IN_OWNERS
  summary = FILECOUNT_SUMMARY_NONE
  summaryUser = FILECOUNT_SUMMARY_USER
  fileIdEntity = {}
  internalDomains = [GC.Values[GC.DOMAIN]]
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'excludetrashed':
      query += ' and trashed=false'
    elif myarg == 'internaldomains':
      internalDomains = getString(Cmd.OB_DOMAIN_NAME_LIST).replace(',', ' ').split()
    elif myarg == 'summary':
      summary = getChoice(FILECOUNT_SUMMARY_CHOICE_MAP, mapChoice=True)
    elif myarg == 'summaryuser':
      summaryUser = getString(Cmd.OB_STRING)
    else:
      unknownArgumentExit()
  summaryShareCounts = FILESHARECOUNTS_ZEROCOUNTS.copy()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = _validateUserSharedDrive(user, i, count, fileIdEntity)
    if not drive:
      continue
    userShareCounts = FILESHARECOUNTS_ZEROCOUNTS.copy()
    gettingEntity = _getGettingEntity(user, fileIdEntity)
    printGettingAllEntityItemsForWhom(Ent.DRIVE_FILE_OR_FOLDER, gettingEntity, i, count, query=query)
    try:
      feed = yieldGAPIpages(drive.files(), 'list', 'files',
                            pageMessage=getPageMessageForWhom(),
                            throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                            retryReasons=[GAPI.UNKNOWN_ERROR],
                            q=query, fields='nextPageToken,files(permissions(type,role,emailAddress,domain,allowFileDiscovery,deleted))',
                            pageSize=GC.Values[GC.DRIVE_MAX_RESULTS])
      for files in  feed:
        for f_file in files:
          counterSet = {FILESHARECOUNTS_TOTAL: False, FILESHARECOUNTS_SHARED: False,
                        FILESHARECOUNTS_SHARED_EXTERNAL: False, FILESHARECOUNTS_SHARED_INTERNAL: False}
          for permission in f_file['permissions']:
            if permission['role'] == 'owner':
              incrementCounter(FILESHARECOUNTS_TOTAL)
            else:
              incrementCounter(FILESHARECOUNTS_SHARED)
              ptype = permission['type']
              if ptype == 'anyone':
                incrementCounter(FILESHARECOUNTS_SHARED_EXTERNAL)
                userShareCounts[FILESHARECOUNTS_CATEGORIES[ptype][not permission['allowFileDiscovery']]] += 1
              else:
                domain = permission.get('domain', '')
                if not domain and ptype in ['user', 'group']:
                  if permission.get('deleted', False):
                    userShareCounts[FILESHARECOUNTS_CATEGORIES['deleted'][ptype]] += 1
                    continue
                  emailAddress = permission['emailAddress']
                  domain = emailAddress[emailAddress.find('@')+1:]
                internal = domain in internalDomains
                incrementCounter([FILESHARECOUNTS_SHARED_EXTERNAL, FILESHARECOUNTS_SHARED_INTERNAL][internal])
                if ptype == 'domain':
                  userShareCounts[FILESHARECOUNTS_CATEGORIES[ptype][internal][not permission['allowFileDiscovery']]] +=1
                else: # group, user
                  userShareCounts[FILESHARECOUNTS_CATEGORIES[ptype][internal]] += 1
      showShareCounts(user, userShareCounts, i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      continue
  if summary != FILECOUNT_SUMMARY_NONE:
    showShareCounts(summaryUser, summaryShareCounts, 0, 0)
  if csvPF:
    csvPF.writeCSVfile('Drive File Share Counts')

FILETREE_FIELDS_CHOICE_MAP = {
  'explicitlytrashed': 'explicitlyTrashed',
  'filesize': 'size',
  'id': 'id',
  'mime': 'mimeType',
  'mimetype': 'mimeType',
  'owners': 'owners',
  'parents': 'parents',
  'size': 'size',
  'trashed': 'trashed',
  }

FILETREE_FIELDS_PRINT_ORDER = ['id', 'parents', 'owners', 'mimeType', 'size', 'explicitlyTrashed', 'trashed']

# gam <UserTypeEntity> print filetree [todrive <ToDriveAttribute>*]
#	[select <DriveFileEntity> [selectsubquery <QueryDriveFile>] [depth <Number>]]
#	[anyowner|(showownedby any|me|others)]
#	[showmimetype [not] <MimeTypeList>] [showmimetype category <MimeTypeNameList>]
#	[sizefield quotabytesused|size] [minimumfilesize <Integer>] [maximumfilesize <Integer>]
#	[filenamematchpattern <REMatchPattern>]
#	<PermissionMatch>* [<PermissionMatchMode>] [<PermissionMatchAction>]
#	[excludetrashed]
#	[fields <FileTreeFieldNameList>]
#	(orderby <DriveFileOrderByFieldName> [ascending|descending])* [delimiter <Character>]
#	[noindent] [stripcrsfromname]
# gam <UserTypeEntity> show filetree
#	[select <DriveFileEntity> [selectsubquery <QueryDriveFile>] [depth <Number>]]
#	[anyowner|(showownedby any|me|others)]
#	[showmimetype [not] <MimeTypeList>] [showmimetype category <MimeTypeNameList>]
#	[sizefield quotabytesused|size] [minimumfilesize <Integer>] [maximumfilesize <Integer>]
#	[filenamematchpattern <REMatchPattern>]
#	<PermissionMatch>* [<PermissionMatchMode>] [<PermissionMatchAction>]
#	[excludetrashed]
#	[fields <FileTreeFieldNameList>]
#	(orderby <DriveFileOrderByFieldName> [ascending|descending])* [delimiter <Character>]
#	[stripcrsfromname]
def printShowFileTree(users):
  def _showFileInfo(fileEntry, depth, j=0, jcount=0):
    if not DLP.CheckExcludeTrashed(fileEntry):
      return
    if stripCRsFromName:
      fileEntry['name'] = _stripControlCharsFromName(fileEntry['name'])
    if not csvPF:
      fileInfoList = []
      for field in FILETREE_FIELDS_PRINT_ORDER:
        if showFields[field]:
          if field == 'parents':
            parents = fileEntry.get(field, [])
            fileInfoList.extend([field, f'{len(parents)} [{delimiter.join(parents)}]'])
          elif field == 'owners':
            owners = [owner['emailAddress'] for owner in fileEntry.get(field, [])]
            if owners:
              fileInfoList.extend([field, delimiter.join(owners)])
          elif field in {'explicitlyTrashed', 'trashed'}:
            trashed = fileEntry.get(field, False)
            if trashed:
              fileInfoList.extend([field, trashed])
          elif field == 'size':
            fileInfoList.extend([field, fileEntry.get(sizeField, 0)])
          else:
            fileInfoList.extend([field, fileEntry.get(field, '')])
      if fileInfoList:
        printKeyValueListWithCount([fileEntry['name'], formatKeyValueList('(', fileInfoList, ')')], j, jcount)
      else:
        printKeyValueList([fileEntry['name']])
    else:
      userInfo['index'] += 1
      row = userInfo.copy()
      row['depth'] = depth
      row[fileNameTitle] = ('' if noindent else Ind.SpacesSub1())+fileEntry['name']
      for field in FILETREE_FIELDS_PRINT_ORDER:
        if showFields[field]:
          if field == 'parents':
            row[field] = delimiter.join(fileEntry.get(field, []))
          elif field == 'owners':
            row[field] = delimiter.join([owner['emailAddress'] for owner in fileEntry.get(field, [])])
          elif field == 'size':
            row[fileSize] = fileEntry.get(sizeField, 0)
          else:
            row[field] = fileEntry.get(field, '')
      csvPF.WriteRow(row)

  def _showDriveFolderContents(fileEntry, depth):
    for childId in fileEntry['children']:
      childEntry = fileTree.get(childId)
      if childEntry:
        if not DLP.CheckExcludeTrashed(childEntry['info']):
          continue
        if (DLP.CheckMimeType(childEntry['info']) and
            DLP.CheckFileSize(childEntry['info'], sizeField) and
            DLP.CheckFilenameMatch(childEntry['info']) and
            DLP.CheckFilePermissionMatches(childEntry['info'])):
          _showFileInfo(childEntry['info'], depth)
        if childEntry['info']['mimeType'] == MIMETYPE_GA_FOLDER and (maxdepth == -1 or depth < maxdepth):
          Ind.Increment()
          _showDriveFolderContents(childEntry, depth+1)
          Ind.Decrement()

  def _showChildDriveFolderContents(drive, fileEntry, user, i, count, depth):
    if not DLP.CheckExcludeTrashed(fileEntry):
      return
    q = WITH_PARENTS.format(fileEntry['id'])
    if selectSubQuery:
      q += ' and ('+selectSubQuery+')'
    try:
      children = callGAPIpages(drive.files(), 'list', 'files',
                               throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID],
                               retryReasons=[GAPI.UNKNOWN_ERROR],
                               q=q, orderBy=OBY.orderBy, fields=pagesFields,
                               pageSize=GC.Values[GC.DRIVE_MAX_RESULTS], supportsAllDrives=True, includeItemsFromAllDrives=True)
    except (GAPI.invalidQuery, GAPI.invalid):
      entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE, None], invalidQuery(selectSubQuery), i, count)
      return
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      return
    for childEntryInfo in children:
      if not DLP.CheckExcludeTrashed(childEntryInfo):
        continue
      if (DLP.CheckShowOwnedBy(childEntryInfo) and
          DLP.CheckMimeType(childEntryInfo) and
          DLP.CheckFileSize(childEntryInfo, sizeField) and
          DLP.CheckFilenameMatch(childEntryInfo) and
          DLP.CheckFilePermissionMatches(childEntryInfo)):
        _showFileInfo(childEntryInfo, depth)
      if childEntryInfo['mimeType'] == MIMETYPE_GA_FOLDER and (maxdepth == -1 or depth < maxdepth):
        Ind.Increment()
        _showChildDriveFolderContents(drive, childEntryInfo, user, i, count, depth+1)
        Ind.Decrement()

  csvPF = CSVPrintFile(['User', 'index', 'depth', 'name']) if Act.csvFormat() else None
  maxdepth = -1
  fileIdEntity = {}
  selectSubQuery = ''
  sizeField = 'quotaBytesUsed'
  showFields = {}
  for mappedField in FILETREE_FIELDS_CHOICE_MAP.values():
    showFields[mappedField] = False
  buildTree = noindent = stripCRsFromName = False
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  OBY = OrderBy(DRIVEFILE_ORDERBY_CHOICE_MAP)
  DLP = DriveListParameters({'allowChoose': False, 'allowCorpora': False, 'allowQuery': False, 'mimeTypeInQuery': False})
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif DLP.ProcessArgument(myarg, fileIdEntity):
      pass
    elif myarg == 'select':
      if fileIdEntity:
        usageErrorExit(Msg.CAN_NOT_BE_SPECIFIED_MORE_THAN_ONCE.format('select'))
      fileIdEntity = getDriveFileEntity(DLP=DLP)
    elif myarg == 'selectsubquery':
      selectSubQuery = getString(Cmd.OB_QUERY, minLen=0)
    elif myarg == 'orderby':
      OBY.GetChoice()
    elif myarg == 'depth':
      maxdepth = getInteger(minVal=-1)
    elif myarg == 'sizefield':
      sizeField = getChoice(SIZE_FIELD_CHOICE_MAP, mapChoice=True)
    elif myarg == 'fields':
      for field in _getFieldsList():
        if field in FILETREE_FIELDS_CHOICE_MAP:
          showFields[FILETREE_FIELDS_CHOICE_MAP[field]] = True
          if csvPF:
            csvPF.AddTitle(FILETREE_FIELDS_CHOICE_MAP[field])
        else:
          invalidChoiceExit(field, FILETREE_FIELDS_CHOICE_MAP, True)
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    elif csvPF and myarg == 'noindent':
      noindent = True
    elif myarg == 'stripcrsfromname':
      stripCRsFromName = True
    else:
      unknownArgumentExit()
  fieldsList = ['driveId', 'id', 'name', 'parents', 'mimeType', 'ownedByMe', 'owners(emailAddress)',
                'shared', sizeField, 'explicitlyTrashed', 'trashed']
  if csvPF:
    if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES]:
      fileNameTitle = 'title'
      fileSize = 'fileSize'
      csvPF.SetMapDrive3Titles(True)
    else:
      fileNameTitle = 'name'
      fileSize = 'size'
  buildTree = (not fileIdEntity
               or (not fileIdEntity['dict']
                   and not fileIdEntity['query']
                   and not fileIdEntity['shareddrivefilequery']
                   and _simpleFileIdEntityList(fileIdEntity['list'])))
  if buildTree:
    if not fileIdEntity:
      fileIdEntity = initDriveFileEntity()
      DLP.GetFileIdEntity()
    if not fileIdEntity.get('shareddrive'):
      btkwargs = DLP.kwargs
      btkwargs['q'] = DLP.fileIdEntity['query']
      cleanFileIDsList(fileIdEntity, [ROOT, ORPHANS])
    else:
      btkwargs = fileIdEntity['shareddrive']
    DLP.Finalize(fileIdEntity)
  elif not fileIdEntity:
    fileIdEntity = initDriveFileEntity()
  if DLP.PM.permissionMatches:
    fieldsList.append('permissions')
  fields = getFieldsFromFieldsList(fieldsList)
  pagesFields = getItemFieldsFromFieldsList('files', fieldsList)
  shareddriveFields = []
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, drive = _validateUserSharedDrive(user, i, count, fileIdEntity)
    if not drive:
      continue
    if buildTree:
      fileTree, status = initFileTree(drive, fileIdEntity.get('shareddrive'), DLP, shareddriveFields, True, user, i, count)
      if not status:
        continue
      gettingEntity = _getGettingEntity(user, fileIdEntity)
      printGettingAllEntityItemsForWhom(Ent.DRIVE_FILE_OR_FOLDER, gettingEntity, i, count, query=DLP.fileIdEntity['query'])
      try:
        feed = yieldGAPIpages(drive.files(), 'list', 'files',
                              pageMessage=getPageMessageForWhom(),
                              throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.TEAMDRIVE_MEMBERSHIP_REQUIRED],
                              retryReasons=[GAPI.UNKNOWN_ERROR],
                              orderBy=OBY.orderBy,
                              fields=pagesFields, pageSize=GC.Values[GC.DRIVE_MAX_RESULTS], **btkwargs)
        for files in feed:
          extendFileTree(fileTree, files, DLP, stripCRsFromName)
        extendFileTreeParents(drive, fileTree, fields)
        DLP.GetLocationFileIdsFromTree(fileTree, fileIdEntity)
      except (GAPI.notFound, GAPI.teamDriveMembershipRequired) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, fileIdEntity['shareddrive']['driveId']], str(e), i, count)
        continue
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        continue
    else:
      fileTree = {}
    user, drive, jcount = _validateUserGetFileIDs(origUser, i, count, fileIdEntity, drive=drive, entityType=Ent.DRIVE_FILE_OR_FOLDER)
    if jcount == 0:
      continue
    if csvPF:
      userInfo = {'User': user, 'index': 0, 'depth': 0, fileNameTitle: ''}
    j = 0
    Ind.Increment()
    for fileId in fileIdEntity['list']:
      j += 1
      fileEntry = fileTree.get(fileId)
      if fileEntry:
        fileEntryInfo = fileEntry['info']
      else:
        try:
          fileEntryInfo = callGAPI(drive.files(), 'get',
                                   throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                                   fileId=fileId, fields=fields, supportsAllDrives=True)
          if (fileEntryInfo['mimeType'] == MIMETYPE_GA_FOLDER and fileEntryInfo.get('driveId') and
              fileEntryInfo['name'] == TEAM_DRIVE and not fileEntryInfo.get('parents', [])):
            fileEntryInfo['name'] = f"{SHARED_DRIVES}/{_getSharedDriveNameFromId(drive, fileId)}"
          if stripCRsFromName:
            fileEntryInfo['name'] = _stripControlCharsFromName(fileEntryInfo['name'])
          if buildTree:
            fileTree[fileId] = {'info': fileEntryInfo}
        except GAPI.fileNotFound:
          entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER, fileId], Msg.NOT_FOUND, j, jcount)
          continue
        except (GAPI.notFound, GAPI.teamDriveMembershipRequired) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, fileIdEntity['shareddrive']['driveId']], str(e), j, jcount)
          continue
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          userDriveServiceNotEnabledWarning(user, str(e), i, count)
          break
      _showFileInfo(fileEntryInfo, -1, j, jcount)
      Ind.Increment()
      if buildTree:
        _showDriveFolderContents(fileEntry, 0)
      else:
        _showChildDriveFolderContents(drive, fileEntryInfo, user, i, count, 0)
      Ind.Decrement()
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('Drive File Tree')

def getCreationModificationTimes(path_to_file):
  """
  Try to get the date that a file was created, falling back to when it was
  last modified if that isn't possible.
  See http://stackoverflow.com/a/39501288/1709587 for explanation.
  """
  mtime = os.path.getmtime(path_to_file)
  if platform.system() == 'Windows':
    ctime = os.path.getctime(path_to_file)
  else:
    stat = os.stat(path_to_file)
    try:
      ctime = stat.st_birthtime
    except AttributeError:
      # We're probably on Linux. No easy way to get creation dates here,
      # so we'll settle for when its content was last modified.
      ctime = stat.st_mtime
  return (formatLocalSecondsTimestamp(ctime), formatLocalSecondsTimestamp(mtime))

def writeReturnIdLink(returnIdLink, mimeType, result):
  if returnIdLink != 'editLink':
    writeStdout(f'{result[returnIdLink]}\n')
    return
  if mimeType is None:
    writeStdout(f'{result["webViewLink"]}\n')
    return
  for mt in MICROSOFT_FORMATS_LIST:
    if mimeType == mt['mime']:
      if mt['ext'][1] == 'd':
        writeStdout(f'https://docs.google.com/document/d/{result["id"]}/edit\n')
        return
      if mt['ext'][1] == 'x':
        writeStdout(f'https://docs.google.com/spreadsheets/d/{result["id"]}/edit\n')
        return
      if mt['ext'][1] == 'p':
        writeStdout(f'https://docs.google.com/presentation/d/{result["id"]}/edit\n')
        return
  writeStdout(f'https://drive.google.com/file/d/{result["id"]}/edit\n')

def processFilenameReplacements(name, replacements):
  for replacement in replacements:
    name = re.sub(replacement[0], replacement[1], name)
  return name

def addTimestampToFilename(parameters, body):
  tdtime = datetime.datetime.now(GC.Values[GC.TIMEZONE])
  body['name'] += ' - '
  if not parameters[DFA_TIMEFORMAT]:
    body['name'] += ISOformatTimeStamp(tdtime)
  else:
    body['name'] += tdtime.strftime(parameters[DFA_TIMEFORMAT])

createReturnItemMap = {
  'returnidonly': 'id',
  'returnlinkonly': 'webViewLink',
  'returneditlinkonly': 'editLink'
  }

# gam <UserTypeEntity> create drivefile
#	[(localfile <FileName>|-)|(url <URL>)]
#	[(drivefilename|newfilename <DriveFileName>) | (replacefilename <REMatchPattern> <RESubstitution>)*]
#	[stripnameprefix <String>]
#	[timestamp <Boolean>]] [timeformat <String>]
#	<DriveFileCreateAttribute>* [noduplicate]
#	[(csv [todrive <ToDriveAttribute>*] (addcsvdata <FieldName> <String>)*)) |
#	 (returnidonly|returnlinkonly|returneditlinkonly|showdetails)]
def createDriveFile(users):
  csvPF = media_body = None
  addCSVData = {}
  returnIdLink = None
  noDuplicate = showDetails = False
  body = {}
  newName = None
  assignLocalName = True
  parameters = initDriveFileAttributes()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'drivefilename', 'newfilename'}:
      newName = getString(Cmd.OB_DRIVE_FILE_NAME)
      assignLocalName = False
    elif myarg in createReturnItemMap:
      returnIdLink = createReturnItemMap[myarg]
      showDetails = False
    elif myarg == 'showdetails':
      returnIdLink = None
      showDetails = True
    elif myarg == 'noduplicate':
      noDuplicate = True
    elif myarg == 'csv':
      csvPF = CSVPrintFile()
    elif csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif csvPF and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    else:
      getDriveFileAttribute(myarg, body, parameters, False)
  if assignLocalName and parameters[DFA_LOCALFILENAME] and parameters[DFA_LOCALFILENAME] != '-':
    newName = parameters[DFA_LOCALFILENAME]
  if newName:
    if parameters[DFA_STRIPNAMEPREFIX] and newName.startswith(parameters[DFA_STRIPNAMEPREFIX]):
      newName = newName[len(parameters[DFA_STRIPNAMEPREFIX]):]
    if parameters[DFA_REPLACEFILENAME]:
      body['name'] = processFilenameReplacements(newName, parameters[DFA_REPLACEFILENAME])
    else:
      body['name'] = newName
  else:
    body['name'] = 'Untitled'
  if parameters[DFA_TIMESTAMP]:
    addTimestampToFilename(parameters, body)
  if parameters[DFA_LOCALFILEPATH]:
    if parameters[DFA_LOCALFILEPATH] != '-' and parameters[DFA_PRESERVE_FILE_TIMES]:
      setPreservedFileTimes(body, parameters, False)
    if body.get('mimeType') == MIMETYPE_GA_SCRIPT_JSON:
      parameters[DFA_LOCALMIMETYPE] = body['mimeType']
    media_body = getMediaBody(parameters)
  elif parameters[DFA_URL]:
    media_body = getMediaBody(parameters)
    body['mimeType'] = parameters[DFA_LOCALMIMETYPE]
  if csvPF:
    fileNameTitle = 'title' if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES] else 'name'
    csvPF.SetTitles(['User', fileNameTitle, 'id'])
    if showDetails:
      csvPF.AddTitles(['parentId', 'mimeType'])
    if addCSVData:
      csvPF.AddTitles(sorted(addCSVData.keys()))
  Act.Set(Act.CREATE)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
    if not drive:
      continue
    if not _getDriveFileParentInfo(drive, user, i, count, body, parameters):
      continue
    entityType = _getEntityMimeType(body) if 'mimeType' in body else Ent.DRIVE_FILE
    try:
      if noDuplicate:
        # Check for existing file/folder, do not duplicate
        files = callGAPIitems(drive.files(), 'list', 'files',
                              throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID],
                              q=f"name = '{escapeDriveFileName(body['name'])}'and '{body['parents'][0]}' in parents and trashed = false",
                              fields='files(id)', **parameters['searchargs'])
        if files:
          entityActionNotPerformedWarning([Ent.USER, user, entityType, body['name'], Ent.DRIVE_PARENT_FOLDER_ID, body['parents'][0]],
                                          f"{Msg.DUPLICATE} IDs {','.join([file['id'] for file in files])}", i, count)
          continue
      result = callGAPI(drive.files(), 'create',
                        throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                    GAPI.PERMISSION_DENIED, GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.CANNOT_ADD_PARENT,
                                                                    GAPI.FILE_NOT_FOUND, GAPI.UNKNOWN_ERROR, GAPI.INTERNAL_ERROR,
                                                                    GAPI.STORAGE_QUOTA_EXCEEDED, GAPI.TEAMDRIVES_SHARING_RESTRICTION_NOT_ALLOWED,
                                                                    GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP,
                                                                    GAPI.UPLOAD_TOO_LARGE, GAPI.TEAMDRIVES_SHORTCUT_FILE_NOT_SUPPORTED],
                        ocrLanguage=parameters[DFA_OCRLANGUAGE],
                        ignoreDefaultVisibility=parameters[DFA_IGNORE_DEFAULT_VISIBILITY],
                        keepRevisionForever=parameters[DFA_KEEP_REVISION_FOREVER],
                        useContentAsIndexableText=parameters[DFA_USE_CONTENT_AS_INDEXABLE_TEXT],
                        media_body=media_body, body=body, fields='id,name,mimeType,parents,webViewLink', supportsAllDrives=True)
      parentId = result['parents'][0] if 'parents' in result and result['parents'] else UNKNOWN
      if returnIdLink:
        writeReturnIdLink(returnIdLink, parameters[DFA_LOCALMIMETYPE], result)
      elif not csvPF:
        kvList = [Ent.USER, user]
        if not showDetails:
          titleInfo = f'{result["name"]}({result["id"]})'
          if parameters[DFA_LOCALFILENAME]:
            kvList.extend([Ent.DRIVE_FILE, titleInfo])
          else:
            kvList.extend([_getEntityMimeType(result), titleInfo])
        else:
          if result['mimeType'] != MIMETYPE_GA_FOLDER:
            kvList.extend([Ent.DRIVE_FILE, result['name'], Ent.DRIVE_FILE_ID, result['id']])
          else:
            kvList.extend([Ent.DRIVE_FOLDER, result['name'], Ent.DRIVE_FOLDER_ID, result['id']])
          kvList.extend([Ent.DRIVE_PARENT_FOLDER_ID, parentId, Ent.MIMETYPE, result['mimeType']])
        if media_body:
          entityModifierNewValueActionPerformed(kvList, Act.MODIFIER_WITH_CONTENT_FROM, parameters[DFA_LOCALFILENAME] or parameters[DFA_URL], i, count)
        else:
          entityActionPerformed(kvList, i, count)
      else:
        row = {'User': user, fileNameTitle: result['name'], 'id': result['id']}
        if showDetails:
          row.update({'parentId': parentId, 'mimeType': result['mimeType']})
        if addCSVData:
          row.update(addCSVData)
        csvPF.WriteRow(row)
    except (GAPI.forbidden, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions,
            GAPI.invalidQuery, GAPI.permissionDenied, GAPI.invalid, GAPI.badRequest, GAPI.cannotAddParent,
            GAPI.fileNotFound, GAPI.unknownError, GAPI.storageQuotaExceeded, GAPI.teamDrivesSharingRestrictionNotAllowed,
            GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep,
            GAPI.uploadTooLarge, GAPI.teamDrivesShortcutFileNotSupported) as e:
      entityActionFailedWarning([Ent.USER, user, entityType, body['name']], str(e), i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
  if csvPF:
    csvPF.writeCSVfile('Files')

# gam <UserTypeEntity> create drivefolderpath
#	[pathdelimiter <Character>]
#	((fullpath <DriveFolderPath) |
#	 (path <DriveFolderPath [<DriveFileParentAttribute>]) |
#	 (list <DriveFolderNameList>) [<DriveFileParentAttribute>]))
#	[(csv [todrive <ToDriveAttribute>*] (addcsvdata <FieldName> <String>)*) |
#	 returnidonly]
def createDriveFolderPath(users):
  csvPF = None
  addCSVData = {}
  fullPath = parentSpecified = returnIdOnly = False
  parentBody = {}
  parentParms = initDriveFileAttributes()
  driveFolderNameList = []
  pathDelimiter = '/'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'pathdelimiter':
      pathDelimiter = getCharacter()
    elif myarg == 'fullpath':
      folderPathLocation = Cmd.Location()
      driveFolderNameList = getString(Cmd.OB_DRIVE_FOLDER_PATH).lstrip(pathDelimiter).strip(' ').split(pathDelimiter)
      if len(driveFolderNameList) > 0:
        if driveFolderNameList[0].lower() == MY_DRIVE.lower():
          parentParms[DFA_PARENTID] = ROOT
          driveFolderNameList = driveFolderNameList[1:]
        elif driveFolderNameList[0].lower() == SHARED_DRIVES.lower() and len(driveFolderNameList) > 1:
          parentParms[DFA_SHAREDDRIVE_PARENT] = driveFolderNameList[1]
          driveFolderNameList = driveFolderNameList[2:]
        else:
          usageErrorExit(Msg.FULL_PATH_MUST_START_WITH_DRIVE.format(MY_DRIVE, f'{SHARED_DRIVES}{pathDelimiter}<SharedDriveName>'))
        fullPath = True
    elif myarg == 'path':
      folderPathLocation = Cmd.Location()
      driveFolderNameList = getString(Cmd.OB_DRIVE_FOLDER_PATH).strip(' ').split(pathDelimiter)
    elif myarg == 'list':
      folderPathLocation = Cmd.Location()
      driveFolderNameList = shlexSplitList(getString(Cmd.OB_DRIVE_FOLDER_NAME_LIST), dataDelimiter=',')
    elif getDriveFileParentAttribute(myarg, parentParms):
      parentSpecified = True
    elif myarg == 'returnidonly':
      returnIdOnly = True
    elif myarg == 'csv':
      csvPF = CSVPrintFile(['User', 'name', 'id', 'status', 'pathIndex', 'pathLength'], 'sortall')
    elif csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif csvPF and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    else:
      unknownArgumentExit()
  if not driveFolderNameList:
    Cmd.SetLocation(folderPathLocation)
    emptyArgumentExit('fullpath|path|list')
  if fullPath and parentSpecified:
    usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format('fullpath', '<DriveFileParentAttribute>'))
  for folderName in driveFolderNameList:
    if not folderName.strip():
      Cmd.SetLocation(folderPathLocation)
      usageErrorExit(Msg.ALL_FOLDER_NAMES_MUST_BE_NON_BLANK.format(driveFolderNameList))
  if csvPF:
    if addCSVData:
      csvPF.AddTitles(sorted(addCSVData.keys()))
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
    if not drive:
      continue
    if not _getDriveFileParentInfo(drive, user, i, count, parentBody, parentParms,
                                   defaultToRoot=True, entityType=Ent.DRIVE_FOLDER):
      continue
    parentId = parentBody['parents'][0]
    if parentParms.get('searchargs', {}).get('corpora', ''):
      query = ANY_NON_TRASHED_FOLDER_NAME_WITH_PARENTS
    else:
      query = MY_NON_TRASHED_FOLDER_NAME_WITH_PARENTS
    errors = False
    createOnly = False
    if not returnIdOnly and not csvPF:
      entityPerformAction([Ent.USER, user, Ent.DRIVE_FOLDER_PATH, ''], i, count)
    jcount = len(driveFolderNameList)
    Ind.Increment()
    j = 0
    for folderName in driveFolderNameList:
      j += 1
      try:
        folderFound = False
        if not createOnly:
          op = 'Find Folder'
          result = callGAPIpages(drive.files(), 'list', 'files',
                                 throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.BAD_REQUEST, GAPI.INSUFFICIENT_PERMISSIONS],
                                 retryReasons=[GAPI.UNKNOWN_ERROR],
                                 q=query.format(escapeDriveFileName(folderName), parentId),
                                 supportsAllDrives=True, includeItemsFromAllDrives=True,
                                 fields='nextPageToken,files(id,name)')
          if result:
            if len(result) > 1:
              entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FOLDER, folderName], f'{op}: {len(result)} Folders with same name')
              errors = True
              break
            parentId = result[0]['id']
            parentName = result[0]['name']
            folderFound = True
            Act.Set(Act.EXISTS)
        if not folderFound:
          op = 'Create Folder'
          result = callGAPI(drive.files(), 'create',
                            throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                        GAPI.UNKNOWN_ERROR, GAPI.BAD_REQUEST,
                                                                        GAPI.STORAGE_QUOTA_EXCEEDED, GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP],
                            body={'parents': [parentId], 'name': folderName, 'mimeType': MIMETYPE_GA_FOLDER}, fields='id,name', supportsAllDrives=True)
          parentId = result['id']
          parentName = result['name']
          createOnly = True
          Act.Set(Act.CREATE)
      except (GAPI.forbidden, GAPI.insufficientPermissions, GAPI.insufficientParentPermissions,
              GAPI.unknownError, GAPI.badRequest, GAPI.storageQuotaExceeded, GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FOLDER, folderName], f'{op}: {str(e)}', j, jcount)
        errors = True
        break
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        errors = True
        break
      if not returnIdOnly:
        if not csvPF:
          entityActionPerformed([Ent.USER, user, Ent.DRIVE_FOLDER_NAME, f'{parentName}({parentId})'],
                                j, jcount)
        else:
          row = {'User': user, 'name': parentName, 'id': parentId, 'status': Act.Performed(), 'pathIndex': j, 'pathLength': jcount}
          if addCSVData:
            row.update(addCSVData)
          csvPF.WriteRow(row)
    if returnIdOnly and not errors:
      writeStdout(f'{parentId}\n')
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('Folders')

# gam <UserTypeEntity> create drivefileshortcut <DriveFileEntity> [shortcutname <String>]
#	[<DriveFileParentAttribute>|convertparents]
#	[(csv [todrive <ToDriveAttribute>*]) | returnidonly]
def createDriveFileShortcut(users):
  csvPF = baseShortcutName = None
  convertParents = newParentsSpecified = returnIdOnly = False
  fileIdEntity = getDriveFileEntity()
  parentBody = {}
  parentParms = initDriveFileAttributes()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'shortcutname':
      baseShortcutName = getString(Cmd.OB_DRIVE_FILE_NAME)
    elif myarg == 'returnidonly':
      returnIdOnly = True
    elif myarg == 'csv':
      csvPF = CSVPrintFile(['User', 'name', 'id', 'targetName', 'targetId'], 'sortall')
    elif csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif getDriveFileParentAttribute(myarg, parentParms):
      if convertParents:
        usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format(myarg, 'convertparents'))
      newParentsSpecified = True
    elif myarg == 'convertparents':
      if newParentsSpecified:
        usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format(myarg, '<DriveFileParentAttribute>'))
      convertParents = True
    else:
      unknownArgumentExit()
  if fileIdEntity['query']:
    fileIdEntity['query'] = fileIdEntity['query']+AND_NOT_SHORTCUT
  elif fileIdEntity['shareddrivefilequery']:
    fileIdEntity['shareddrivefilequery'] = fileIdEntity['shareddrivefilequery']+AND_NOT_SHORTCUT
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity)
    if not returnIdOnly and not csvPF:
      entityPerformActionSubItemModifierNumItems([Ent.USER, user], Ent.DRIVE_FILE_SHORTCUT,
                                                 Act.MODIFIER_FOR, jcount, Ent.DRIVE_FILE_OR_FOLDER, i, count)
    if jcount == 0:
      continue
    if not _getDriveFileParentInfo(drive, user, i, count, parentBody, parentParms):
      continue
    if newParentsSpecified:
      newParents = parentBody['parents']
      numNewParents = len(newParents)
    elif not convertParents:
      try:
        rootFolderId = callGAPI(drive.files(), 'get',
                                throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                                fileId=ROOT, fields='id')['id']
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        continue
      newParents = [rootFolderId]
      numNewParents = 1
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      Act.Set(Act.CREATE)
      try:
        target = callGAPI(drive.files(), 'get',
                          throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                          fileId=fileId, fields='mimeType,name,parents', supportsAllDrives=True)
      except (GAPI.forbidden, GAPI.insufficientFilePermissions, GAPI.invalid, GAPI.badRequest,
              GAPI.fileNotFound, GAPI.unknownError, GAPI.teamDrivesSharingRestrictionNotAllowed) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER, fileId], str(e), j, jcount)
        continue
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
      targetName = target['name']
      if baseShortcutName:
        shortcutName = baseShortcutName.replace('#filename#', targetName)
      else:
        shortcutName = targetName
      targetEntityType = _getEntityMimeType(target)
      if convertParents:
        newParents = target.get('parents', [])[:-1]
        numNewParents = len(newParents)
        if numNewParents <= 1:
          entityActionNotPerformedWarning([Ent.USER, user, targetEntityType, targetName, Ent.DRIVE_FILE_SHORTCUT, None],
                                          Msg.NO_PARENTS_TO_CONVERT_TO_SHORTCUTS, j, jcount)
          continue
      removeParents = []
      body = {'name': shortcutName, 'mimeType': MIMETYPE_GA_SHORTCUT, 'parents': None, 'shortcutDetails': {'targetId': fileId}}
      if not returnIdOnly and not csvPF:
        entityPerformActionNumItems([Ent.USER, user, targetEntityType, targetName], numNewParents, Ent.DRIVE_FILE_SHORTCUT, j, jcount)
      try:
        existingShortcuts = callGAPI(drive.files(), 'list',
                                     throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID],
                                     retryReasons=[GAPI.UNKNOWN_ERROR],
                                     supportsAllDrives=True, includeItemsFromAllDrives=True,
                                     q=f"shortcutDetails.targetId = '{fileId}' and trashed = False", fields='files(id,name,parents)')['files']
      except (GAPI.invalidQuery, GAPI.invalid, GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy):
        existingShortcuts = []
      Ind.Increment()
      k = 0
      for parentId in newParents:
        k += 1
        duplicateShortcut = False
        for shortcut in existingShortcuts:
          if parentId in shortcut['parents'] and shortcutName == shortcut['name']:
            entityActionNotPerformedWarning([Ent.USER, user, targetEntityType, targetName, Ent.DRIVE_FILE_SHORTCUT, f'{shortcut["name"]}({shortcut["id"]})'],
                                            Msg.DUPLICATE, k, numNewParents)
            duplicateShortcut = True
            break
        if duplicateShortcut:
          continue
        body['parents'] = [parentId]
        try:
          result = callGAPI(drive.files(), 'create',
                            throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                        GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND, GAPI.UNKNOWN_ERROR,
                                                                        GAPI.STORAGE_QUOTA_EXCEEDED, GAPI.TEAMDRIVES_SHARING_RESTRICTION_NOT_ALLOWED,
                                                                        GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP,
                                                                        GAPI.SHORTCUT_TARGET_INVALID],
                            body=body, fields='id,name', supportsAllDrives=True)
          removeParents.append(parentId)
          if returnIdOnly:
            writeStdout(f'{result["id"]}\n')
          elif not csvPF:
            entityActionPerformed([Ent.USER, user, targetEntityType, targetName, Ent.DRIVE_FILE_SHORTCUT, f'{result["name"]}({result["id"]})'],
                                  k, numNewParents)
          else:
            csvPF.WriteRow({'User': user, 'name': result['name'], 'id': result['id'], 'targetName': targetName, 'targetId': fileId})
        except (GAPI.forbidden, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions, GAPI.invalid, GAPI.badRequest,
                GAPI.fileNotFound, GAPI.unknownError, GAPI.storageQuotaExceeded, GAPI.teamDrivesSharingRestrictionNotAllowed,
                GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep, GAPI.shortcutTargetInvalid) as e:
          entityActionFailedWarning([Ent.USER, user, targetEntityType, targetName, Ent.DRIVE_FILE_SHORTCUT, body['name']], str(e), k, numNewParents)
      Ind.Decrement()
      if convertParents and removeParents:
        if not returnIdOnly and not csvPF:
          lcount = len(removeParents)
          Act.Set(Act.DELETE)
          entityPerformActionNumItems([Ent.USER, user, targetEntityType, targetName], lcount, Ent.DRIVE_PARENT_FOLDER_REFERENCE, j, jcount)
        try:
          callGAPI(drive.files(), 'update',
                   throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST],
                   fileId=fileId,
                   removeParents=','.join(removeParents), body={}, fields='id', supportsAllDrives=True)
          if not returnIdOnly and not csvPF:
            Ind.Increment()
            for l, parent in enumerate(removeParents):
              entityActionPerformed([Ent.USER, user, targetEntityType, targetName, Ent.DRIVE_PARENT_FOLDER_REFERENCE, parent], l+1, lcount)
            Ind.Decrement()
        except (GAPI.forbidden, GAPI.insufficientFilePermissions, GAPI.invalid, GAPI.badRequest,
                GAPI.fileNotFound, GAPI.unknownError, GAPI.teamDrivesSharingRestrictionNotAllowed) as e:
          entityActionFailedWarning([Ent.USER, user, targetEntityType, targetName, Ent.DRIVE_PARENT_FOLDER_REFERENCE, str(l)], str(e), j, jcount)
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('Shortcuts')

SHORTCUT_CODE_VALID = 0
SHORTCUT_CODE_SHORTCUT_NOT_FOUND = 1
SHORTCUT_CODE_NOT_A_SHORTCUT = 2
SHORTCUT_CODE_TARGET_NOT_FOUND = 3
SHORTCUT_CODE_MIMETYPE_MISMATCH = 4

# gam <UserTypeEntity> check drivefileshortcut <DriveFileEntity>
#	[csv [todrive <ToDriveAttribute>*]]
def checkDriveFileShortcut(users):
  csvPF = None
  fileIdEntity = getDriveFileEntity()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'csv':
      csvPF = CSVPrintFile(['User', 'name', 'id', 'owner', 'parentId', 'shortcutDetails.targetId', 'shortcutDetails.targetMimeType',
                            'targetName', 'targetId', 'targetMimeType', 'code'], 'sortall')
    elif csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    else:
      unknownArgumentExit()
  scfields = 'id,name,mimeType,owners(emailAddress),parents,shortcutDetails'
  trfields = 'id,name,mimeType'
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity)
    if not csvPF:
      entityPerformActionNumItems([Ent.USER, user], jcount, Ent.DRIVE_FILE_SHORTCUT, i, count)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      row = {'User': user, 'id': fileId}
      try:
        scresult = callGAPI(drive.files(), 'get',
                            throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                            fileId=fileId, fields=scfields, supportsAllDrives=True)
        row['name'] = scresult['name']
        if scresult['mimeType'] != MIMETYPE_GA_SHORTCUT:
          if not csvPF:
            entityActionFailedWarning([Ent.USER, user, _getEntityMimeType(scresult), fileId],
                                      Msg.INVALID_MIMETYPE.format(scresult['mimeType'], MIMETYPE_GA_SHORTCUT), j, jcount)
          else:
            row['code'] = SHORTCUT_CODE_NOT_A_SHORTCUT
            csvPF.WriteRow(row)
          continue
        if 'owners' in scresult:
          row['owner'] = scresult['owners'][0]['emailAddress']
        row['parentId'] = scresult['parents'][0]
        row[f'shortcutDetails{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}targetId'] = scresult['shortcutDetails']['targetId']
        row[f'shortcutDetails{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}targetMimeType'] = scresult['shortcutDetails']['targetMimeType']
        trfileId = scresult['shortcutDetails']['targetId']
        try:
          trresult = callGAPI(drive.files(), 'get',
                              throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                              fileId=trfileId, fields=trfields, supportsAllDrives=True)
          row['targetName'] = trresult['name']
          row['targetId'] = trresult['id']
          row['targetMimeType'] = trresult['mimeType']
          entityList = [Ent.USER, user, Ent.DRIVE_FILE_SHORTCUT, f"{scresult['name']}({fileId})",
                        _getEntityMimeType(trresult), f"{trresult['name']}({trfileId})"]
          if scresult['shortcutDetails']['targetMimeType'] == trresult['mimeType']:
            if not csvPF:
              entityActionPerformed(entityList, j, jcount)
            else:
              row['code'] = SHORTCUT_CODE_VALID
          else:
            if not csvPF:
              entityActionFailedWarning(entityList, Msg.MIMETYPE_MISMATCH.format(scresult['shortcutDetails']['targetMimeType'], trresult['mimeType']), j, jcount)
            else:
              row['code'] = SHORTCUT_CODE_MIMETYPE_MISMATCH
        except GAPI.fileNotFound:
          if not csvPF:
            entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_SHORTCUT, f"{scresult['name']}({fileId})",
                                       _getTargetEntityMimeType(scresult), trfileId], Msg.NOT_FOUND, j, jcount)
          else:
            row['code'] = SHORTCUT_CODE_TARGET_NOT_FOUND
      except GAPI.fileNotFound:
        if not csvPF:
          entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_SHORTCUT, fileId], Msg.NOT_FOUND, j, jcount)
        else:
          row['code'] = SHORTCUT_CODE_SHORTCUT_NOT_FOUND
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
      if csvPF:
        csvPF.WriteRow(row)
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('Check Shortcuts')

# gam <UserTypeEntity> update drivefile <DriveFileEntity> [copy] [returnidonly|returnlinkonly]
#	[(localfile <FileName>|-)|(url <URL>)]
#	[retainname | (newfilename <DriveFileName>) | (replacefilename <REMatchPattern> <RESubstitution>)*]
#	[stripnameprefix <String>]
#	[timestamp <Boolean>]] [timeformat <String>]
#	<DriveFileUpdateAttribute>*
#	[(gsheet|csvsheet <SheetEntity> [clearfilter])|(addsheet <String>)]
#	[charset <String>] [columndelimiter <Character>]
def updateDriveFile(users):
  fileIdEntity = getDriveFileEntity()
  body = {}
  newName = None
  assignLocalName = True
  parameters = initDriveFileAttributes()
  media_body = None
  addSheetBody = addSheetEntity = None
  updateSheetEntity = None
  clearFilter = False
  preserveModifiedTime = False
  encoding = GC.Values[GC.CHARSET]
  columnDelimiter = GC.Values[GC.CSV_INPUT_COLUMN_DELIMITER]
  returnIdLink = None
  operation = 'update'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'copy':
      operation = 'copy'
      Act.Set(Act.COPY)
    elif myarg == 'returnidonly':
      returnIdLink = 'id'
    elif myarg == 'returnlinkonly':
      returnIdLink = 'webViewLink'
    elif myarg == 'retainname':
      assignLocalName = False
    elif myarg == 'newfilename':
      newName = getString(Cmd.OB_DRIVE_FILE_NAME)
      assignLocalName = False
    elif getDriveFileAddRemoveParentAttribute(myarg, parameters):
      pass
    elif myarg == 'addsheet':
      if updateSheetEntity:
        usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format(myarg, 'csvsheet'))
      sheetTitle = getString(Cmd.OB_STRING)
      addSheetEntity = {'sheetType': Ent.SHEET, 'sheetValue': sheetTitle, 'sheetId': None, 'sheetTitle': sheetTitle}
      addSheetBody = {'requests': [{'addSheet': {'properties': {'title': sheetTitle, 'sheetType': 'GRID'}}}]}
    elif myarg in {'gsheet', 'csvsheet'}:
      if addSheetEntity:
        usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format(myarg, 'addsheet'))
      updateSheetEntity = getSheetEntity(False)
    elif myarg == 'clearfilter':
      clearFilter = getBoolean()
    elif myarg == 'charset':
      encoding = getString(Cmd.OB_CHAR_SET)
    elif myarg == 'columndelimiter':
      columnDelimiter = getCharacter()
    else:
      getDriveFileAttribute(myarg, body, parameters, True)
  if assignLocalName and parameters[DFA_LOCALFILENAME] and parameters[DFA_LOCALFILENAME] != '-':
    newName = parameters[DFA_LOCALFILENAME]
  if newName:
    if parameters[DFA_STRIPNAMEPREFIX] and newName.startswith(parameters[DFA_STRIPNAMEPREFIX]):
      newName = newName[len(parameters[DFA_STRIPNAMEPREFIX]):]
  if operation == 'update' and parameters[DFA_LOCALFILEPATH]:
    if parameters[DFA_LOCALFILEPATH] != '-' and parameters[DFA_PRESERVE_FILE_TIMES]:
      setPreservedFileTimes(body, parameters, True)
    if not addSheetEntity and not updateSheetEntity:
      media_body = getMediaBody(parameters)
  elif operation == 'update' and parameters[DFA_URL]:
    addSheetEntity =  updateSheetEntity = None
    media_body = getMediaBody(parameters)
    body['mimeType'] = parameters[DFA_LOCALMIMETYPE]
  elif operation == 'update' and parameters[DFA_PRESERVE_FILE_TIMES]:
    preserveModifiedTime = True
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity,
                                                  entityType=Ent.DRIVE_FILE_OR_FOLDER if returnIdLink is None else None)
    if jcount == 0:
      continue
    if not _getDriveFileParentInfo(drive, user, i, count, body, parameters, defaultToRoot=False):
      continue
    newParents = body.pop('parents', [])
    if operation == 'update':
      status, addParentsBase, removeParentsBase = _getDriveFileAddRemoveParentInfo(user, i, count, parameters, drive)
      if not status:
        continue
      Ind.Increment()
      j = 0
      for fileId in fileIdEntity['list']:
        j += 1
        try:
          addParents = addParentsBase[:]
          removeParents = removeParentsBase[:]
          if newParents or (not newName and parameters[DFA_REPLACEFILENAME]) or preserveModifiedTime:
            result = callGAPI(drive.files(), 'get',
                              throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                              fileId=fileId, fields='name,parents,modifiedTime', supportsAllDrives=True)
            if newParents:
              addParents.extend(newParents)
              removeParents.extend(result.get('parents', []))
            if not newName and parameters[DFA_REPLACEFILENAME]:
              body['name'] = processFilenameReplacements(result['name'], parameters[DFA_REPLACEFILENAME])
            if preserveModifiedTime:
              body['modifiedTime'] = result['modifiedTime']
          if newName:
            if parameters[DFA_REPLACEFILENAME]:
              body['name'] = processFilenameReplacements(newName, parameters[DFA_REPLACEFILENAME])
            else:
              body['name'] = newName
            if parameters[DFA_TIMESTAMP]:
              addTimestampToFilename(parameters, body)
          if addSheetEntity or updateSheetEntity:
            entityValueList = [Ent.USER, user, Ent.DRIVE_FILE_ID, fileId]
            try:
              result = callGAPI(drive.files(), 'get',
                                throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FILE_NOT_FOUND],
                                fileId=fileId, fields='id,mimeType,capabilities(canEdit)', supportsAllDrives=True)
              if result['mimeType'] != MIMETYPE_GA_SPREADSHEET:
                entityActionNotPerformedWarning(entityValueList, f'{Msg.NOT_A} {Ent.Singular(Ent.SPREADSHEET)}', j, jcount)
                continue
              if not result['capabilities']['canEdit']:
                entityActionNotPerformedWarning(entityValueList, Msg.NOT_WRITABLE, j, jcount)
                continue
              _, sheet = buildGAPIServiceObject(API.SHEETS, user)
              if sheet is None:
                continue
              if addSheetEntity:
                addresult = callGAPI(sheet.spreadsheets(), 'batchUpdate',
                                     throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                                     spreadsheetId=fileId, body=addSheetBody)
                sheetEntity = addSheetEntity.copy()
                sheetEntity['sheetId'] = addresult['replies'][0]['addSheet']['properties']['sheetId']
                entityValueList.extend([sheetEntity['sheetType'], sheetEntity['sheetTitle']])
                sheetEntity['sheetType'] = Ent.SHEET_ID # Temporarily set addsheet type to ID
              else:
                sheetEntity = updateSheetEntity.copy()
                entityValueList.extend([sheetEntity['sheetType'], sheetEntity['sheetValue']])
              spreadsheet = callGAPI(sheet.spreadsheets(), 'get',
                                     throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                                     spreadsheetId=fileId,
                                     fields='spreadsheetUrl,sheets(properties(sheetId,title),protectedRanges(range(sheetId),requestingUserCanEdit))')
              sheetId = getSheetIdFromSheetEntity(spreadsheet, sheetEntity)
              if sheetId is None:
                entityActionNotPerformedWarning(entityValueList, Msg.NOT_FOUND, j, jcount)
                continue
              if protectedSheetId(spreadsheet, sheetId):
                entityActionNotPerformedWarning(entityValueList, Msg.NOT_WRITABLE, j, jcount)
                continue
              if addSheetEntity: # Restore addsheet type
                sheetEntity['sheetType'] = Ent.SHEET
              result = callGAPI(drive.files(), 'update',
                                throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST, GAPI.CANNOT_ADD_PARENT,
                                                                              GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                              GAPI.CANNOT_MODIFY_VIEWERS_CAN_COPY_CONTENT,
                                                                              GAPI.TEAMDRIVES_PARENT_LIMIT,
                                                                              GAPI.TEAMDRIVES_FOLDER_MOVE_IN_NOT_SUPPORTED,
                                                                              GAPI.TEAMDRIVES_SHARING_RESTRICTION_NOT_ALLOWED],
                                fileId=fileId,
                                ocrLanguage=parameters[DFA_OCRLANGUAGE],
                                keepRevisionForever=parameters[DFA_KEEP_REVISION_FOREVER],
                                useContentAsIndexableText=parameters[DFA_USE_CONTENT_AS_INDEXABLE_TEXT],
                                addParents=','.join(addParents), removeParents=','.join(removeParents),
                                body=body, fields='id,name,mimeType,webViewLink', supportsAllDrives=True)
### File size check??
              sbody = {'requests': []}
              if clearFilter and updateSheetEntity:
                sbody['requests'].append({'clearBasicFilter': {'sheetId': sheetId}})
              sbody['requests'].append({'updateCells': {'range': {'sheetId': sheetId}, 'fields': '*'}})
              sbody['requests'].append({'pasteData': {'coordinate': {'sheetId': sheetId, 'rowIndex': '0', 'columnIndex': '0'},
                                                      'data': readFile(parameters[DFA_LOCALFILEPATH], encoding=encoding),
                                                      'type': 'PASTE_NORMAL', 'delimiter': columnDelimiter}})
              callGAPI(sheet.spreadsheets(), 'batchUpdate',
                       throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                       spreadsheetId=fileId, body=sbody)
              if returnIdLink:
                writeStdout(f'{result[returnIdLink]}\n')
              else:
                entityModifierNewValueActionPerformed([Ent.USER, user, Ent.DRIVE_FILE, result['name'], sheetEntity['sheetType'], sheetEntity['sheetValue']],
                                                      Act.MODIFIER_WITH_CONTENT_FROM, parameters[DFA_LOCALFILENAME], j, jcount)
            except GAPI.fileNotFound as e:
              entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_ID, fileId], str(e), j, jcount)
            except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
                    GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
                    GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
              entityActionFailedWarning(entityValueList, str(e), j, jcount)
            except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
              userDriveServiceNotEnabledWarning(user, str(e), i, count)
              break
          else:
            result = callGAPI(drive.files(), 'update',
                              throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST, GAPI.CANNOT_ADD_PARENT,
                                                                            GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                            GAPI.FILE_NEVER_WRITABLE, GAPI.CANNOT_MODIFY_VIEWERS_CAN_COPY_CONTENT,
                                                                            GAPI.SHARE_OUT_NOT_PERMITTED, GAPI.SHARE_OUT_NOT_PERMITTED_TO_USER,
                                                                            GAPI.TEAMDRIVES_PARENT_LIMIT, GAPI.TEAMDRIVES_FOLDER_MOVE_IN_NOT_SUPPORTED,
                                                                            GAPI.TEAMDRIVES_SHARING_RESTRICTION_NOT_ALLOWED,
                                                                            GAPI.CROSS_DOMAIN_MOVE_RESTRICTION, GAPI.UPLOAD_TOO_LARGE,
                                                                            GAPI.TEAMDRIVES_SHORTCUT_FILE_NOT_SUPPORTED,
                                                                            GAPI.FILE_OWNER_NOT_MEMBER_OF_WRITER_DOMAIN,
                                                                            GAPI.FILE_WRITER_TEAMDRIVE_MOVE_IN_DISABLED],
                              fileId=fileId,
                              ocrLanguage=parameters[DFA_OCRLANGUAGE],
                              keepRevisionForever=parameters[DFA_KEEP_REVISION_FOREVER],
                              useContentAsIndexableText=parameters[DFA_USE_CONTENT_AS_INDEXABLE_TEXT],
                              addParents=','.join(addParents), removeParents=','.join(removeParents),
                              media_body=media_body, body=body, fields='id,name,mimeType,webViewLink',
                              supportsAllDrives=True)
            if result:
              if returnIdLink:
                writeStdout(f'{result[returnIdLink]}\n')
              elif media_body:
                entityModifierNewValueActionPerformed([Ent.USER, user, Ent.DRIVE_FILE, result['name']],
                                                      Act.MODIFIER_WITH_CONTENT_FROM, parameters[DFA_LOCALFILENAME] or parameters[DFA_URL], j, jcount)
              else:
                entityActionPerformed([Ent.USER, user, _getEntityMimeType(result), result['name']], j, jcount)
            else:
              if returnIdLink:
                writeStdout(f'{fileId}\n')
              else:
                entityActionPerformed([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER_ID, fileId], j, jcount)
        except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions,
                GAPI.unknownError, GAPI.invalid, GAPI.badRequest, GAPI.cannotAddParent,
                GAPI.fileNeverWritable, GAPI.cannotModifyViewersCanCopyContent,
                GAPI.shareInNotPermitted, GAPI.shareOutNotPermitted, GAPI.shareOutNotPermittedToUser,
                GAPI.teamDrivesParentLimit, GAPI.teamDrivesFolderMoveInNotSupported,
                GAPI.teamDrivesSharingRestrictionNotAllowed, GAPI.crossDomainMoveRestriction,
                GAPI.uploadTooLarge, GAPI.teamDrivesShortcutFileNotSupported,
                GAPI.fileOwnerNotMemberOfWriterDomain, GAPI.fileWriterTeamDriveMoveInDisabled) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER_ID, fileId], str(e), j, jcount)
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          userDriveServiceNotEnabledWarning(user, str(e), i, count)
          break
      Ind.Decrement()
    else:
      if newName:
        if parameters[DFA_REPLACEFILENAME]:
          body['name'] = processFilenameReplacements(newName, parameters[DFA_REPLACEFILENAME])
        else:
          body['name'] = newName
        if parameters[DFA_TIMESTAMP]:
          addTimestampToFilename(parameters, body)
      Ind.Increment()
      j = 0
      for fileId in fileIdEntity['list']:
        j += 1
        try:
          result = callGAPI(drive.files(), 'copy',
                            throwReasons=GAPI.DRIVE_COPY_THROW_REASONS+[GAPI.CANNOT_MODIFY_VIEWERS_CAN_COPY_CONTENT],
                            fileId=fileId,
                            ignoreDefaultVisibility=parameters[DFA_IGNORE_DEFAULT_VISIBILITY],
                            keepRevisionForever=parameters[DFA_KEEP_REVISION_FOREVER],
                            body=body, fields='id,name,webViewLink', supportsAllDrives=True)
          if returnIdLink:
            writeReturnIdLink(returnIdLink, parameters[DFA_LOCALMIMETYPE], result)
          else:
            entityModifierNewValueItemValueListActionPerformed([Ent.USER, user, Ent.DRIVE_FILE, fileId],
                                                               Act.MODIFIER_TO, result['name'], [Ent.DRIVE_FILE_ID, result['id']], j, jcount)
        except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
                GAPI.invalid, GAPI.cannotCopyFile, GAPI.badRequest, GAPI.responsePreparationFailure, GAPI.fileNeverWritable, GAPI.fieldNotWritable,
                GAPI.teamDrivesSharingRestrictionNotAllowed, GAPI.rateLimitExceeded, GAPI.userRateLimitExceeded,
                GAPI.cannotModifyViewersCanCopyContent) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE, fileId], str(e), j, jcount)
        except (GAPI.storageQuotaExceeded, GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep,) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE, fileId], str(e), j, jcount)
          break
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          userDriveServiceNotEnabledWarning(user, str(e), i, count)
          break
      Ind.Decrement()

STAT_FOLDER_TOTAL = 0
STAT_FOLDER_COPIED_MOVED = 1
STAT_FOLDER_SHORTCUT_CREATED = 2
STAT_FOLDER_SHORTCUT_EXISTS = 3
STAT_FOLDER_MERGED = 4
STAT_FOLDER_DUPLICATE = 5
STAT_FOLDER_FAILED = 6
STAT_FOLDER_NOT_WRITABLE = 7
STAT_FOLDER_PERMISSIONS_FAILED = 8
STAT_FILE_TOTAL = 9
STAT_FILE_COPIED_MOVED = 10
STAT_FILE_SHORTCUT_CREATED = 11
STAT_FILE_SHORTCUT_EXISTS = 12
STAT_FILE_DUPLICATE = 13
STAT_FILE_FAILED = 14
STAT_FILE_NOT_COPYABLE_MOVABLE = 15
STAT_FILE_IN_SKIPIDS = 16
STAT_FILE_PERMISSIONS_FAILED = 17
STAT_FILE_PROTECTEDRANGES_FAILED = 18
STAT_USER_NOT_ORGANIZER = 19
STAT_LENGTH = 20

FOLDER_SUBTOTAL_STATS = [STAT_FOLDER_COPIED_MOVED, STAT_FOLDER_SHORTCUT_CREATED, STAT_FOLDER_SHORTCUT_EXISTS,
                         STAT_FOLDER_DUPLICATE, STAT_FOLDER_MERGED, STAT_FOLDER_FAILED, STAT_FOLDER_NOT_WRITABLE]
FILE_SUBTOTAL_STATS = [STAT_FILE_COPIED_MOVED, STAT_FILE_SHORTCUT_CREATED, STAT_FILE_SHORTCUT_EXISTS,
                       STAT_FILE_DUPLICATE, STAT_FILE_FAILED, STAT_FILE_NOT_COPYABLE_MOVABLE, STAT_FILE_IN_SKIPIDS]

def _initStatistics():
  return [0] * STAT_LENGTH

def _incrStatistic(statistics, stat):
  statistics[stat] += 1
  if stat in FOLDER_SUBTOTAL_STATS:
    statistics[STAT_FOLDER_TOTAL] += 1
  elif stat in FILE_SUBTOTAL_STATS:
    statistics[STAT_FILE_TOTAL] += 1

def _printStatistics(user, statistics, i, count, copy):
  if statistics[STAT_FOLDER_TOTAL]:
    if copy:
      printEntityKVList([Ent.USER, user],
                        [Ent.Plural(Ent.DRIVE_FOLDER),
                         Msg.STATISTICS_COPY_FOLDER.format(statistics[STAT_FOLDER_TOTAL],
                                                           statistics[STAT_FOLDER_COPIED_MOVED],
                                                           statistics[STAT_FOLDER_SHORTCUT_CREATED],
                                                           statistics[STAT_FOLDER_SHORTCUT_EXISTS],
                                                           statistics[STAT_FOLDER_DUPLICATE],
                                                           statistics[STAT_FOLDER_MERGED],
                                                           statistics[STAT_FOLDER_FAILED],
                                                           statistics[STAT_FOLDER_NOT_WRITABLE],
                                                           statistics[STAT_FOLDER_PERMISSIONS_FAILED])],
                        i, count)
    else:
      printEntityKVList([Ent.USER, user],
                        [Ent.Plural(Ent.DRIVE_FOLDER),
                         Msg.STATISTICS_MOVE_FOLDER.format(statistics[STAT_FOLDER_TOTAL],
                                                           statistics[STAT_FOLDER_COPIED_MOVED],
                                                           statistics[STAT_FOLDER_SHORTCUT_CREATED],
                                                           statistics[STAT_FOLDER_SHORTCUT_EXISTS],
                                                           statistics[STAT_FOLDER_DUPLICATE],
                                                           statistics[STAT_FOLDER_MERGED],
                                                           statistics[STAT_FOLDER_FAILED],
                                                           statistics[STAT_FOLDER_NOT_WRITABLE])],
                        i, count)
  if statistics[STAT_FILE_TOTAL]:
    if copy:
      printEntityKVList([Ent.USER, user],
                        [Ent.Plural(Ent.DRIVE_FILE),
                         Msg.STATISTICS_COPY_FILE.format(statistics[STAT_FILE_TOTAL],
                                                         statistics[STAT_FILE_COPIED_MOVED],
                                                         statistics[STAT_FILE_SHORTCUT_CREATED],
                                                         statistics[STAT_FILE_SHORTCUT_EXISTS],
                                                         statistics[STAT_FILE_DUPLICATE],
                                                         statistics[STAT_FILE_FAILED],
                                                         statistics[STAT_FILE_NOT_COPYABLE_MOVABLE],
                                                         statistics[STAT_FILE_IN_SKIPIDS],
                                                         statistics[STAT_FILE_PERMISSIONS_FAILED],
                                                         statistics[STAT_FILE_PROTECTEDRANGES_FAILED])],
                        i, count)
    else:
      printEntityKVList([Ent.USER, user],
                        [Ent.Plural(Ent.DRIVE_FILE),
                         Msg.STATISTICS_MOVE_FILE.format(statistics[STAT_FILE_TOTAL],
                                                         statistics[STAT_FILE_COPIED_MOVED],
                                                         statistics[STAT_FILE_SHORTCUT_CREATED],
                                                         statistics[STAT_FILE_SHORTCUT_EXISTS],
                                                         statistics[STAT_FILE_DUPLICATE],
                                                         statistics[STAT_FILE_FAILED],
                                                         statistics[STAT_FILE_NOT_COPYABLE_MOVABLE])],
                        i, count)
  if statistics[STAT_USER_NOT_ORGANIZER]:
    printEntityKVList([Ent.USER, user],
                      [Ent.Plural(Ent.DRIVE_FILE_OR_FOLDER),
                       Msg.STATISTICS_USER_NOT_ORGANIZER.format(statistics[STAT_USER_NOT_ORGANIZER])],
                      i, count)

DUPLICATE_FILE_OVERWRITE_OLDER = 0
DUPLICATE_FILE_OVERWRITE_ALL = 1
DUPLICATE_FILE_DUPLICATE_NAME = 2
DUPLICATE_FILE_UNIQUE_NAME = 3
DUPLICATE_FILE_SKIP = 4
DUPLICATE_FOLDER_MERGE = 0
DUPLICATE_FOLDER_DUPLICATE_NAME = 1
DUPLICATE_FOLDER_UNIQUE_NAME = 2
DUPLICATE_FOLDER_SKIP = 3

DEST_PARENT_MYDRIVE_ROOT = 0
DEST_PARENT_MYDRIVE_FOLDER = 1
DEST_PARENT_SHAREDDRIVE_ROOT = 2
DEST_PARENT_SHAREDDRIVE_FOLDER = 3

COPY_NONINHERITED_PERMISSIONS_NEVER = 0
COPY_NONINHERITED_PERMISSIONS_ALWAYS = 1
COPY_NONINHERITED_PERMISSIONS_SYNC_ALL_FOLDERS = 2
COPY_NONINHERITED_PERMISSIONS_SYNC_UPDATED_FOLDERS = 3
COPY_NONINHERITED_PERMISSIONS_CHOICES_MAP = {
  'never': COPY_NONINHERITED_PERMISSIONS_NEVER,
  'always': COPY_NONINHERITED_PERMISSIONS_ALWAYS,
  'syncallfolders': COPY_NONINHERITED_PERMISSIONS_SYNC_ALL_FOLDERS,
  'syncupdatedfolders': COPY_NONINHERITED_PERMISSIONS_SYNC_UPDATED_FOLDERS,
  }

def initCopyMoveOptions(copyCmd):
  return {
    'copyCmd': copyCmd,
    'sourceDriveId': None,
    'destDriveId': None,
    'destParentType': False,
    'newFilename': None,
    'stripNamePrefix': None,
    'replaceFilename': [],
    'summary': False,
    'mergeWithParent': False,
    'mergeWithParentRetain': False,
    'retainSourceFolders': False,
    'sourceIsMyDriveSharedDrive': False,
    'showPermissionMessages': False,
    'sendEmailIfRequired': False,
    'useDomainAdminAccess': False,
    'enforceExpansiveAccess': False,
    'copiedShortcutsPointToCopiedFiles': True,
    'createShortcutsForNonmovableFiles': False,
    'duplicateFiles': DUPLICATE_FILE_OVERWRITE_OLDER,
    'duplicateFolders': DUPLICATE_FOLDER_MERGE,
    'copyFilePermissions': False,
    'copyFileInheritedPermissions': True,
    'copyFileNonInheritedPermissions': COPY_NONINHERITED_PERMISSIONS_ALWAYS,
    'copyMergeWithParentFolderPermissions': False,
    'copyMergedTopFolderPermissions': copyCmd,
    'copyMergedSubFolderPermissions': copyCmd,
    'copyTopFolderPermissions': True,
    'copySubFolderPermissions': True,
    'copyTopFolderInheritedPermissions': True,
    'copySubFolderInheritedPermissions': True,
    'copyTopFolderNonInheritedPermissions': COPY_NONINHERITED_PERMISSIONS_ALWAYS,
    'copySubFolderNonInheritedPermissions': COPY_NONINHERITED_PERMISSIONS_ALWAYS,
    'noCopyNonInheritedPermissions': COPY_NONINHERITED_PERMISSIONS_NEVER,
    'excludePermissionsFromDomains': set(),
    'includePermissionsFromDomains': set(),
    'mapPermissionsDomains': {},
    'copySheetProtectedRangesInheritedPermissions': False,
    'copySheetProtectedRangesNonInheritedPermissions': COPY_NONINHERITED_PERMISSIONS_NEVER,
    'copySubFiles': True,
    'copySubFolders': True,
    'copySubShortcuts': True,
    'fileNameMatchPattern': None,
    'folderNameMatchPattern': None,
    'shortcutNameMatchPattern': None,
    'fileMimeTypes': set(),
    'notMimeTypes': False,
    'copySubFilesOwnedBy': None,
    'copyPermissionRoles': set(DRIVEFILE_ACL_ROLES_MAP.values()),
    'copyPermissionTypes': set(DRIVEFILE_ACL_PERMISSION_TYPES),
    }

DUPLICATE_FILE_CHOICES = {
  'overwriteall': DUPLICATE_FILE_OVERWRITE_ALL,
  'overwriteolder': DUPLICATE_FILE_OVERWRITE_OLDER,
  'duplicatename': DUPLICATE_FILE_DUPLICATE_NAME,
  'uniquename': DUPLICATE_FILE_UNIQUE_NAME,
  'skip': DUPLICATE_FILE_SKIP,
  }
DUPLICATE_FOLDER_CHOICES = {
  'merge': DUPLICATE_FOLDER_MERGE,
  'duplicatename': DUPLICATE_FOLDER_DUPLICATE_NAME,
  'uniquename': DUPLICATE_FOLDER_UNIQUE_NAME,
  'skip': DUPLICATE_FOLDER_SKIP,
  }

def getCopyMoveOptions(myarg, copyMoveOptions):
# Copy/Move arguments
  if myarg == 'newfilename':
    copyMoveOptions['newFilename'] = getString(Cmd.OB_DRIVE_FILE_NAME)
  elif myarg =='stripnameprefix':
    copyMoveOptions['stripNamePrefix'] = getString(Cmd.OB_STRING, minLen=0)
  elif myarg == 'replacefilename':
    copyMoveOptions['replaceFilename'].append(getREPatternSubstitution(re.IGNORECASE))
  elif myarg == 'showpermissionmessages':
    copyMoveOptions['showPermissionMessages'] = getBoolean()
  elif myarg == 'sendemailifrequired':
    copyMoveOptions['sendEmailIfRequired'] = getBoolean()
  elif myarg == 'summary':
    copyMoveOptions['summary'] = getBoolean()
  elif myarg == 'mergewithparent':
    copyMoveOptions['mergeWithParent'] = getBoolean()
    if copyMoveOptions['mergeWithParent']:
      copyMoveOptions['mergeWithParentRetain'] = False
  elif myarg == 'duplicatefiles':
    copyMoveOptions['duplicateFiles'] = getChoice(DUPLICATE_FILE_CHOICES, mapChoice=True)
  elif myarg == 'duplicatefolders':
    copyMoveOptions['duplicateFolders'] = getChoice(DUPLICATE_FOLDER_CHOICES, mapChoice=True)
  elif myarg == 'copymergewithparentfolderpermissions':
    copyMoveOptions['copyMergeWithParentFolderPermissions'] = getBoolean()
  elif myarg == 'copymergedtopfolderpermissions':
    copyMoveOptions['copyMergedTopFolderPermissions'] = getBoolean()
  elif myarg == 'copytopfolderpermissions':
    copyMoveOptions['copyTopFolderPermissions'] = getBoolean()
  elif myarg == 'copytopfolderinheritedpermissions':
    copyMoveOptions['copyTopFolderInheritedPermissions'] = getBoolean()
  elif myarg == 'copytopfoldernoninheritedpermissions':
    copyMoveOptions['copyTopFolderNonInheritedPermissions'] = getChoice(COPY_NONINHERITED_PERMISSIONS_CHOICES_MAP, mapChoice=True)
  elif myarg == 'copymergedsubfolderpermissions':
    copyMoveOptions['copyMergedSubFolderPermissions'] = getBoolean()
  elif myarg == 'copysubfolderpermissions':
    copyMoveOptions['copySubFolderPermissions'] = getBoolean()
  elif myarg == 'copysubfolderinheritedpermissions':
    copyMoveOptions['copySubFolderInheritedPermissions'] = getBoolean()
  elif myarg == 'copysubfoldernoninheritedpermissions':
    copyMoveOptions['copySubFolderNonInheritedPermissions'] = getChoice(COPY_NONINHERITED_PERMISSIONS_CHOICES_MAP, mapChoice=True)
  elif myarg == 'excludepermissionsfromdomains':
    copyMoveOptions['excludePermissionsFromDomains'] = set(getString(Cmd.OB_DOMAIN_NAME_LIST).lower().replace(',', ' ').split())
    copyMoveOptions['includePermissionsFromDomains'] = set()
  elif myarg == 'includepermissionsfromdomains':
    copyMoveOptions['includePermissionsFromDomains'] = set(getString(Cmd.OB_DOMAIN_NAME_LIST).lower().replace(',', ' ').split())
    copyMoveOptions['excludePermissionsFromDomains'] = set()
  elif myarg == 'mappermissionsdomain':
    oldDomain = getString(Cmd.OB_DOMAIN_NAME).lower()
    copyMoveOptions['mapPermissionsDomains'][oldDomain] = getString(Cmd.OB_DOMAIN_NAME).lower()
  elif myarg == 'enforceexpansiveaccess':
    copyMoveOptions['enforceExpansiveAccess'] = getBoolean()
  else:
# Move arguments
    if not copyMoveOptions['copyCmd']:
      if myarg == 'retainsourcefolders':
        copyMoveOptions['retainSourceFolders'] = getBoolean()
      elif myarg == 'mergewithparentretain':
        copyMoveOptions['mergeWithParentRetain'] = getBoolean()
        if copyMoveOptions['mergeWithParentRetain']:
          copyMoveOptions['mergeWithParent'] = False
      elif myarg == 'createshortcutsfornonmovablefiles':
        copyMoveOptions['createShortcutsForNonmovableFiles'] = getBoolean()
      else:
        return False
# Copy arguments
    else:
      if myarg == 'copiedshortcutspointtocopiedfiles':
        copyMoveOptions['copiedShortcutsPointToCopiedFiles'] = getBoolean()
      elif myarg == 'copyfilepermissions':
        copyMoveOptions['copyFilePermissions'] = getBoolean()
      elif myarg == 'copyfileinheritedpermissions':
        copyMoveOptions['copyFileInheritedPermissions'] = getBoolean()
      elif myarg == 'copyfilenoninheritedpermissions':
        copyMoveOptions['copyFileNonInheritedPermissions'] = COPY_NONINHERITED_PERMISSIONS_ALWAYS if getBoolean() else COPY_NONINHERITED_PERMISSIONS_NEVER
      elif myarg == 'copypermissionroles':
        copyMoveOptions['copyPermissionRoles'] = set()
        for prole in getString(Cmd.OB_PERMISSION_ROLE_LIST).lower().replace(',', ' ').split():
          if prole in DRIVEFILE_ACL_ROLES_MAP:
            copyMoveOptions['copyPermissionRoles'].add(DRIVEFILE_ACL_ROLES_MAP[prole])
          else:
            invalidChoiceExit(prole, DRIVEFILE_ACL_ROLES_MAP, True)
      elif myarg == 'copypermissiontypes':
        copyMoveOptions['copyPermissionTypes'] = set()
        for ptype in getString(Cmd.OB_PERMISSION_TYPE_LIST).lower().replace(',', ' ').split():
          if ptype in DRIVEFILE_ACL_PERMISSION_TYPES:
            copyMoveOptions['copyPermissionTypes'].add(ptype)
          else:
            invalidChoiceExit(ptype, DRIVEFILE_ACL_PERMISSION_TYPES, True)
      elif myarg == 'copysheetprotectedranges':
        if getBoolean():
          copyMoveOptions['copySheetProtectedRangesInheritedPermissions'] = True
          copyMoveOptions['copySheetProtectedRangesNonInheritedPermissions'] = COPY_NONINHERITED_PERMISSIONS_ALWAYS
        else:
          copyMoveOptions['copySheetProtectedRangesInheritedPermissions'] = False
          copyMoveOptions['copySheetProtectedRangesNonInheritedPermissions'] = COPY_NONINHERITED_PERMISSIONS_NEVER
      elif myarg == 'copysheetprotectedrangesinheritedpermissions':
        copyMoveOptions['copySheetProtectedRangesInheritedPermissions'] = getBoolean()
      elif myarg == 'copysheetprotectedrangesnoninheritedpermissions':
        copyMoveOptions['copySheetProtectedRangesNonInheritedPermissions'] = COPY_NONINHERITED_PERMISSIONS_ALWAYS if getBoolean() else COPY_NONINHERITED_PERMISSIONS_NEVER
      elif myarg == 'copysubfiles':
        copyMoveOptions['copySubFiles'] = getBoolean()
      elif myarg == 'copysubfolders':
        copyMoveOptions['copySubFolders'] = getBoolean()
      elif myarg == 'copysubshortcuts':
        copyMoveOptions['copySubShortcuts'] = getBoolean()
      elif myarg == 'filenamematchpattern':
        copyMoveOptions['fileNameMatchPattern'] = getREPattern(re.IGNORECASE)
      elif myarg == 'foldernamematchpattern':
        copyMoveOptions['folderNameMatchPattern'] = getREPattern(re.IGNORECASE)
      elif myarg == 'shortcutnamematchpattern':
        copyMoveOptions['shortcutNameMatchPattern'] = getREPattern(re.IGNORECASE)
      elif myarg == 'filemimetype':
        copyMoveOptions['notMimeTypes'] = checkArgumentPresent('not')
        for mimeType in getString(Cmd.OB_MIMETYPE_LIST).lower().replace(',', ' ').split():
          copyMoveOptions['fileMimeTypes'].add(validateMimeType(mimeType))
      elif myarg == 'copysubfilesownedby':
        copyMoveOptions['copySubFilesOwnedBy'] = getChoice(SHOW_OWNED_BY_CHOICE_MAP, mapChoice=True)
      else:
        return False
  return True

def _targetFilenameExists(destFilename, mimeType, targetChildren):
  destFilenameLower = destFilename.lower()
  for target in targetChildren:
    if destFilenameLower == target['name'].lower() and mimeType == target['mimeType']:
      return target
  return None

UNIQUE_PREFIX_PATTERN = re.compile(r'^(.+)\((\d+)\)$')

def _getFilenameParts(destFilename):
  base, ext = os.path.splitext(destFilename)
  if ext and base.endswith('.tar'):
    ext = '.tar'+ext
    base = base[:-4]
  elif len(ext) > 5:
    base = destFilename
    ext = ''
  mg = UNIQUE_PREFIX_PATTERN.match(base)
  if mg:
    return (mg.group(1), int(mg.group(2)), ext)
  return (base, 0, ext)

def _getFilenamePrefix(destFilename):
  base, _, _ = _getFilenameParts(destFilename)
  return base

def _getUniqueFilename(destFilename, mimeType, targetChildren):
  if _targetFilenameExists(destFilename, mimeType, targetChildren) is None:
    return destFilename
  base, _, ext = _getFilenameParts(destFilename)
  n = 0
  for target in targetChildren:
    tbase, tcnt, text = _getFilenameParts(target['name'])
    if base != tbase or ext != text:
      continue
    if tcnt > n:
      n = tcnt
  if ext:
    return f'{base}({n+1}){ext}'
  return f'{base}({n+1})'

def _copyPermissions(drive, user, i, count, j, jcount,
                     entityType, fileId, fileTitle, newFileId, newFileTitle,
                     statistics, stat, copyMoveOptions, atTop, copyInherited, copyNonInherited,
                     updateOwner):
  def getPermissions(fid):
    permissions = {}
    try:
      result = callGAPIpages(drive.permissions(), 'list', 'permissions',
                             throwReasons=GAPI.DRIVE3_GET_ACL_REASONS,
                             retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                             fileId=fid,
                             fields='nextPageToken,permissions(allowFileDiscovery,domain,emailAddress,expirationTime,id,role,type,deleted,view,pendingOwner,permissionDetails)',
                             useDomainAdminAccess=copyMoveOptions['useDomainAdminAccess'], supportsAllDrives=True)
      for permission in result:
        permission.pop('teamDrivePermissionDetails', None)
        permission['inherited'] = permission.pop('permissionDetails', [{'inherited': False}])[0]['inherited']
        permissions[permission['id']] = permission
      return permissions
    except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError,
            GAPI.insufficientAdministratorPrivileges, GAPI.insufficientFilePermissions,
            GAPI.unknownError, GAPI.invalid) as e:
      entityActionFailedWarning([Ent.USER, user, entityType, fileTitle], str(e), j, jcount)
      _incrStatistic(statistics, stat)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      _incrStatistic(statistics, stat)
    return None

  def permissionKVList(user, entityType, title, permission):
    permstr = f"{['noninherited', 'inherited'][permission['inherited']]}/{permission['role']}/{permission['type']}"
    if permission['type'] in {'group', 'user'}:
      permstr += f"/{permission['emailAddress']}"
    elif permission['type'] == 'domain':
      permstr += f"/{permission['domain']}"
    return [Ent.USER, user, entityType, title, Ent.PERMISSION, permstr]

  def isPermissionCopyable(kvList, permission):
    role = permission['role']
    emailAddress = permission.get('emailAddress', '')
    permissionType = permission['type']
    domain = ''
    if copyMoveOptions['excludePermissionsFromDomains'] or copyMoveOptions['includePermissionsFromDomains']:
      if permissionType in {'group', 'user'}:
        atLoc = emailAddress.find('@')
        if atLoc > 0:
          domain = emailAddress[atLoc+1:]
      elif permissionType == 'domain':
        domain = permission.get('domain', '')
    if role not in copyMoveOptions['copyPermissionRoles']:
      notCopiedMessage = f'role {role} not selected'
    elif permissionType not in copyMoveOptions['copyPermissionTypes']:
      notCopiedMessage = f'type {permissionType} not selected'
    elif permission['inherited'] and not copyMoveOptions[copyInherited]:
      notCopiedMessage = 'inherited not selected'
    elif not permission['inherited'] and copyMoveOptions[copyNonInherited] == COPY_NONINHERITED_PERMISSIONS_NEVER:
      notCopiedMessage = 'noninherited not selected'
    elif role == 'owner':
      if emailAddress == user or copyMoveOptions['destDriveId'] or not updateOwner:
        notCopiedMessage = f'role {role} copy not required/appropriate'
      else:
        permission['role'] = 'writer'
        return True
    elif updateOwner and emailAddress == user:
      notCopiedMessage = 'user is now owner'
    elif domain and domain in copyMoveOptions['excludePermissionsFromDomains']:
      notCopiedMessage = f'domain {domain} excluded'
    elif domain and copyMoveOptions['includePermissionsFromDomains'] and domain not in copyMoveOptions['includePermissionsFromDomains']:
      notCopiedMessage = f'domain {domain} not included'
    elif permission.pop('deleted', False) or (permissionType in {'group', 'user'} and not emailAddress):
      notCopiedMessage = f"{permissionType} deleted or has blank email address"
    elif ((copyInherited == 'copySheetProtectedRangesInheritedPermissions' and copyMoveOptions[copyInherited]) or
          (copyNonInherited == 'copySheetProtectedRangesNonInheritedPermissions' and
           copyMoveOptions[copyNonInherited] != COPY_NONINHERITED_PERMISSIONS_NEVER)):
      if role in {'organizer', 'fileOrganizer'}:
        permission['role'] = 'writer'
      return True
    elif role == 'organizer' and (not atTop or copyMoveOptions['destParentType'] != DEST_PARENT_SHAREDDRIVE_ROOT):
      notCopiedMessage = f'role {role} not copyable to {Ent.Plural(Ent.DRIVE_FILE_OR_FOLDER)}'
    elif role == 'fileOrganizer' and entityType == Ent.DRIVE_FILE:
      notCopiedMessage = f'role {role} not copyable to {Ent.Plural(entityType)}'
    elif role == 'fileOrganizer' and not copyMoveOptions['destDriveId']:
      notCopiedMessage = f'role {role} only copyable to {Ent.Singular(Ent.SHAREDDRIVE)} {Ent.Plural(Ent.DRIVE_FOLDER)}'
    else:
      return True
    if copyMoveOptions['showPermissionMessages']:
      entityActionNotPerformedWarning(kvList, notCopiedMessage, 0, 0)
    return False

  def getNonInheritedPermissions(permissions):
    nonInheritedPermIds = set()
    for permissionId, permission in iter(permissions.items()):
      if not permission['inherited']:
        nonInheritedPermIds.add(permissionId)
    return nonInheritedPermIds

  def mapPermissionsDomains(srcPerm):
    if 'emailAddress' in srcPerm:
      email, domain = srcPerm['emailAddress'].lower().split('@', 1)
      if domain in copyMoveOptions['mapPermissionsDomains']:
        srcPerm['emailAddress'] = f"{email}@{copyMoveOptions['mapPermissionsDomains'][domain]}"
        return True
    elif 'domain' in srcPerm:
      domain = srcPerm['domain'].lower()
      if domain in copyMoveOptions['mapPermissionsDomains']:
        srcPerm['domain'] = copyMoveOptions['mapPermissionsDomains'][domain]
        return True
    return False

  sourcePerms = getPermissions(fileId)
  if sourcePerms is None:
    return
  copySourcePerms = {}
  deleteTargetPermIds = set()
  updateTargetPerms = {}
  for permissionId, permission in iter(sourcePerms.items()):
    kvList = permissionKVList(user, entityType, newFileTitle, permission)
    if isPermissionCopyable(kvList, permission):
      copySourcePerms[permissionId] = permission
  if copyMoveOptions[copyNonInherited] == COPY_NONINHERITED_PERMISSIONS_ALWAYS:
    if copyMoveOptions['mapPermissionsDomains']:
      for permissionId in getNonInheritedPermissions(copySourcePerms):
        mapPermissionsDomains(copySourcePerms[permissionId])
  elif copyMoveOptions[copyNonInherited] in {COPY_NONINHERITED_PERMISSIONS_SYNC_ALL_FOLDERS,
                                             COPY_NONINHERITED_PERMISSIONS_SYNC_UPDATED_FOLDERS}:
    targetPerms = getPermissions(newFileId)
    if targetPerms is None:
      return
    sourceNonInheritedPermIDs = getNonInheritedPermissions(copySourcePerms)
    targetNonInheritedPermIDs = getNonInheritedPermissions(targetPerms)
# Permissions in Source only
    if copyMoveOptions['mapPermissionsDomains']:
      for permissionId in sourceNonInheritedPermIDs-targetNonInheritedPermIDs:
        mapPermissionsDomains(copySourcePerms[permissionId])
# Permissions in Target only
    deleteTargetPermIds = targetNonInheritedPermIDs-sourceNonInheritedPermIDs
# Permissions in Source and Target
    for permissionId in targetNonInheritedPermIDs&sourceNonInheritedPermIDs:
      srcPerm = copySourcePerms[permissionId]
      if copyMoveOptions['mapPermissionsDomains'] and mapPermissionsDomains(srcPerm):
        deleteTargetPermIds.add(permissionId)
        continue
      tgtPerm = targetPerms[permissionId]
      updatePerm = {}
      for field in ['expirationTime', 'role', 'view', 'pendingOwner']:
        if field in srcPerm:
          if field not in tgtPerm:
            if field != 'pendingOwner' or srcPerm[field]:
              updatePerm[field] = srcPerm[field]
          elif srcPerm[field] != tgtPerm[field]:
            updatePerm[field] = srcPerm[field]
        elif field in tgtPerm:
          if field == 'expirationTime':
            updatePerm['removeExpiration'] = True
          elif field == 'pendingOwner':
            updatePerm[field] = False
      if updatePerm:
        updateTargetPerms[permissionId] = targetPerms[permissionId]
        updateTargetPerms[permissionId].update(updatePerm)
        updateTargetPerms[permissionId]['updates'] = updatePerm
      copySourcePerms.pop(permissionId)
  deleteUpdateKwargs = {'useDomainAdminAccess': copyMoveOptions['useDomainAdminAccess']}
  if entityType != Ent.SHAREDDRIVE:
    deleteUpdateKwargs['enforceExpansiveAccess'] = copyMoveOptions['enforceExpansiveAccess']
  Ind.Increment()
  action = Act.Get()
  Act.Set(Act.COPY)
  kcount = len(copySourcePerms)
  k = 0
  for permissionId, permission in iter(copySourcePerms.items()):
    k += 1
    kvList = permissionKVList(user, entityType, newFileTitle, permission)
    permission.pop('id')
    permission.pop('inherited')
    if copyMoveOptions['destDriveId']:
      permission.pop('pendingOwner', None)
    sendNotificationEmail = False
    while True:
      try:
        callGAPI(drive.permissions(), 'create',
                 throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+GAPI.DRIVE3_CREATE_ACL_THROW_REASONS,
#                 retryReasons=[GAPI.INVALID_SHARING_REQUEST],
                 useDomainAdminAccess=copyMoveOptions['useDomainAdminAccess'],
                 fileId=newFileId, sendNotificationEmail=sendNotificationEmail, emailMessage=None,
                 body=permission, fields='', supportsAllDrives=True)
        if copyMoveOptions['showPermissionMessages']:
          entityActionPerformed(kvList, k, kcount)
        break
      except (GAPI.badRequest, GAPI.invalid, GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError,
              GAPI.insufficientFilePermissions, GAPI.unknownError, GAPI.ownershipChangeAcrossDomainNotPermitted,
              GAPI.teamDriveDomainUsersOnlyRestriction, GAPI.teamDriveTeamMembersOnlyRestriction,
              GAPI.targetUserRoleLimitedByLicenseRestriction, GAPI.insufficientAdministratorPrivileges, GAPI.sharingRateLimitExceeded,
              GAPI.publishOutNotPermitted, GAPI.shareInNotPermitted, GAPI.shareOutNotPermitted, GAPI.shareOutNotPermittedToUser,
              GAPI.cannotShareTeamDriveTopFolderWithAnyoneOrDomains, GAPI.cannotShareTeamDriveWithNonGoogleAccounts,
              GAPI.ownerOnTeamDriveItemNotSupported,
              GAPI.organizerOnNonTeamDriveNotSupported, GAPI.organizerOnNonTeamDriveItemNotSupported,
              GAPI.fileOrganizerNotYetEnabledForThisTeamDrive,
              GAPI.fileOrganizerOnFoldersInSharedDriveOnly,
              GAPI.fileOrganizerOnNonTeamDriveNotSupported,
              GAPI.teamDrivesFolderSharingNotSupported, GAPI.invalidLinkVisibility, GAPI.abusiveContentRestriction) as e:
        entityActionFailedWarning(kvList, str(e), k, kcount)
        break
      except GAPI.invalidSharingRequest as e:
        if not copyMoveOptions['sendEmailIfRequired'] or sendNotificationEmail or 'You are trying to invite' not in str(e):
          entityActionFailedWarning(kvList, str(e), k, kcount)
          break
        sendNotificationEmail = True
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        _incrStatistic(statistics, stat)
        Ind.Decrement()
        Act.Set(action)
        return
  Act.Set(Act.DELETE)
  kcount = len(deleteTargetPermIds)
  k = 0
  for permissionId in deleteTargetPermIds:
    k += 1
    kvList = permissionKVList(user, entityType, newFileTitle, targetPerms[permissionId])
    try:
      callGAPI(drive.permissions(), 'delete',
               throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+GAPI.DRIVE3_DELETE_ACL_THROW_REASONS+[GAPI.FILE_NEVER_WRITABLE],
               **deleteUpdateKwargs,
               fileId=newFileId, permissionId=permissionId,  supportsAllDrives=True)
      if copyMoveOptions['showPermissionMessages']:
        entityActionPerformed(kvList, k, kcount)
    except (GAPI.notFound, GAPI.permissionNotFound,
            GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
            GAPI.fileNeverWritable, GAPI.badRequest, GAPI.cannotRemoveOwner, GAPI.cannotModifyInheritedTeamDrivePermission,
            GAPI.insufficientAdministratorPrivileges, GAPI.sharingRateLimitExceeded, GAPI.cannotDeletePermission) as e:
      entityActionFailedWarning(kvList, str(e), k, kcount)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      _incrStatistic(statistics, stat)
      Ind.Decrement()
      Act.Set(action)
      return
  Act.Set(Act.UPDATE)
  kcount = len(updateTargetPerms)
  k = 0
  for permissionId, permission in iter(updateTargetPerms.items()):
    k += 1
    kvList = permissionKVList(user, entityType, newFileTitle, permission)
    removeExpiration = permission['updates'].pop('removeExpiration', False)
    try:
      callGAPI(drive.permissions(), 'update',
               bailOnInternalError=True,
               throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+GAPI.DRIVE3_UPDATE_ACL_THROW_REASONS+[GAPI.FILE_NEVER_WRITABLE],
               removeExpiration=removeExpiration,
               **deleteUpdateKwargs,
               fileId=newFileId, permissionId=permissionId, body=permission['updates'], supportsAllDrives=True)
      if copyMoveOptions['showPermissionMessages']:
        entityActionPerformed(kvList, k, kcount)
    except (GAPI.notFound, GAPI.permissionNotFound,
            GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
            GAPI.fileNeverWritable, GAPI.badRequest, GAPI.cannotRemoveOwner, GAPI.cannotModifyInheritedTeamDrivePermission,
            GAPI.insufficientAdministratorPrivileges, GAPI.sharingRateLimitExceeded) as e:
      entityActionFailedWarning(kvList, str(e), k, kcount)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      _incrStatistic(statistics, stat)
      Ind.Decrement()
      Act.Set(action)
      return
  Act.Set(action)
  Ind.Decrement()

def _updateSheetProtectedRangesACLchange(sheet, user, i, count, j, jcount, fileId, fileTitle,
                                         aclAdd, permission):
  ptype = permission['type']
  updList = 'users' if ptype == 'user' else 'groups' if ptype == 'group' else ''
  if updList:
    emailAddress = permission['emailAddress']
  else:
    updDomain = (ptype == 'domain') and (permission['domain'] == GC.Values[GC.DOMAIN])
    if not updDomain:
      return
  addEditor = aclAdd and (permission['role'] not in {'reader', 'commenter'})
  updateProtectedRangeRequests = []
  try:
    result = callGAPI(sheet.spreadsheets(), 'get',
                      throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                      spreadsheetId=fileId, fields='sheets(protectedRanges)')
    for rsheet in result.get('sheets', []):
      for protectedRange in rsheet.get('protectedRanges', []):
        editors = protectedRange.get('editors', None)
        if editors is None:
          continue
        updReqd = False
        if updList:
          if addEditor:
            if updList not in editors:
              editors[updList] = [emailAddress]
              updReqd = True
            elif emailAddress not in editors[updList]:
              editors[updList].append(emailAddress)
              updReqd = True
          else:
            if emailAddress in editors.get(updList, []):
              editors[updList].remove(emailAddress)
              updReqd = True
        elif updDomain:
          editors['domainUsersCanEdit'] = addEditor
          updReqd = True
        if updReqd:
          updateProtectedRangeRequests.append({'updateProtectedRange': {'protectedRange': protectedRange, 'fields': 'editors'}})
    if updateProtectedRangeRequests:
      callGAPI(sheet.spreadsheets(), 'batchUpdate',
               throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
               spreadsheetId=fileId, body={'requests': updateProtectedRangeRequests})
  except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
          GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
          GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
    entityActionFailedWarning([Ent.USER, user, Ent.SPREADSHEET, fileTitle], str(e), j, jcount)
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
    userDriveServiceNotEnabledWarning(user, str(e), i, count)

def _getSheetProtectedRanges(sheet, user, i, count, j, jcount, fileId, fileTitle,
                             statistics, stat):
  sheetProtectedRanges = []
  try:
    result = callGAPI(sheet.spreadsheets(), 'get',
                      throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                      spreadsheetId=fileId, fields='sheets(protectedRanges)')
    for rsheet in result.get('sheets', []):
      for protectedRange in rsheet.get('protectedRanges', []):
        sheetProtectedRanges.append({'updateProtectedRange': {'protectedRange': protectedRange, 'fields': 'editors'}})
  except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
          GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
          GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
    entityActionFailedWarning([Ent.USER, user, Ent.SPREADSHEET, fileTitle], str(e), j, jcount)
    _incrStatistic(statistics, stat)
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
    userDriveServiceNotEnabledWarning(user, str(e), i, count)
    _incrStatistic(statistics, stat)
  return sheetProtectedRanges

def _updateSheetProtectedRanges(sheet, user, i, count, j, jcount, newFileId, newFileTitle, sheetProtectedRanges,
                                statistics, stat):
  try:
    callGAPI(sheet.spreadsheets(), 'batchUpdate',
             throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
             spreadsheetId=newFileId, body={'requests': sheetProtectedRanges})
  except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
          GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
          GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
    entityActionFailedWarning([Ent.USER, user, Ent.SPREADSHEET, newFileTitle], str(e), j, jcount)
    _incrStatistic(statistics, stat)
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
    userDriveServiceNotEnabledWarning(user, str(e), i, count)
    _incrStatistic(statistics, stat)

def _identicalSourceTarget(fileId, targetChildren):
  for target in targetChildren:
    if fileId == target['id']:
      return True
  return False

def _checkForDuplicateTargetFile(drive, user, k, kcount, child, destFilename, targetChildren, copyMoveOptions, statistics):
  destFilenameLower = destFilename.lower()
  for target in targetChildren:
    if not target.get('processed', False) and destFilenameLower == target['name'].lower() and child['mimeType'] == target['mimeType']:
      target['processed'] = True
      if copyMoveOptions['duplicateFiles'] in [DUPLICATE_FILE_OVERWRITE_ALL, DUPLICATE_FILE_OVERWRITE_OLDER]:
        if (copyMoveOptions['duplicateFiles'] == DUPLICATE_FILE_OVERWRITE_OLDER) and (child['modifiedTime'] <= target['modifiedTime']):
          entityActionNotPerformedWarning([Ent.USER, user, Ent.DRIVE_FILE, child['name'], Ent.DRIVE_FILE, target['name']], Msg.DUPLICATE, k, kcount)
          _incrStatistic(statistics, STAT_FILE_DUPLICATE)
          return True
        if not target['capabilities']['canDelete']:
          entityActionNotPerformedWarning([Ent.USER, user, Ent.DRIVE_FILE, child['name'], Ent.DRIVE_FILE, target['name']], Msg.NOT_DELETABLE, k, kcount)
          _incrStatistic(statistics, STAT_FILE_FAILED)
          return True
        try:
          callGAPI(drive.files(), 'delete',
                   throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.FILE_NEVER_WRITABLE],
                   fileId=target['id'], supportsAllDrives=True)
          child['name'] = destFilename
          return False
        except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError, GAPI.fileNeverWritable) as e:
          entityActionNotPerformedWarning([Ent.USER, user, Ent.DRIVE_FILE, child['name'], Ent.DRIVE_FILE, target['name']], f'{Msg.NOT_DELETABLE}: {str(e)}', k, kcount)
          _incrStatistic(statistics, STAT_FILE_FAILED)
          return True
      if copyMoveOptions['duplicateFiles'] == DUPLICATE_FILE_DUPLICATE_NAME:
        child['name'] = destFilename
        return False
      if copyMoveOptions['duplicateFiles'] == DUPLICATE_FILE_UNIQUE_NAME:
        child['name'] = _getUniqueFilename(destFilename, child['mimeType'], targetChildren)
        return False
      #copyMoveOptions['duplicateFiles'] == DUPLICATE_FILE_SKIP
      entityActionNotPerformedWarning([Ent.USER, user, Ent.DRIVE_FILE, child['name'], Ent.DRIVE_FILE, target['name']], Msg.DUPLICATE, k, kcount)
      _incrStatistic(statistics, STAT_FILE_DUPLICATE)
      return True
  child['name'] = destFilename
  return False

def _getCopyMoveParentInfo(drive, user, i, count, j, jcount, newParentId, statistics):
# newParentId is known to be a folder
  try:
    result = callGAPI(drive.files(), 'get',
                      throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                      fileId=newParentId, fields='name,driveId,parents,modifiedTime', supportsAllDrives=True)
    if 'driveId' not in result:
      result['driveId'] = None
      if result['name'] == MY_DRIVE and not result.get('parents', []):
        result['destParentType'] = DEST_PARENT_MYDRIVE_ROOT
      else:
        result['destParentType'] = DEST_PARENT_MYDRIVE_FOLDER
    else:
      if result['name'] == TEAM_DRIVE and not result.get('parents', []):
        result['name'] = _getSharedDriveNameFromId(drive, result['driveId'])
        result['destParentType'] = DEST_PARENT_SHAREDDRIVE_ROOT
      else:
        result['destParentType'] = DEST_PARENT_SHAREDDRIVE_FOLDER
    return result
  except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions,
          GAPI.unknownError, GAPI.cannotCopyFile, GAPI.badRequest, GAPI.fileNeverWritable) as e:
    entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER_ID, newParentId], str(e), j, jcount)
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
    userDriveServiceNotEnabledWarning(user, str(e), i, count)
  _incrStatistic(statistics, STAT_FILE_FAILED)
  return None

def _getCopyMoveTargetInfo(drive, user, i, count, j, jcount, source, destFilename, newParentId, statistics, parentParms):
  try:
    return callGAPIpages(drive.files(), 'list', 'files',
                         throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                         retryReasons=[GAPI.UNKNOWN_ERROR],
                         q=f"mimeType = '{source['mimeType']}' and name contains '{escapeDriveFileName(_getFilenamePrefix(destFilename))}' and trashed = false and '{newParentId}' in parents",
                         orderBy='folder desc,name,modifiedTime desc',
                         fields='nextPageToken,files(id,name,capabilities,mimeType,modifiedTime)',
                         **parentParms[DFA_SEARCHARGS])
  except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions,
          GAPI.unknownError, GAPI.cannotCopyFile, GAPI.badRequest, GAPI.fileNeverWritable) as e:
    entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER_ID, newParentId], str(e), j, jcount)
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
    userDriveServiceNotEnabledWarning(user, str(e), i, count)
  _incrStatistic(statistics, STAT_FILE_FAILED)
  return None

def _verifyUserIsOrganizer(drive, user, i, count, fileId):
  role = UNKNOWN
  try:
    permissionId = getPermissionIdForEmail(user, i, count, user)
    role = callGAPI(drive.permissions(), 'get',
                    throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST, GAPI.PERMISSION_NOT_FOUND, GAPI.INSUFFICIENT_ADMINISTRATOR_PRIVILEGES],
                    useDomainAdminAccess=False,
                    fileId=fileId, permissionId=permissionId, fields='role', supportsAllDrives=True)['role']
    if role == 'organizer':
      return True
    entityActionNotPerformedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, fileId, Ent.ROLE, role], Msg.ROLE_MUST_BE_ORGANIZER, i, count)
  except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
          GAPI.badRequest, GAPI.insufficientAdministratorPrivileges) as e:
    entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, fileId], str(e), i, count)
  except GAPI.permissionNotFound:
    entityDoesNotHaveItemWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, fileId, Ent.PERMISSION_ID, permissionId], i, count)
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy):
    entityActionNotPerformedWarning([Ent.USER, user], Msg.UNABLE_TO_GET_PERMISSION_ID.format(user), i, count)
  return False

def _getCopyFolderNonInheritedPermissions(copyMoveOptions, copyNonInherited, sourceModifiedTime, targetModifiedTime):
  if ((copyMoveOptions[copyNonInherited] == COPY_NONINHERITED_PERMISSIONS_NEVER) or
      ((copyMoveOptions[copyNonInherited] == COPY_NONINHERITED_PERMISSIONS_SYNC_UPDATED_FOLDERS) and
       (sourceModifiedTime <= targetModifiedTime))):
    return 'noCopyNonInheritedPermissions'
  return copyNonInherited

def _checkForExistingShortcut(drive, fileId, fileName, parentId):
  try:
    existingShortcuts = callGAPI(drive.files(), 'list',
                                 throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID],
                                 retryReasons=[GAPI.UNKNOWN_ERROR],
                                 supportsAllDrives=True, includeItemsFromAllDrives=True,
                                 q=f"shortcutDetails.targetId = '{fileId}' and trashed = False", fields='files(id,name,parents)')['files']
    for shortcut in existingShortcuts:
      if parentId in shortcut['parents'] and fileName == shortcut['name']:
        return shortcut['id']
  except (GAPI.invalidQuery, GAPI.invalid, GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy):
    pass
  return None

copyReturnItemMap = {
  'returnidonly': 'id',
  'returnlinkonly': 'webViewLink',
  }

# gam <UserTypeEntity> copy drivefile <DriveFileEntity>
#	[newfilename <DriveFileName>] (replacefilename <REMatchPattern> <RESubstitution>)*
#	[stripnameprefix <String>]
#	[excludetrashed]
#	[(csv [todrive <ToDriveAttribute>*] (addcsvdata <FieldName> <String>)*)) |
#	 (returnidonly|returnlinkonly)]
#	[summary [<Boolean>]] [showpermissionsmessages [<Boolean>]]
#	[<DriveFileParentAttribute>]
#	[mergewithparent [<Boolean>]] [recursive [depth <Number>]]
#	<DriveFileCopyAttribute>*
#	[skipids <DriveFileEntity>]
#	[copysubfiles [<Boolean>]] [filenamematchpattern <REMatchPattern>] [filemimetype [not] <MimeTypeList>]
#	[copysubfolders [<Boolean>]] [foldernamematchpattern <REMatchPattern>]
#	[copysubshortcuts [<Boolean>]] [shortcutnamematchpattern <REMatchPattern>]
#	[duplicatefiles overwriteolder|overwriteall|duplicatename|uniquename|skip]
#	[duplicatefolders merge|duplicatename|uniquename|skip]
#	[copiedshortcutspointtocopiedfiles [<Boolean>]]
#	[copyfilepermissions [<Boolean>]]
#	[copyfileinheritedpermissions [<Boolean>]
#	[copyfilenoninheritedpermissions [<Boolean>]
#	[copymergewithparentfolderpermissions [<Boolean>]]
#	[copymergedtopfolderpermissions [<Boolean>]]
#	[copytopfolderpermissions [<Boolean>]]
#	[copytopfolderiheritedpermissions [<Boolean>]]
#	[copytopfoldernoniheritedpermissions never|always|syncallfolders|syncupdatedfolders]
#	[copymergedsubfolderpermissions [<Boolean>]]
#	[copysubfolderpermissions [<Boolean>]]
#	[copysubfolderinheritedpermissions [<Boolean>]]
#	[copysubfoldernoniheritedpermissions never|always|syncallfolders|syncupdatedfolders]
#	[copypermissionroles <DriveFileACLRoleList>]
#	[copypermissiontypes <DriveFileACLTypeList>]
#	[excludepermissionsfromdomains|includepermissionsfromdomains <DomainNameList>]
#	(mappermissionsdomain <DomainName> <DomainName>)*
#	[copysheetprotectedranges [<Boolean>]]
#	[copysheetprotectedrangesinheritedpermissions [<Boolean>]]
#	[copysheetprotectedrangesnoninheritedpermissions [<Boolean>]]
#	[sendemailifrequired [<Boolean>]]
#	[suppressnotselectedmessages [<Boolean>]]
#	[verifyorganizer [<Boolean>]]
#	[enforceexpansiveaccess [<Boolean>]]
def copyDriveFile(users):
  def _writeCSVData(user, oldName, oldId, newName, newId, mimeType):
    row = {'User': user, fileNameTitle: oldName, 'id': oldId,
           newFileNameTitle: newName, 'newId': newId, 'mimeType': mimeType}
    if addCSVData:
      row.update(addCSVData)
    csvPF.WriteRow(row)

  def _cloneFolderCopy(drive, user, i, count, j, jcount,
                       source, targetChildren, newFolderName, newParentId, newParentName, mergeParentModifiedTime,
                       statistics, copyMoveOptions, atTop):
    folderId = source.pop('id')
    folderName = source['name']
    folderNameId = f'{folderName}({folderId})'
    kvList = [Ent.USER, user, Ent.DRIVE_FOLDER, folderNameId]
    newParentNameId = f'{newParentName}({newParentId})'
# Merge top parent folder
    if atTop and copyMoveOptions['mergeWithParent']:
      action = Act.Get()
      Act.Set(Act.COPY_MERGE)
      if not csvPF:
        entityPerformActionModifierItemValueList(kvList, Act.MODIFIER_CONTENTS_WITH, [Ent.DRIVE_FOLDER, newParentNameId], j, jcount)
      else:
        _writeCSVData(user, folderName, folderId, newParentName, newParentId, MIMETYPE_GA_FOLDER)
      Act.Set(action)
      _incrStatistic(statistics, STAT_FOLDER_MERGED)
      if (copyMoveOptions['copyMergeWithParentFolderPermissions'] and
          copyMoveOptions['destParentType'] != DEST_PARENT_MYDRIVE_ROOT):
        copyFolderNonInheritedPermissions =\
            _getCopyFolderNonInheritedPermissions(copyMoveOptions,
                                                  'copyTopFolderNonInheritedPermissions',
                                                  source['modifiedTime'], mergeParentModifiedTime)
        _copyPermissions(drive, user, i, count, j, jcount,
                         Ent.DRIVE_FOLDER, folderId, folderName, newParentId, newFolderName,
                         statistics, STAT_FOLDER_PERMISSIONS_FAILED,
                         copyMoveOptions, True,
                         'copyTopFolderInheritedPermissions',
                         copyFolderNonInheritedPermissions,
                         True)
      return (newParentId, newParentName, True)
# Merge parent folders
    if copyMoveOptions['duplicateFolders'] == DUPLICATE_FOLDER_MERGE:
      newFolderNameLower = newFolderName.lower()
      for target in targetChildren:
        if not target.get('processed', False) and newFolderNameLower == target['name'].lower() and source['mimeType'] == target['mimeType']:
          target['processed'] = True
          if target['capabilities']['canAddChildren']:
            newFolderId = target['id']
            action = Act.Get()
            Act.Set(Act.COPY_MERGE)
            if not csvPF:
              entityModifierItemValueListActionPerformed(kvList, Act.MODIFIER_CONTENTS_WITH, [Ent.DRIVE_FOLDER, f'{newFolderName}({newFolderId})'], j, jcount)
            else:
              _writeCSVData(user, folderName, folderId, newFolderName, newFolderId, MIMETYPE_GA_FOLDER)
            Act.Set(action)
            _incrStatistic(statistics, STAT_FOLDER_MERGED)
            if (copyMoveOptions[['copyMergedSubFolderPermissions', 'copyMergedTopFolderPermissions'][atTop]] and
                (not atTop or copyMoveOptions['destParentType'] != DEST_PARENT_MYDRIVE_ROOT)):
              copyFolderNonInheritedPermissions =\
                  _getCopyFolderNonInheritedPermissions(copyMoveOptions,
                                                        ['copySubFolderNonInheritedPermissions', 'copyTopFolderNonInheritedPermissions'][atTop],
                                                        source['modifiedTime'], target['modifiedTime'])
              _copyPermissions(drive, user, i, count, j, jcount,
                               Ent.DRIVE_FOLDER, folderId, folderName, newFolderId, newFolderName,
                               statistics, STAT_FOLDER_PERMISSIONS_FAILED,
                               copyMoveOptions, atTop,
                               ['copySubFolderInheritedPermissions', 'copyTopFolderInheritedPermissions'][atTop],
                               copyFolderNonInheritedPermissions,
                               False)
            return (newFolderId, newFolderName, True)
          entityActionFailedWarning(kvList+[Ent.DRIVE_FOLDER, newParentNameId], Msg.NOT_WRITABLE, j, jcount)
          _incrStatistic(statistics, STAT_FOLDER_NOT_WRITABLE)
          return (None, None, False)
    elif copyMoveOptions['duplicateFolders'] == DUPLICATE_FOLDER_UNIQUE_NAME:
      newFolderName = _getUniqueFilename(newFolderName, source['mimeType'], targetChildren)
    elif copyMoveOptions['duplicateFolders'] == DUPLICATE_FOLDER_SKIP:
      targetChild = _targetFilenameExists(newFolderName, source['mimeType'], targetChildren)
      if targetChild is not None:
        entityModifierItemValueListActionNotPerformedWarning(kvList, Act.MODIFIER_TO,
                                                             [Ent.DRIVE_FOLDER, newParentNameId, Ent.DRIVE_FOLDER, f"{newFolderName}({targetChild['id']})"],
                                                             Msg.DUPLICATE, j, jcount)
        _incrStatistic(statistics, STAT_FOLDER_DUPLICATE)
        return (None, None, False)
# Copy parent folders
    body = source.copy()
    body.pop('capabilities', None)
    if copyMoveOptions['sourceDriveId'] or copyMoveOptions['destDriveId']:
      body.pop('copyRequiresWriterPermission', None)
      body.pop('writersCanShare', None)
    body.pop('trashed', None)
    body.pop('driveId', None)
    body['name'] = newFolderName
    try:
      result = callGAPI(drive.files(), 'create',
                        throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                    GAPI.INTERNAL_ERROR, GAPI.STORAGE_QUOTA_EXCEEDED,
                                                                    GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP, GAPI.BAD_REQUEST],
                        body=body, fields='id,webViewLink,modifiedTime', supportsAllDrives=True)
      newFolderId = result['id']
      if returnIdLink:
        writeStdout(f'{result[returnIdLink]}\n')
      elif not csvPF:
        entityModifierItemValueListActionPerformed(kvList, Act.MODIFIER_TO,
                                                   [Ent.DRIVE_FOLDER, newParentNameId, Ent.DRIVE_FOLDER, f'{newFolderName}({newFolderId})'],
                                                   j, jcount)
      else:
        _writeCSVData(user, folderName, folderId, newFolderName, newFolderId, body['mimeType'])
      _incrStatistic(statistics, STAT_FOLDER_COPIED_MOVED)
      if copyMoveOptions[['copySubFolderPermissions', 'copyTopFolderPermissions'][atTop]]:
        _copyPermissions(drive, user, i, count, j, jcount,
                         Ent.DRIVE_FOLDER, folderId, folderName, newFolderId, newFolderName,
                         statistics, STAT_FOLDER_PERMISSIONS_FAILED,
                         copyMoveOptions, False,
                         ['copySubFolderInheritedPermissions', 'copyTopFolderInheritedPermissions'][atTop],
                         ['copySubFolderNonInheritedPermissions', 'copyTopFolderNonInheritedPermissions'][atTop],
                         True)
      return (newFolderId, newFolderName, False)
    except (GAPI.forbidden, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions, GAPI.internalError,
            GAPI.storageQuotaExceeded, GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep, GAPI.badRequest) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FOLDER, newFolderName], str(e), j, jcount)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
    _incrStatistic(statistics, STAT_FOLDER_FAILED)
    copyMoveOptions['retainSourceFolders'] = True
    return (None, None, False)

  def _makeCopyShortcut(drive, user, k, kcount, entityType, childId, childName, newChildName, newParentId, newParentName, targetId):
    kvList = [Ent.USER, user, entityType, f'{childName}({childId})']
    if entityType == Ent.DRIVE_FILE:
      targetEntityType = Ent.DRIVE_FILE_SHORTCUT
      statShortcutCreated = STAT_FILE_SHORTCUT_CREATED
      statShortcutExists = STAT_FILE_SHORTCUT_EXISTS
    else:
      targetEntityType = Ent.DRIVE_FOLDER_SHORTCUT
      statShortcutCreated = STAT_FOLDER_SHORTCUT_CREATED
      statShortcutExists = STAT_FOLDER_SHORTCUT_EXISTS
    newParentNameId = f'{newParentName}({newParentId})'
    action = Act.Get()
    existingShortcut = _checkForExistingShortcut(drive, targetId, newChildName, newParentId)
    if existingShortcut:
      Act.Set(Act.CREATE_SHORTCUT)
      entityModifierItemValueListActionPerformed(kvList, Act.MODIFIER_PREVIOUSLY_IN,
                                                 [Ent.DRIVE_FOLDER, newParentNameId, targetEntityType, f"{childName}({existingShortcut})"],
                                                 k, kcount)
      Act.Set(action)
      _incrStatistic(statistics, statShortcutExists)
      return
    body = {'name': newChildName, 'mimeType': MIMETYPE_GA_SHORTCUT,
            'parents': [newParentId], 'shortcutDetails': {'targetId': targetId}}
    try:
      result = callGAPI(drive.files(), 'create',
                        throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                    GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND, GAPI.UNKNOWN_ERROR,
                                                                    GAPI.STORAGE_QUOTA_EXCEEDED, GAPI.TEAMDRIVES_SHARING_RESTRICTION_NOT_ALLOWED,
                                                                    GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP,
                                                                    GAPI.SHORTCUT_TARGET_INVALID, GAPI.SHARE_OUT_WARNING],
                        body=body, fields='id', supportsAllDrives=True)
      Act.Set(Act.CREATE_SHORTCUT)
      entityModifierItemValueListActionPerformed(kvList, Act.MODIFIER_IN,
                                                 [Ent.DRIVE_FOLDER, newParentNameId, targetEntityType, f"{newChildName}({result['id']})"],
                                                 k, kcount)
      Act.Set(action)
      _incrStatistic(statistics, statShortcutCreated)
    except (GAPI.forbidden, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions,
            GAPI.invalid, GAPI.badRequest, GAPI.fileNotFound, GAPI.unknownError,
            GAPI.storageQuotaExceeded, GAPI.teamDrivesSharingRestrictionNotAllowed,
            GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep,
            GAPI.shortcutTargetInvalid, GAPI.shareOutWarning) as e:
      entityActionFailedWarning(kvList+[Ent.DRIVE_FILE_SHORTCUT, childName], str(e), k, kcount)
      _incrStatistic(statistics, STAT_FILE_FAILED)

  def _checkChildCopyAllowed(childMimeType, childName, child):
    if childMimeType == MIMETYPE_GA_FOLDER:
      if not copyMoveOptions['copySubFolders']:
        return False
      nameMatchPattern = copyMoveOptions['folderNameMatchPattern']
    elif childMimeType == MIMETYPE_GA_SHORTCUT:
      if not copyMoveOptions['copySubShortcuts']:
        return False
      nameMatchPattern = copyMoveOptions['shortcutNameMatchPattern']
    else:
      if not copyMoveOptions['copySubFiles']:
        return False
      if copyMoveOptions['copySubFilesOwnedBy'] is not None:
        if child.get('driveId', None) is None and child.get('ownedByMe', False) != copyMoveOptions['copySubFilesOwnedBy']:
          return False
      if copyMoveOptions['fileMimeTypes']:
        if not copyMoveOptions['notMimeTypes']:
          if childMimeType not in copyMoveOptions['fileMimeTypes']:
            return False
        else:
          if childMimeType in copyMoveOptions['fileMimeTypes']:
            return False
      nameMatchPattern = copyMoveOptions['fileNameMatchPattern']
    return not nameMatchPattern or nameMatchPattern.match(childName)

  def _recursiveFolderCopy(drive, user, i, count, j, jcount,
                           source, targetChildren, newFolderName, newParentId, newParentName, mergeParentModifiedTime, atTop, depth):
    folderId = source['id']
    newFolderId, newFolderName, existingTargetFolder = _cloneFolderCopy(drive, user, i, count, j, jcount,
                                                                        source, targetChildren, newFolderName,
                                                                        newParentId, newParentName, mergeParentModifiedTime,
                                                                        statistics, copyMoveOptions, atTop)
    if newFolderId is None:
      return
    newFolderNameId = f'{newFolderName}({newFolderId})'
    copiedSourceFiles[folderId] = newFolderId
    copiedTargetFiles.add(newFolderId) # Don't recopy folder copied into a sub-folder
    if maxdepth != -1 and depth > maxdepth:
      return
    depth += 1
    sourceChildren = callGAPIpages(drive.files(), 'list', 'files',
                                   throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                                   retryReasons=[GAPI.UNKNOWN_ERROR],
                                   q=WITH_PARENTS.format(folderId),
                                   orderBy='folder desc,name,modifiedTime desc',
                                   fields='nextPageToken,files(id,name,parents,appProperties,capabilities,contentHints,copyRequiresWriterPermission,'\
                                     'description,folderColorRgb,mimeType,modifiedTime,ownedByMe,properties,starred,driveId,trashed,viewedByMeTime,writersCanShare,'\
                                     'shortcutDetails(targetId,targetMimeType))',
                                   pageSize=GC.Values[GC.DRIVE_MAX_RESULTS], **sourceSearchArgs)
    kcount = len(sourceChildren)
    if kcount > 0:
      if existingTargetFolder:
        subTargetChildren = callGAPIpages(drive.files(), 'list', 'files',
                                          throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                                          retryReasons=[GAPI.UNKNOWN_ERROR],
                                          q=ANY_NON_TRASHED_WITH_PARENTS.format(newFolderId),
                                          orderBy='folder desc,name,modifiedTime desc',
                                          fields='nextPageToken,files(id,name,capabilities,mimeType,modifiedTime)',
                                          **parentParms[DFA_SEARCHARGS])
      else:
        subTargetChildren = []
      Ind.Increment()
      k = 0
      for child in sourceChildren:
        k += 1
        childId = child['id']
        childName = child['name']
        childNameId = f'{childName}({childId})'
        childMimeType = child['mimeType']
        if childId in copiedTargetFiles: # Don't recopy file/folder copied into a sub-folder
          continue
        kvList = [Ent.USER, user, _getEntityMimeType(child), childNameId]
        if childId in skipFileIdEntity['list']:
          if not suppressNotSelectedMessages:
            entityActionNotPerformedWarning(kvList, Msg.IN_SKIPIDS, k, kcount)
          _incrStatistic(statistics, STAT_FILE_IN_SKIPIDS)
          continue
        if not _checkChildCopyAllowed(childMimeType, childName, child):
          if not suppressNotSelectedMessages:
            entityActionNotPerformedWarning(kvList, Msg.NOT_SELECTED, k, kcount)
          continue
        child.pop('ownedByMe', None)
        trashed = child.pop('trashed', False)
        if (childId == newFolderId) or (excludeTrashed and trashed):
          entityActionNotPerformedWarning(kvList,
                                          [Msg.NOT_COPYABLE, Msg.IN_TRASH_AND_EXCLUDE_TRASHED][trashed], k, kcount)
          _incrStatistic(statistics, STAT_FILE_NOT_COPYABLE_MOVABLE)
          continue
        if copyMoveOptions['replaceFilename']:
          newChildName = processFilenameReplacements(childName, copyMoveOptions['replaceFilename'])
        else:
          newChildName = childName
# If source child has already been copied, make shortcut to its copy
        if childId in copiedSourceFiles:
          _makeCopyShortcut(drive, user, k, kcount, _getEntityMimeType(child), childId, childName,
                            newChildName, newFolderId, newFolderName, copiedSourceFiles[childId])
          continue
        child.pop('parents', [])
        child['parents'] = [newFolderId]
        if childMimeType == MIMETYPE_GA_FOLDER:
          _recursiveFolderCopy(drive, user, i, count, k, kcount,
                               child, subTargetChildren, newChildName, newFolderId, newFolderName, child['modifiedTime'],
                               False, depth)
        elif childMimeType == MIMETYPE_GA_SHORTCUT:
          shortcutsToCreate.append({'childName': childName, 'childId': childId, 'newChildName': newChildName,
                                    'newFolderId': newFolderId, 'newFolderName': newFolderName,
                                    'targetId': child['shortcutDetails']['targetId'], 'mimeType': child['shortcutDetails']['targetMimeType']})
        else:
          if not child.pop('capabilities')['canCopy']:
            entityActionFailedWarning(kvList, Msg.NOT_COPYABLE, k, kcount)
            _incrStatistic(statistics, STAT_FILE_NOT_COPYABLE_MOVABLE)
            continue
          if existingTargetFolder:
            if _checkForDuplicateTargetFile(drive, user, k, kcount, child, newChildName, subTargetChildren, copyMoveOptions, statistics):
              continue
          else:
            child['name'] = newChildName
          child.pop('id')
          if copyMoveOptions['destDriveId']:
            child.pop('copyRequiresWriterPermission', None)
            child.pop('writersCanShare', None)
          child.pop('driveId', None)
          if childMimeType == MIMETYPE_GA_SHORTCUT:
            child.pop('folderColorRgb', None)
          child.pop('mimeType')
          try:
            result = callGAPI(drive.files(), 'copy',
                              bailOnInternalError=True,
                              throwReasons=GAPI.DRIVE_COPY_THROW_REASONS+[GAPI.INTERNAL_ERROR, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                          GAPI.TEAMDRIVES_SHORTCUT_FILE_NOT_SUPPORTED, GAPI.SHARE_OUT_WARNING],
                              fileId=childId, body=child, fields='id,name', supportsAllDrives=True)
            if not csvPF:
              entityModifierItemValueListActionPerformed(kvList, Act.MODIFIER_TO,
                                                         [Ent.DRIVE_FOLDER, newFolderNameId, Ent.DRIVE_FILE, f"{result['name']}({result['id']})"],
                                                         k, kcount)
            else:
              _writeCSVData(user, childName, childId, result['name'], result['id'], childMimeType)
            _incrStatistic(statistics, STAT_FILE_COPIED_MOVED)
            copiedSourceFiles[childId] = result['id']
            copiedTargetFiles.add(result['id']) # Don't recopy file copied into a sub-folder
            if (childMimeType == MIMETYPE_GA_SPREADSHEET and
                (copyMoveOptions['copySheetProtectedRangesInheritedPermissions'] or
                 copyMoveOptions['copySheetProtectedRangesNonInheritedPermissions'] != COPY_NONINHERITED_PERMISSIONS_NEVER)):
              protectedSheetRanges = _getSheetProtectedRanges(sheet, user, i, count, k, kcount, childId, childName,
                                                              statistics, STAT_FILE_PROTECTEDRANGES_FAILED)
              _copyPermissions(drive, user, i, count, k, kcount,
                               Ent.DRIVE_FILE, childId, childName, result['id'], result['name'],
                               statistics, STAT_FILE_PERMISSIONS_FAILED,
                               copyMoveOptions, False,
                               'copySheetProtectedRangesInheritedPermissions',
                               'copySheetProtectedRangesNonInheritedPermissions',
                               True)
              _updateSheetProtectedRanges(sheet, user, i, count, k, kcount, result['id'], result['name'], protectedSheetRanges,
                                          statistics, STAT_FILE_PROTECTEDRANGES_FAILED)
            elif copyMoveOptions['copyFilePermissions']:
              _copyPermissions(drive, user, i, count, k, kcount,
                               Ent.DRIVE_FILE, childId, childName, result['id'], result['name'],
                               statistics, STAT_FILE_PERMISSIONS_FAILED,
                               copyMoveOptions, False,
                               'copyFileInheritedPermissions',
                               'copyFileNonInheritedPermissions',
                               True)
          except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions,
                  GAPI.insufficientParentPermissions, GAPI.unknownError,
                  GAPI.invalid, GAPI.cannotCopyFile, GAPI.badRequest, GAPI.responsePreparationFailure, GAPI.fileNeverWritable, GAPI.fieldNotWritable,
                  GAPI.teamDrivesSharingRestrictionNotAllowed, GAPI.rateLimitExceeded, GAPI.userRateLimitExceeded,
                  GAPI.internalError, GAPI.teamDrivesShortcutFileNotSupported, GAPI.shareOutWarning) as e:
            entityActionFailedWarning(kvList, str(e), k, kcount)
            _incrStatistic(statistics, STAT_FILE_FAILED)
          except (GAPI.storageQuotaExceeded, GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep) as e:
            entityActionFailedWarning(kvList, str(e), k, kcount)
            _incrStatistic(statistics, STAT_FILE_FAILED)
            break
      Ind.Decrement()

  fileIdEntity = getDriveFileEntity()
  csvPF = None
  addCSVData = {}
  returnIdLink = None
  copyBody = {}
  parentBody = {}
  parentParms = initDriveFileAttributes()
  copyParameters = initDriveFileAttributes()
  copyMoveOptions = initCopyMoveOptions(True)
  excludeTrashed = newParentsSpecified = recursive = suppressNotSelectedMessages = False
  maxdepth = -1
  verifyOrganizer = True
  skipFileIdEntity = initDriveFileEntity()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if getCopyMoveOptions(myarg, copyMoveOptions):
      pass
    elif getDriveFileParentAttribute(myarg, parentParms):
      newParentsSpecified = True
    elif myarg in copyReturnItemMap:
      returnIdLink = copyReturnItemMap[myarg]
    elif myarg == 'excludetrashed':
      excludeTrashed = True
    elif myarg == 'recursive':
      recursive = getBoolean()
    elif myarg == 'depth':
      maxdepth = getInteger(minVal=-1)
    elif myarg == 'skipids':
      skipFileIdEntity = getDriveFileEntity()
    elif myarg == 'convert':
      deprecatedArgument(myarg)
    elif myarg == 'csv':
      csvPF = CSVPrintFile()
    elif csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif csvPF and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg == 'suppressnotselectedmessages':
      suppressNotSelectedMessages = getBoolean()
    elif getDriveFileCopyAttribute(myarg, copyBody, copyParameters):
      pass
    elif myarg == 'verifyorganizer':
      verifyOrganizer = getBoolean()
    else:
      unknownArgumentExit()
  if csvPF:
    fileNameTitle = 'title' if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES] else 'name'
    newFileNameTitle = f'new{fileNameTitle.capitalize()}'
    csvPF.SetTitles(['User', fileNameTitle, 'id', newFileNameTitle, 'newId', 'mimeType'])
    if addCSVData:
      csvPF.AddTitles(sorted(addCSVData.keys()))
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity,
                                                  entityType=Ent.DRIVE_FILE_OR_FOLDER if returnIdLink is None else None)
    if jcount == 0:
      continue
    if not _getDriveFileParentInfo(drive, user, i, count, parentBody, parentParms):
      continue
    if (copyMoveOptions['copySheetProtectedRangesInheritedPermissions'] or
        copyMoveOptions['copySheetProtectedRangesNonInheritedPermissions'] != COPY_NONINHERITED_PERMISSIONS_NEVER):
      _, sheet = buildGAPIServiceObject(API.SHEETS, user, i, count)
      if not sheet:
        continue
    copiedSourceFiles = {}
    copiedTargetFiles = set()
    shortcutsToCreate = []
    statistics = _initStatistics()
    if skipFileIdEntity['query'] or skipFileIdEntity[ROOT]:
      _validateUserGetFileIDs(origUser, i, count, skipFileIdEntity, drive=drive)
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      try:
        source = callGAPI(drive.files(), 'get',
                          throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                          fileId=fileId,
                          fields='id,name,parents,appProperties,capabilities,contentHints,copyRequiresWriterPermission,'\
                            'description,mimeType,modifiedTime,properties,starred,driveId,trashed,viewedByMeTime,writersCanShare',
                          supportsAllDrives=True)
# Source at root of Shared Drive?
        sourceMimeType = source['mimeType']
        if sourceMimeType == MIMETYPE_GA_FOLDER and source.get('driveId') and source['name'] == TEAM_DRIVE and not source.get('parents', []):
          copyMoveOptions['sourceIsMyDriveSharedDrive'] = True
          source['name'] = _getSharedDriveNameFromId(drive, source['driveId'])
        sourceName = source['name']
        sourceNameId = f"{sourceName}({source['id']})"
        copyMoveOptions['sourceDriveId'] = source.get('driveId')
        kvList = [Ent.USER, user, _getEntityMimeType(source), sourceNameId]
        if fileId in copiedSourceFiles:
          entityActionNotPerformedWarning(kvList, Msg.DUPLICATE, j, jcount)
          _incrStatistic(statistics, STAT_FILE_DUPLICATE)
          continue
        if fileId in skipFileIdEntity['list']:
          entityActionNotPerformedWarning(kvList, Msg.IN_SKIPIDS, j, jcount)
          _incrStatistic(statistics, STAT_FILE_IN_SKIPIDS)
          continue
        trashed = source.pop('trashed', False)
        if excludeTrashed and trashed:
          entityActionNotPerformedWarning(kvList, Msg.IN_TRASH_AND_EXCLUDE_TRASHED, j, jcount)
          _incrStatistic(statistics, STAT_FILE_NOT_COPYABLE_MOVABLE)
          continue
        if copyMoveOptions['sourceDriveId']:
# If copying from a Shared Drive, user has to be an organizer
          if verifyOrganizer and not _verifyUserIsOrganizer(drive, user, i, count, copyMoveOptions['sourceDriveId']):
            _incrStatistic(statistics, STAT_USER_NOT_ORGANIZER)
            continue
          sourceSearchArgs = {'driveId': copyMoveOptions['sourceDriveId'], 'corpora': 'drive', 'includeItemsFromAllDrives': True, 'supportsAllDrives': True}
        else:
          sourceSearchArgs = {}
        sourceParents = source.pop('parents', [])
        if newParentsSpecified:
          newParents = parentBody['parents']
          numNewParents = len(newParents)
          if numNewParents > 1:
            entityActionNotPerformedWarning(kvList, Msg.MULTIPLE_PARENTS_SPECIFIED.format(numNewParents), j, jcount)
            _incrStatistic(statistics, STAT_FILE_FAILED)
            continue
        else:
          newParents = sourceParents if sourceParents else [ROOT]
        source['parents'] = newParents
        newParentId = newParents[0]
        dest = _getCopyMoveParentInfo(drive, user, i, count, j, jcount, newParentId, statistics)
        if dest is None:
          continue
        newParentName = dest['name']
        newParentNameId = f'{newParentName}({newParentId})'
        copyMoveOptions['destDriveId'] = dest['driveId']
        copyMoveOptions['destParentType'] = dest['destParentType']
        if copyMoveOptions['destDriveId']:
# If copying to a Shared Drive, user has to be an organizer
          if verifyOrganizer and not _verifyUserIsOrganizer(drive, user, i, count, copyMoveOptions['destDriveId']):
            _incrStatistic(statistics, STAT_USER_NOT_ORGANIZER)
            continue
          if not parentParms[DFA_SEARCHARGS]:
            parentParms[DFA_SEARCHARGS] = {'driveId': copyMoveOptions['destDriveId'], 'corpora': 'drive',
                                           'includeItemsFromAllDrives': True, 'supportsAllDrives': True}
        if copyMoveOptions['newFilename']:
          destName = copyMoveOptions['newFilename']
        elif (sourceMimeType == MIMETYPE_GA_FOLDER) and copyMoveOptions['mergeWithParent']:
          destName = dest['name']
        elif ((newParentsSpecified and newParentId not in sourceParents) or
              ((newParentId in sourceParents and
                (sourceMimeType == MIMETYPE_GA_FOLDER and copyMoveOptions['duplicateFolders'] != DUPLICATE_FOLDER_MERGE) or
                (sourceMimeType != MIMETYPE_GA_FOLDER and copyMoveOptions['duplicateFiles'] not in [DUPLICATE_FILE_OVERWRITE_ALL, DUPLICATE_FILE_OVERWRITE_OLDER])))):
          if copyMoveOptions['replaceFilename']:
            destName = processFilenameReplacements(sourceName, copyMoveOptions['replaceFilename'])
          else:
            destName = sourceName
        elif copyMoveOptions['replaceFilename']:
          destName = processFilenameReplacements(sourceName, copyMoveOptions['replaceFilename'])
        else:
          destName = f'Copy of {sourceName}'
        if copyMoveOptions['stripNamePrefix'] and destName.startswith(copyMoveOptions['stripNamePrefix']):
          destName = destName[len(copyMoveOptions['stripNamePrefix']):]
        targetChildren = _getCopyMoveTargetInfo(drive, user, i, count, j, jcount, source, destName, newParentId, statistics, parentParms)
        if targetChildren is None:
          continue
# Copy folder
        if sourceMimeType == MIMETYPE_GA_FOLDER:
          copiedTargetFiles.add(newParentId) # Don't recopy folder copied into a sub-folder
          if fileId == newParentId:
            entityActionNotPerformedWarning(kvList, Msg.NOT_COPYABLE_INTO_ITSELF, j, jcount)
            _incrStatistic(statistics, STAT_FOLDER_FAILED)
            continue
          if copyMoveOptions['duplicateFolders'] == DUPLICATE_FOLDER_MERGE:
            if _identicalSourceTarget(fileId, targetChildren):
              entityActionNotPerformedWarning(kvList, Msg.NOT_COPYABLE_SAME_NAME_CURRENT_FOLDER_MERGE, j, jcount)
              _incrStatistic(statistics, STAT_FOLDER_FAILED)
              continue
          elif copyMoveOptions['duplicateFolders'] == DUPLICATE_FOLDER_UNIQUE_NAME:
            destName = _getUniqueFilename(destName, sourceMimeType, targetChildren)
          elif copyMoveOptions['duplicateFolders'] == DUPLICATE_FOLDER_SKIP:
            targetChild = _targetFilenameExists(destName, sourceMimeType, targetChildren)
            if targetChild is not None:
              entityModifierItemValueListActionNotPerformedWarning(kvList, Act.MODIFIER_TO,
                                                                   [Ent.DRIVE_FOLDER, newParentNameId, Ent.DRIVE_FOLDER, f"{destName}({targetChild['id']})"],
                                                                   Msg.DUPLICATE, j, jcount)
              _incrStatistic(statistics, STAT_FOLDER_DUPLICATE)
              continue
          if recursive:
            _recursiveFolderCopy(drive, user, i, count, j, jcount,
                                 source, targetChildren, destName, newParentId, newParentName, dest['modifiedTime'],
                                 True, 0)
            kcount = len(shortcutsToCreate)
            if kcount > 0:
              entityPerformActionNumItems([Ent.USER, user], kcount, Ent.DRIVE_FILE_SHORTCUT, i, count)
              Ind.Increment()
              k = 0
              for shortcut in shortcutsToCreate:
                k += 1
                targetId = shortcut['targetId']
                if targetId in copiedSourceFiles and copyMoveOptions['copiedShortcutsPointToCopiedFiles']:
                  targetId = copiedSourceFiles[targetId]
                _makeCopyShortcut(drive, user, k, kcount, _getEntityMimeType(shortcut), shortcut['childId'], shortcut['childName'],
                                  shortcut['newChildName'], shortcut['newFolderId'], shortcut['newFolderName'], targetId)
              Ind.Decrement()
          else:
            source.update(copyBody)
            _cloneFolderCopy(drive, user, i, count, j, jcount,
                             source, targetChildren, destName, newParentId, newParentName, dest['modifiedTime'],
                             statistics, copyMoveOptions, True)
# Copy file
        else:
          if not source.pop('capabilities')['canCopy']:
            entityActionFailedWarning(kvList, Msg.NOT_COPYABLE, j, jcount)
            _incrStatistic(statistics, STAT_FILE_NOT_COPYABLE_MOVABLE)
            continue
          if copyMoveOptions['duplicateFiles'] in [DUPLICATE_FILE_OVERWRITE_ALL, DUPLICATE_FILE_OVERWRITE_OLDER] and _identicalSourceTarget(fileId, targetChildren):
            entityActionNotPerformedWarning(kvList, Msg.NOT_COPYABLE_SAME_NAME_CURRENT_FOLDER_OVERWRITE, j, jcount)
            _incrStatistic(statistics, STAT_FILE_FAILED)
            continue
          if _checkForDuplicateTargetFile(drive, user, j, jcount, source, destName, targetChildren, copyMoveOptions, statistics):
            continue
          sourceId = source.pop('id')
          sourceMimeType = source.pop('mimeType')
          if copyMoveOptions['destDriveId']:
            source.pop('copyRequiresWriterPermission', None)
            source.pop('writersCanShare', None)
          source.pop('driveId', None)
          if sourceMimeType == MIMETYPE_GA_SHORTCUT:
            source.pop('folderColorRgb', None)
          source.update(copyBody)
          result = callGAPI(drive.files(), 'copy',
                            bailOnInternalError=True,
                            throwReasons=GAPI.DRIVE_COPY_THROW_REASONS+[GAPI.INTERNAL_ERROR, GAPI.INSUFFICIENT_PARENT_PERMISSIONS],
                            fileId=fileId,
                            ignoreDefaultVisibility=copyParameters[DFA_IGNORE_DEFAULT_VISIBILITY],
                            keepRevisionForever=copyParameters[DFA_KEEP_REVISION_FOREVER],
                            body=source, fields='id,name,webViewLink', supportsAllDrives=True)
          if returnIdLink:
            writeStdout(f'{result[returnIdLink]}\n')
          elif not csvPF:
            entityModifierItemValueListActionPerformed(kvList, Act.MODIFIER_TO,
                                                       [Ent.DRIVE_FOLDER, newParentNameId, Ent.DRIVE_FILE, f"{result['name']}({result['id']})"],
                                                       j, jcount)
          else:
            _writeCSVData(user, sourceName, sourceId, result['name'], result['id'], sourceMimeType)
          _incrStatistic(statistics, STAT_FILE_COPIED_MOVED)
          if (sourceMimeType == MIMETYPE_GA_SPREADSHEET and
              (copyMoveOptions['copySheetProtectedRangesInheritedPermissions'] or
               copyMoveOptions['copySheetProtectedRangesNonInheritedPermissions'] != COPY_NONINHERITED_PERMISSIONS_NEVER)):
            protectedSheetRanges = _getSheetProtectedRanges(sheet, user, i, count, j, jcount, sourceId, sourceName,
                                                            statistics, STAT_FILE_PROTECTEDRANGES_FAILED)
            _copyPermissions(drive, user, i, count, j, jcount,
                             Ent.DRIVE_FILE, sourceId, sourceName, result['id'], result['name'],
                             statistics, STAT_FILE_PERMISSIONS_FAILED,
                             copyMoveOptions, False,
                             'copySheetProtectedRangesInheritedPermissions',
                             'copySheetProtectedRangesNonInheritedPermissions',
                             True)
            _updateSheetProtectedRanges(sheet, user, i, count, j, jcount, result['id'], result['name'], protectedSheetRanges,
                                        statistics, STAT_FILE_PROTECTEDRANGES_FAILED)
          elif copyMoveOptions['copyFilePermissions']:
            _copyPermissions(drive, user, i, count, j, jcount,
                             Ent.DRIVE_FILE, sourceId, sourceName, result['id'], result['name'],
                             statistics, STAT_FILE_PERMISSIONS_FAILED,
                             copyMoveOptions, False,
                             'copyFileInheritedPermissions',
                             'copyFileNonInheritedPermissions',
                             True)
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions,
              GAPI.insufficientParentPermissions, GAPI.unknownError,
              GAPI.invalid, GAPI.badRequest, GAPI.cannotCopyFile, GAPI.responsePreparationFailure, GAPI.fileNeverWritable, GAPI.fieldNotWritable,
              GAPI.teamDrivesSharingRestrictionNotAllowed, GAPI.rateLimitExceeded, GAPI.userRateLimitExceeded,
              GAPI.storageQuotaExceeded, GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER_ID, fileId], str(e), j, jcount)
        _incrStatistic(statistics, STAT_FILE_FAILED)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        _incrStatistic(statistics, STAT_FILE_FAILED)
        break
    Ind.Decrement()
    if copyMoveOptions['summary']:
      _printStatistics(user, statistics, i, count, True)
  if csvPF:
    csvPF.writeCSVfile('Copied Files-Folders')

def _updateMoveFilePermissions(drive, user, i, count,
                               entityType, fileId, fileTitle,
                               statistics, stat, copyMoveOptions):
  def getPermissions(fid):
    permissions = {}
    try:
      result = callGAPIpages(drive.permissions(), 'list', 'permissions',
                             throwReasons=GAPI.DRIVE3_GET_ACL_REASONS,
                             retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                             fileId=fid,
                             fields='nextPageToken,permissions(allowFileDiscovery,domain,emailAddress,expirationTime,id,role,type,deleted,view,pendingOwner,permissionDetails)',
                             useDomainAdminAccess=copyMoveOptions['useDomainAdminAccess'], supportsAllDrives=True)
      for permission in result:
        permission.pop('teamDrivePermissionDetails', None)
        if not permission.pop('permissionDetails', [{'inherited': False}])[0]['inherited']:
          permissions[permission['id']] = permission
      return permissions
    except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError,
            GAPI.insufficientAdministratorPrivileges, GAPI.insufficientFilePermissions,
            GAPI.unknownError, GAPI.invalid):
      pass
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      _incrStatistic(statistics, stat)
    return None

  def permissionKVList(user, entityType, title, permission):
    permstr = f"noninherited/{permission['role']}/{permission['type']}"
    if permission['type'] in {'group', 'user'}:
      permstr += f"/{permission['emailAddress']}"
    elif permission['type'] == 'domain':
      permstr += f"/{permission['domain']}"
    return [Ent.USER, user, entityType, title, Ent.PERMISSION, permstr]

  def isPermissionDeletable(kvList, permission):
    domain = ''
    if copyMoveOptions['excludePermissionsFromDomains'] or copyMoveOptions['includePermissionsFromDomains']:
      if permission['type'] in {'group', 'user'}:
        atLoc = permission.get('emailAddress', '').find('@')
        if atLoc > 0:
          domain = permission['emailAddress'][atLoc+1:]
      elif permission['type'] == 'domain':
        domain = permission.get('domain', '')
    if domain and domain in copyMoveOptions['excludePermissionsFromDomains']:
      notMovedMessage = f'domain {domain} excluded'
    elif domain and copyMoveOptions['includePermissionsFromDomains'] and domain not in copyMoveOptions['includePermissionsFromDomains']:
      notMovedMessage = f'domain {domain} not included'
    elif permission.pop('deleted', False):
      notMovedMessage = f"{permission['type']} {permission['emailAddress']} deleted"
    else:
      return False
    deleteSourcePerms[permission['id']] = permission.copy()
    if copyMoveOptions['showPermissionMessages']:
      entityActionNotPerformedWarning(kvList, notMovedMessage, 0, 0)
    return True

  def mapPermissionsDomains(kvList, permission):
    if 'emailAddress' in permission:
      email, domain = permission['emailAddress'].lower().split('@', 1)
      if domain in copyMoveOptions['mapPermissionsDomains']:
        deleteSourcePerms[permission['id']] = permission.copy()
        if copyMoveOptions['showPermissionMessages']:
          notMovedMessage = f"domain {domain} mapped to {copyMoveOptions['mapPermissionsDomains'][domain]}"
          entityActionNotPerformedWarning(kvList, notMovedMessage, 0, 0)
        permission['emailAddress'] = f"{email}@{copyMoveOptions['mapPermissionsDomains'][domain]}"
        addSourcePerms[permission['id']] = permission
        return True
    elif 'domain' in permission:
      domain = permission['domain'].lower()
      if domain in copyMoveOptions['mapPermissionsDomains']:
        deleteSourcePerms[permission['id']] = permission.copy()
        if copyMoveOptions['showPermissionMessages']:
          notMovedMessage = f"domain {domain} mapped to {copyMoveOptions['mapPermissionsDomains'][domain]}"
          entityActionNotPerformedWarning(kvList, notMovedMessage, 0, 0)
        permission['domain'] = copyMoveOptions['mapPermissionsDomains'][domain]
        addSourcePerms[permission['id']] = permission
        return True
    return False

  sourcePerms = getPermissions(fileId)
  if sourcePerms is None:
    return
  Ind.Increment()
  deleteSourcePerms = {}
  addSourcePerms = {}
  for permissionId, permission in iter(sourcePerms.items()):
    kvList = permissionKVList(user, entityType, fileTitle, permission)
    if isPermissionDeletable(kvList, permission):
      pass
    elif copyMoveOptions['mapPermissionsDomains']:
      mapPermissionsDomains(kvList, permission)
  action = Act.Get()
  kcount = len(deleteSourcePerms)
  if kcount > 0:
    Act.Set(Act.DELETE)
    k = 0
    for permissionId, permission in iter(deleteSourcePerms.items()):
      k += 1
      kvList = permissionKVList(user, entityType, fileTitle, permission)
      try:
        callGAPI(drive.permissions(), 'delete',
                 throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+GAPI.DRIVE3_DELETE_ACL_THROW_REASONS+[GAPI.FILE_NEVER_WRITABLE],
                 useDomainAdminAccess=copyMoveOptions['useDomainAdminAccess'],
                 enforceExpansiveAccess=copyMoveOptions['enforceExpansiveAccess'],
                 fileId=fileId, permissionId=permissionId,  supportsAllDrives=True)
        if copyMoveOptions['showPermissionMessages']:
          entityActionPerformed(kvList, k, kcount)
      except (GAPI.notFound, GAPI.permissionNotFound,
              GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
              GAPI.fileNeverWritable, GAPI.badRequest, GAPI.cannotRemoveOwner, GAPI.cannotModifyInheritedTeamDrivePermission,
              GAPI.insufficientAdministratorPrivileges, GAPI.sharingRateLimitExceeded, GAPI.cannotDeletePermission) as e:
        entityActionFailedWarning(kvList, str(e), k, kcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        _incrStatistic(statistics, stat)
        Ind.Decrement()
        Act.Set(action)
        return
  kcount = len(addSourcePerms)
  if kcount > 0:
    Act.Set(Act.CREATE)
    k = 0
    for permissionId, permission in iter(addSourcePerms.items()):
      k += 1
      kvList = permissionKVList(user, entityType, fileTitle, permission)
      permission.pop('id')
      if copyMoveOptions['destDriveId']:
        permission.pop('pendingOwner', None)
      sendNotificationEmail = False
      while True:
        try:
          callGAPI(drive.permissions(), 'create',
                   throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+GAPI.DRIVE3_CREATE_ACL_THROW_REASONS,
#                   retryReasons=[GAPI.INVALID_SHARING_REQUEST],
                   fileId=fileId, sendNotificationEmail=sendNotificationEmail, emailMessage=None,
                   body=permission, fields='', useDomainAdminAccess=copyMoveOptions['useDomainAdminAccess'], supportsAllDrives=True)
          if copyMoveOptions['showPermissionMessages']:
            entityActionPerformed(kvList, k, kcount)
          break
        except (GAPI.badRequest, GAPI.invalid, GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError,
                GAPI.insufficientFilePermissions, GAPI.unknownError, GAPI.ownershipChangeAcrossDomainNotPermitted,
                GAPI.teamDriveDomainUsersOnlyRestriction, GAPI.teamDriveTeamMembersOnlyRestriction,
                GAPI.targetUserRoleLimitedByLicenseRestriction, GAPI.insufficientAdministratorPrivileges, GAPI.sharingRateLimitExceeded,
                GAPI.publishOutNotPermitted, GAPI.shareInNotPermitted, GAPI.shareOutNotPermitted, GAPI.shareOutNotPermittedToUser,
                GAPI.cannotShareTeamDriveTopFolderWithAnyoneOrDomains, GAPI.cannotShareTeamDriveWithNonGoogleAccounts,
                GAPI.ownerOnTeamDriveItemNotSupported,
                GAPI.organizerOnNonTeamDriveNotSupported, GAPI.organizerOnNonTeamDriveItemNotSupported,
                GAPI.fileOrganizerNotYetEnabledForThisTeamDrive,
                GAPI.fileOrganizerOnFoldersInSharedDriveOnly,
                GAPI.fileOrganizerOnNonTeamDriveNotSupported,
                GAPI.teamDrivesFolderSharingNotSupported, GAPI.invalidLinkVisibility, GAPI.abusiveContentRestriction) as e:
          entityActionFailedWarning(kvList, str(e), k, kcount)
          break
        except GAPI.invalidSharingRequest as e:
          if not copyMoveOptions['sendEmailIfRequired'] or sendNotificationEmail or 'You are trying to invite' not in str(e):
            entityActionFailedWarning(kvList, str(e), k, kcount)
            break
          sendNotificationEmail = True
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          userDriveServiceNotEnabledWarning(user, str(e), i, count)
          _incrStatistic(statistics, stat)
          Ind.Decrement()
          Act.Set(action)
          return
    Ind.Decrement()
  Act.Set(action)

# gam <UserTypeEntity> move drivefile <DriveFileEntity> [newfilename <DriveFileName>]
#	[summary [<Boolean>]] [showpermissionsmessages [<Boolean>]]
#	[<DriveFileParentAttribute>]
#       [mergewithparent|mergewithparentretain [<Boolean>]]
#	[createshortcutsfornonmovablefiles [<Boolean>]]
#	[duplicatefiles overwriteolder|overwriteall|duplicatename|uniquename|skip]
#	[duplicatefolders merge|duplicatename|uniquename|skip]
#	[copymergewithparentfolderpermissions [<Boolean>]]
#	[copymergedtopfolderpermissions [<Boolean>]]
#	[copytopfolderpermissions [<Boolean>]]
#	[copytopfolderiheritedpermissions [<Boolean>]]
#	[copytopfoldernoniheritedpermissions never|always|syncallfolders|syncupdatedfolders]
#	[copymergedsubfolderpermissions [<Boolean>]]
#	[copysubfolderpermissions [<Boolean>]]
#	[copysubfolderinheritedpermissions [<Boolean>]]
#	[copysubfoldernoniheritedpermissions never|always|syncallfolders|syncupdatedfolders]
#	[copypermissionroles <DriveFileACLRoleList>]
#	[copypermissiontypes <DriveFileACLTypeList>]
#	[synctopfoldernoniheritedpermissions [<Boolean>]] [syncsubfoldernoninheritedpermissions [<Boolean>]]
#	[excludepermissionsfromdomains|includepermissionsfromdomains <DomainNameList>]
#	(mappermissionsdomain <DomainName> <DomainName>)*
#	[updatefilepermissions [<Boolean>]]
#	[retainsourcefolders [<Boolean>]]
#	[sendemailifrequired [<Boolean>]]
#	[verifyorganizer [<Boolean>]]
#	[enforceexpansiveaccess [<Boolean>]]
def moveDriveFile(users):
  def _cloneFolderMove(drive, user, i, count, j, jcount,
                       source, targetChildren, newFolderName, newParentId, newParentName, mergeParentModifiedTime,
                       statistics, copyMoveOptions, atTop):
    folderId = source.pop('id')
    folderName = source['name']
    sourceMimeType = source['mimeType']
    folderNameId = f'{folderName}({folderId})'
    kvList = [Ent.USER, user, Ent.DRIVE_FOLDER, folderNameId]
    newParentNameId = f'{newParentName}({newParentId})'
# Merge top parent folder
    if atTop and (copyMoveOptions['mergeWithParent'] or copyMoveOptions['mergeWithParentRetain']):
      action = Act.Get()
      Act.Set(Act.MOVE_MERGE)
      entityPerformActionModifierItemValueList(kvList, Act.MODIFIER_CONTENTS_WITH, [Ent.DRIVE_FOLDER, newParentNameId], j, jcount)
      Act.Set(action)
      _incrStatistic(statistics, STAT_FOLDER_MERGED)
      if (copyMoveOptions['copyMergeWithParentFolderPermissions'] and
          copyMoveOptions['destParentType'] != DEST_PARENT_MYDRIVE_ROOT):
        copyFolderNonInheritedPermissions =\
            _getCopyFolderNonInheritedPermissions(copyMoveOptions,
                                                  'copyTopFolderNonInheritedPermissions',
                                                  source['modifiedTime'], mergeParentModifiedTime)
        _copyPermissions(drive, user, i, count, j, jcount,
                         Ent.DRIVE_FOLDER, folderId, folderName, newParentId, newFolderName,
                         statistics, STAT_FOLDER_PERMISSIONS_FAILED,
                         copyMoveOptions, True,
                         'copyTopFolderInheritedPermissions',
                         copyFolderNonInheritedPermissions,
                         False)
      source.pop('oldparents', None)
      return (newParentId, newParentName, True)
# Merge parent folders
    if atTop and copyMoveOptions['sourceIsMyDriveSharedDrive']:
      pass
    elif copyMoveOptions['duplicateFolders'] == DUPLICATE_FOLDER_MERGE:
      newFolderNameLower = newFolderName.lower()
      for target in targetChildren:
        if not target.get('processed', False) and newFolderNameLower == target['name'].lower() and sourceMimeType == target['mimeType']:
          target['processed'] = True
          if target['capabilities']['canAddChildren']:
            newFolderId = target['id']
            action = Act.Get()
            Act.Set(Act.MOVE_MERGE)
            entityModifierItemValueListActionPerformed(kvList, Act.MODIFIER_CONTENTS_WITH, [Ent.DRIVE_FOLDER, f'{newFolderName}({newFolderId})'], j, jcount)
            Act.Set(action)
            _incrStatistic(statistics, STAT_FOLDER_MERGED)
            if (copyMoveOptions[['copyMergedSubFolderPermissions', 'copyMergedTopFolderPermissions'][atTop]] and
                (not atTop or copyMoveOptions['destParentType'] != DEST_PARENT_MYDRIVE_ROOT)):
              copyFolderNonInheritedPermissions =\
                  _getCopyFolderNonInheritedPermissions(copyMoveOptions,
                                                        ['copySubFolderNonInheritedPermissions', 'copyTopFolderNonInheritedPermissions'][atTop],
                                                        source['modifiedTime'], target['modifiedTime'])
              _copyPermissions(drive, user, i, count, j, jcount,
                               Ent.DRIVE_FOLDER, folderId, folderName, newFolderId, newFolderName,
                               statistics, STAT_FOLDER_PERMISSIONS_FAILED,
                               copyMoveOptions, atTop,
                               ['copySubFolderInheritedPermissions', 'copyTopFolderInheritedPermissions'][atTop],
                               copyFolderNonInheritedPermissions,
                               False)
            return (newFolderId, newFolderName, True)
          entityActionFailedWarning(kvList+[Ent.DRIVE_FOLDER, newParentNameId], Msg.NOT_WRITABLE, j, jcount)
          _incrStatistic(statistics, STAT_FOLDER_NOT_WRITABLE)
          copyMoveOptions['retainSourceFolders'] = True
          return (None, None, False)
    elif copyMoveOptions['duplicateFolders'] == DUPLICATE_FOLDER_UNIQUE_NAME:
      newFolderName = _getUniqueFilename(newFolderName, sourceMimeType, targetChildren)
    elif copyMoveOptions['duplicateFolders'] == DUPLICATE_FOLDER_SKIP:
      targetChild = _targetFilenameExists(newFolderName, sourceMimeType, targetChildren)
      if targetChild is not None:
        entityModifierItemValueListActionNotPerformedWarning(kvList, Act.MODIFIER_TO,
                                                             [Ent.DRIVE_FOLDER, newParentNameId, Ent.DRIVE_FOLDER, f"{newFolderName}({targetChild['id']})"],
                                                             Msg.DUPLICATE, j, jcount)
        _incrStatistic(statistics, STAT_FOLDER_DUPLICATE)
        copyMoveOptions['retainSourceFolders'] = True
        return (None, None, False)
# Update parents on: not retain and MD->MD, SD->MD, SD->SD
    if atTop and copyMoveOptions['sourceIsMyDriveSharedDrive']:
      pass
    elif (not copyMoveOptions['retainSourceFolders'] and (copyMoveOptions['sourceDriveId'] or not copyMoveOptions['destDriveId'])):
      if newFolderName != folderName:
        body = {'name': newFolderName}
      else:
        body = {}
      removeParents = ','.join([parentId for parentId in source.pop('oldparents', []) if parentId not in source['parents']])
      try:
        callGAPI(drive.files(), 'update',
                 throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST,
                                                               GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                               GAPI.FILE_OWNER_NOT_MEMBER_OF_TEAMDRIVE,
                                                               GAPI.FILE_OWNER_NOT_MEMBER_OF_WRITER_DOMAIN,
                                                               GAPI.FILE_WRITER_TEAMDRIVE_MOVE_IN_DISABLED,
                                                               GAPI.TARGET_USER_ROLE_LIMITED_BY_LICENSE_RESTRICTION,
                                                               GAPI.CANNOT_MOVE_TRASHED_ITEM_INTO_TEAMDRIVE,
                                                               GAPI.CANNOT_MOVE_TRASHED_ITEM_OUT_OF_TEAMDRIVE,
                                                               GAPI.CROSS_DOMAIN_MOVE_RESTRICTION,
                                                               GAPI.STORAGE_QUOTA_EXCEEDED],
                 fileId=folderId,
                 addParents=newParentId, removeParents=removeParents,
                 body=body, fields=None, supportsAllDrives=True)
        entityModifierItemValueListActionPerformed(kvList, Act.MODIFIER_TO,
                                                   [Ent.DRIVE_FOLDER, newParentNameId, Ent.DRIVE_FOLDER, f'{newFolderName}({folderId})'],
                                                   j, jcount)
        _incrStatistic(statistics, STAT_FILE_COPIED_MOVED)
        return (None, None, False)
      except (GAPI.badRequest, GAPI.insufficientParentPermissions, GAPI.fileOwnerNotMemberOfTeamDrive, GAPI.fileOwnerNotMemberOfWriterDomain,
              GAPI.fileWriterTeamDriveMoveInDisabled, GAPI.targetUserRoleLimitedByLicenseRestriction,
              GAPI.cannotMoveTrashedItemIntoTeamDrive, GAPI.cannotMoveTrashedItemOutOfTeamDrive,
              GAPI.crossDomainMoveRestriction, GAPI.storageQuotaExceeded) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FOLDER, folderName], str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
      _incrStatistic(statistics, STAT_FILE_FAILED)
      copyMoveOptions['retainSourceFolders'] = True
      return (None, None, False)
# Create new  parent on: retain or MD->SD
    source.pop('oldparents', None)
    body = source.copy()
    body.pop('capabilities', None)
    if copyMoveOptions['sourceDriveId'] or copyMoveOptions['destDriveId']:
      body.pop('copyRequiresWriterPermission', None)
      body.pop('writersCanShare', None)
    body.pop('trashed', None)
    if not copyMoveOptions['destDriveId']:
      body.pop('driveId', None)
    body['name'] = newFolderName
    try:
      result = callGAPI(drive.files(), 'create',
                        throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                    GAPI.INTERNAL_ERROR, GAPI.STORAGE_QUOTA_EXCEEDED,
                                                                    GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP,
                                                                    GAPI.BAD_REQUEST, GAPI.TARGET_USER_ROLE_LIMITED_BY_LICENSE_RESTRICTION],
                        body=body, fields='id', supportsAllDrives=True)
      newFolderId = result['id']
      action = Act.Get()
      Act.Set(Act.RECREATE)
      entityModifierItemValueListActionPerformed(kvList, Act.MODIFIER_IN,
                                                 [Ent.DRIVE_FOLDER, newParentNameId, Ent.DRIVE_FOLDER, f'{newFolderName}({newFolderId})'],
                                                 j, jcount)
      Act.Set(action)
      _incrStatistic(statistics, STAT_FOLDER_COPIED_MOVED)
      if copyMoveOptions[['copySubFolderPermissions', 'copyTopFolderPermissions'][atTop]]:
        _copyPermissions(drive, user, i, count, j, jcount,
                         Ent.DRIVE_FOLDER, folderId, folderName, newFolderId, newFolderName,
                         statistics, STAT_FOLDER_PERMISSIONS_FAILED,
                         copyMoveOptions, False,
                         ['copySubFolderInheritedPermissions', 'copyTopFolderInheritedPermissions'][atTop],
                         ['copySubFolderNonInheritedPermissions', 'copyTopFolderNonInheritedPermissions'][atTop],
                         True)
      return (newFolderId, newFolderName, False)
    except (GAPI.forbidden, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions,
            GAPI.internalError, GAPI.storageQuotaExceeded, GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep,
            GAPI.badRequest, GAPI.targetUserRoleLimitedByLicenseRestriction) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FOLDER, newFolderName], str(e), j, jcount)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
    _incrStatistic(statistics, STAT_FOLDER_FAILED)
    copyMoveOptions['retainSourceFolders'] = True
    return (None, None, False)

  def _makeMoveShortcut(drive, user, k, kcount, entityType, childId, childName, newParentId, newParentName):
    kvList = [Ent.USER, user, entityType, f'{childName}({childId})']
    if entityType == Ent.DRIVE_FILE:
      targetEntityType = Ent.DRIVE_FILE_SHORTCUT
      statShortcutCreated = STAT_FILE_SHORTCUT_CREATED
      statShortcutExists = STAT_FILE_SHORTCUT_EXISTS
    else:
      targetEntityType = Ent.DRIVE_FOLDER_SHORTCUT
      statShortcutCreated = STAT_FOLDER_SHORTCUT_CREATED
      statShortcutExists = STAT_FOLDER_SHORTCUT_EXISTS
    newParentNameId = f'{newParentName}({newParentId})'
    action = Act.Get()
    existingShortcut = _checkForExistingShortcut(drive, childId, childName, newParentId)
    if existingShortcut:
      Act.Set(Act.CREATE_SHORTCUT)
      entityModifierItemValueListActionPerformed(kvList, Act.MODIFIER_PREVIOUSLY_IN,
                                                 [Ent.DRIVE_FOLDER, newParentNameId, targetEntityType, f"{childName}({existingShortcut})"],
                                                 k, kcount)
      Act.Set(action)
      _incrStatistic(statistics, statShortcutExists)
      return
    body = {'name': childName, 'mimeType': MIMETYPE_GA_SHORTCUT,
            'parents': [newParentId], 'shortcutDetails': {'targetId': childId}}
    try:
      result = callGAPI(drive.files(), 'create',
                        throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                    GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND, GAPI.UNKNOWN_ERROR,
                                                                    GAPI.STORAGE_QUOTA_EXCEEDED, GAPI.TEAMDRIVES_SHARING_RESTRICTION_NOT_ALLOWED,
                                                                    GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP, GAPI.SHORTCUT_TARGET_INVALID,
                                                                    GAPI.TARGET_USER_ROLE_LIMITED_BY_LICENSE_RESTRICTION, GAPI.SHARE_OUT_WARNING],
                        body=body, fields='id', supportsAllDrives=True)
      Act.Set(Act.CREATE_SHORTCUT)
      entityModifierItemValueListActionPerformed(kvList, Act.MODIFIER_IN,
                                                 [Ent.DRIVE_FOLDER, newParentNameId, targetEntityType, f"{childName}({result['id']})"],
                                                 k, kcount)
      Act.Set(action)
      _incrStatistic(statistics, statShortcutCreated)
    except (GAPI.forbidden, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions,
            GAPI.invalid, GAPI.badRequest, GAPI.fileNotFound, GAPI.unknownError,
            GAPI.storageQuotaExceeded, GAPI.teamDrivesSharingRestrictionNotAllowed,
            GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep, GAPI.shortcutTargetInvalid,
            GAPI.targetUserRoleLimitedByLicenseRestriction, GAPI.shareOutWarning) as e:
      entityActionFailedWarning(kvList+[Ent.DRIVE_FILE_SHORTCUT, childName], str(e), k, kcount)
      _incrStatistic(statistics, STAT_FILE_FAILED)

  def _moveFile(drive, user, i, count, k, kcount, entityType, childId, childName, newChildName, newParentId, newParentName, removeParents, body):
    kvList = [Ent.USER, user, entityType, f'{childName}({childId})']
    newParentNameId = f'{newParentName}({newParentId})'
    if updateFilePermissions:
      _updateMoveFilePermissions(drive, user, i, count,
                                 entityType, childId, childName,
                                 statistics, STAT_FILE_PERMISSIONS_FAILED, copyMoveOptions)
    try:
      callGAPI(drive.files(), 'update',
               throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST,
                                                             GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                             GAPI.FILE_OWNER_NOT_MEMBER_OF_TEAMDRIVE,
                                                             GAPI.FILE_OWNER_NOT_MEMBER_OF_WRITER_DOMAIN,
                                                             GAPI.FILE_WRITER_TEAMDRIVE_MOVE_IN_DISABLED,
                                                             GAPI.SHARE_OUT_NOT_PERMITTED,
                                                             GAPI.TARGET_USER_ROLE_LIMITED_BY_LICENSE_RESTRICTION,
                                                             GAPI.CANNOT_MOVE_TRASHED_ITEM_INTO_TEAMDRIVE,
                                                             GAPI.CANNOT_MOVE_TRASHED_ITEM_OUT_OF_TEAMDRIVE,
                                                             GAPI.TEAMDRIVES_SHORTCUT_FILE_NOT_SUPPORTED,
                                                             GAPI.CROSS_DOMAIN_MOVE_RESTRICTION,
                                                             GAPI.STORAGE_QUOTA_EXCEEDED],
               fileId=childId, addParents=newParentId, removeParents=removeParents,
               body=body, fields='', supportsAllDrives=True)
      entityModifierItemValueListActionPerformed(kvList, Act.MODIFIER_TO,
                                                 [Ent.DRIVE_FOLDER, newParentNameId, entityType, f'{newChildName}({childId})'],
                                                 k, kcount)
      _incrStatistic(statistics, STAT_FILE_COPIED_MOVED)
      return
    except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.unknownError, GAPI.badRequest,
            GAPI.shareOutNotPermitted, GAPI.targetUserRoleLimitedByLicenseRestriction,
            GAPI.cannotMoveTrashedItemIntoTeamDrive, GAPI.cannotMoveTrashedItemOutOfTeamDrive,
            GAPI.teamDrivesShortcutFileNotSupported, GAPI.storageQuotaExceeded) as e:
      entityActionFailedWarning(kvList, str(e), k, kcount)
      _incrStatistic(statistics, STAT_FILE_FAILED)
    except (GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions,
            GAPI.fileOwnerNotMemberOfTeamDrive, GAPI.fileOwnerNotMemberOfWriterDomain,
            GAPI.fileWriterTeamDriveMoveInDisabled,
            GAPI.crossDomainMoveRestriction) as e:
      if not copyMoveOptions['createShortcutsForNonmovableFiles']:
        entityActionFailedWarning(kvList, str(e), k, kcount)
        _incrStatistic(statistics, STAT_FILE_FAILED)
      else:
        _makeMoveShortcut(drive, user, k, kcount, entityType, childId, childName, newParentId, newParentName)
    copyMoveOptions['retainSourceFolders'] = True

  def _recursiveFolderMove(drive, user, i, count, j, jcount,
                           source, targetChildren, newFolderName, newParentId, newParentName, mergeParentModifiedTime, atTop):
    folderId = source['id']
    newFolderId, newFolderName, existingTargetFolder = _cloneFolderMove(drive, user, i, count, j, jcount,
                                                                        source, targetChildren, newFolderName,
                                                                        newParentId, newParentName, mergeParentModifiedTime,
                                                                        statistics, copyMoveOptions, atTop)
    if newFolderId is None:
      return
    movedFiles[newFolderId] = 1
    sourceChildren = callGAPIpages(drive.files(), 'list', 'files',
                                   throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                                   retryReasons=[GAPI.UNKNOWN_ERROR],
                                   q=WITH_PARENTS.format(folderId),
                                   orderBy='folder desc,name,modifiedTime desc',
                                   fields='nextPageToken,files(id,name,parents,appProperties,capabilities,contentHints,copyRequiresWriterPermission,'\
                                     'description,folderColorRgb,mimeType,modifiedTime,properties,starred,driveId,trashed,viewedByMeTime,writersCanShare)',
                                   pageSize=GC.Values[GC.DRIVE_MAX_RESULTS], **sourceSearchArgs)
    kcount = len(sourceChildren)
    if kcount > 0:
      if existingTargetFolder:
        subTargetChildren = callGAPIpages(drive.files(), 'list', 'files',
                                          throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                                          retryReasons=[GAPI.UNKNOWN_ERROR],
                                          q=ANY_NON_TRASHED_WITH_PARENTS.format(newFolderId),
                                          orderBy='folder desc,name,modifiedTime desc',
                                          fields='nextPageToken,files(id,name,capabilities,mimeType,modifiedTime)',
                                          **parentParms[DFA_SEARCHARGS])
      else:
        subTargetChildren = []
      Ind.Increment()
      k = 0
      for child in sourceChildren:
        k += 1
        childId = child['id']
        childName = child['name']
        childNameId = f'{childName}({childId})'
        if movedFiles.get(childId):
          continue
        trashed = child.pop('trashed', False)
        if (childId == newFolderId) or (copyMoveOptions['destDriveId'] and trashed):
          entityActionNotPerformedWarning([Ent.USER, user, _getEntityMimeType(child), childNameId],
                                          [Msg.NOT_MOVABLE, Msg.NOT_MOVABLE_IN_TRASH][trashed], k, kcount)
          _incrStatistic(statistics, STAT_FILE_NOT_COPYABLE_MOVABLE)
          continue
        childParents = child.pop('parents', [])
        child['parents'] = [newFolderId]
        if child['mimeType'] == MIMETYPE_GA_FOLDER:
          child['oldparents'] = childParents
          _recursiveFolderMove(drive, user, i, count, k, kcount,
                               child, subTargetChildren, childName, newFolderId, newFolderName, child['modifiedTime'], False)
        else:
          if existingTargetFolder and _checkForDuplicateTargetFile(drive, user, k, kcount, child, childName, subTargetChildren, copyMoveOptions, statistics):
            copyMoveOptions['retainSourceFolders'] = True
            continue
          if childName != child['name']:
            body = {'name': child['name']}
          else:
            body = {}
          removeParents = ','.join(childParents)
          _moveFile(drive, user, i, count, k, kcount, Ent.DRIVE_FILE, childId, childName, childName, newFolderId, newFolderName, removeParents, body)
      Ind.Decrement()
    sourceName = source['name']
    kvList = [Ent.USER, user, Ent.DRIVE_FOLDER, f"{sourceName}({folderId})"]
    if (atTop and (copyMoveOptions['mergeWithParentRetain'] or copyMoveOptions['sourceIsMyDriveSharedDrive'])) or copyMoveOptions['retainSourceFolders']:
      Act.Set(Act.RETAIN)
      entityActionPerformed(kvList, i, count)
    else:
      Act.Set(Act.DELETE)
      try:
        callGAPI(drive.files(), 'delete',
                 throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.FILE_NEVER_WRITABLE],
                 fileId=folderId, supportsAllDrives=True)
        entityActionPerformed(kvList, i, count)
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
              GAPI.fileNeverWritable) as e:
        entityActionFailedWarning(kvList, str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
    Act.Set(Act.MOVE)
    return

  def anyFolderPermissionOperations():
    for field in ['copyMergeWithParentFolderPermissions',
                  'copyMergedTopFolderPermissions', 'copyMergedSubFolderPermissions',
                  'copyTopFolderPermissions', 'copySubFolderPermissions']:
      if copyMoveOptions[field]:
        return True
    return False

  fileIdEntity = getDriveFileEntity()
  parentBody = {}
  parentParms = initDriveFileAttributes()
  copyMoveOptions = initCopyMoveOptions(False)
  newParentsSpecified = updateFilePermissions = False
  movedFiles = {}
  verifyOrganizer = True
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if getCopyMoveOptions(myarg, copyMoveOptions):
      pass
    elif getDriveFileParentAttribute(myarg, parentParms):
      newParentsSpecified = True
    elif myarg == 'updatefilepermissions':
      updateFilePermissions = getBoolean()
    elif myarg == 'verifyorganizer':
      verifyOrganizer = getBoolean()
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity, entityType=Ent.DRIVE_FILE_OR_FOLDER)
    if jcount == 0:
      continue
    if not _getDriveFileParentInfo(drive, user, i, count, parentBody, parentParms):
      continue
    statistics = _initStatistics()
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      try:
        source = callGAPI(drive.files(), 'get',
                          throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                          fileId=fileId,
                          fields='id,name,parents,appProperties,capabilities,contentHints,copyRequiresWriterPermission,'\
                            'description,mimeType,modifiedTime,properties,starred,driveId,trashed,viewedByMeTime,writersCanShare',
                          supportsAllDrives=True)
# Source at root of My Drive or Shared Drive?
        sourceMimeType = source['mimeType']
        if sourceMimeType == MIMETYPE_GA_FOLDER and source['name'] in [MY_DRIVE, TEAM_DRIVE] and not source.get('parents', []):
          copyMoveOptions['sourceIsMyDriveSharedDrive'] = True
          if source.get('driveId'):
            source['name'] = _getSharedDriveNameFromId(drive, source['driveId'])
        sourceName = source['name']
        sourceNameId = f"{sourceName}({source['id']})"
        copyMoveOptions['sourceDriveId'] = source.get('driveId')
        kvList = [Ent.USER, user, _getEntityMimeType(source), sourceNameId]
        if fileId in movedFiles:
          entityActionNotPerformedWarning(kvList, Msg.DUPLICATE, j, jcount)
          _incrStatistic(statistics, STAT_FILE_DUPLICATE)
          continue
        if copyMoveOptions['sourceDriveId']:
# If moving from a Shared Drive, user has to be an organizer
          if verifyOrganizer and not _verifyUserIsOrganizer(drive, user, i, count, copyMoveOptions['sourceDriveId']):
            _incrStatistic(statistics, STAT_USER_NOT_ORGANIZER)
            continue
          if source['trashed']:
            entityActionNotPerformedWarning(kvList, Msg.NOT_MOVABLE_IN_TRASH, j, jcount)
            _incrStatistic(statistics, STAT_FILE_NOT_COPYABLE_MOVABLE)
            continue
          sourceSearchArgs = {'driveId': copyMoveOptions['sourceDriveId'], 'corpora': 'drive', 'includeItemsFromAllDrives': True, 'supportsAllDrives': True}
        else:
          sourceSearchArgs = {}
        sourceParents = source.pop('parents', [])
        if newParentsSpecified:
          newParents = parentBody['parents']
          numNewParents = len(newParents)
          if numNewParents > 1:
            entityActionNotPerformedWarning(kvList, Msg.MULTIPLE_PARENTS_SPECIFIED.format(numNewParents), j, jcount)
            _incrStatistic(statistics, STAT_FILE_FAILED)
            continue
        else:
          newParents = sourceParents if sourceParents else [ROOT]
        source['parents'] = newParents
        newParentId = newParents[0]
        dest = _getCopyMoveParentInfo(drive, user, i, count, j, jcount, newParentId, statistics)
        if dest is None:
          continue
        newParentName = dest['name']
        newParentNameId = f'{newParentName}({newParentId})'
        copyMoveOptions['destDriveId'] = dest['driveId']
        copyMoveOptions['destParentType'] = dest['destParentType']
        if copyMoveOptions['destDriveId'] and not parentParms[DFA_SEARCHARGS]:
          parentParms[DFA_SEARCHARGS] = {'driveId': copyMoveOptions['destDriveId'], 'corpora': 'drive',
                                         'includeItemsFromAllDrives': True, 'supportsAllDrives': True}
        if copyMoveOptions['newFilename']:
          destName = copyMoveOptions['newFilename']
        elif (sourceMimeType == MIMETYPE_GA_FOLDER) and (copyMoveOptions['mergeWithParent'] or copyMoveOptions['mergeWithParentRetain']):
          destName = dest['name']
        else:
          destName = sourceName
        targetChildren = _getCopyMoveTargetInfo(drive, user, i, count, j, jcount, source, destName, newParentId, statistics, parentParms)
        if targetChildren is None:
          continue
        if copyMoveOptions['destDriveId']:
# If moving to a Shared Drive, user has to be an organizer
          if verifyOrganizer and not _verifyUserIsOrganizer(drive, user, i, count, copyMoveOptions['destDriveId']):
            _incrStatistic(statistics, STAT_USER_NOT_ORGANIZER)
            continue
# 3rd party shortcuts can't be moved to Shared Drives
          if sourceMimeType.startswith(MIMETYPE_GA_3P_SHORTCUT):
            entityActionNotPerformedWarning([Ent.USER, user, Ent.DRIVE_3PSHORTCUT, sourceNameId], Msg.NOT_MOVABLE, j, jcount)
            _incrStatistic(statistics, STAT_FILE_NOT_COPYABLE_MOVABLE)
            continue
# Move folder
        if sourceMimeType == MIMETYPE_GA_FOLDER:
          if fileId == newParentId:
            entityActionNotPerformedWarning(kvList, Msg.NOT_MOVABLE_INTO_ITSELF, j, jcount)
            _incrStatistic(statistics, STAT_FOLDER_FAILED)
            continue
          if copyMoveOptions['duplicateFolders'] == DUPLICATE_FOLDER_MERGE:
            if _identicalSourceTarget(fileId, targetChildren):
              entityActionNotPerformedWarning(kvList, Msg.NOT_MOVABLE_SAME_NAME_CURRENT_FOLDER_MERGE, j, jcount)
              _incrStatistic(statistics, STAT_FOLDER_FAILED)
              continue
          elif copyMoveOptions['duplicateFolders'] == DUPLICATE_FOLDER_UNIQUE_NAME:
            destName = _getUniqueFilename(destName, sourceMimeType, targetChildren)
          elif copyMoveOptions['duplicateFolders'] == DUPLICATE_FOLDER_SKIP:
            targetChild = _targetFilenameExists(destName, sourceMimeType, targetChildren)
            if targetChild is not None:
              entityModifierItemValueListActionNotPerformedWarning(kvList, Act.MODIFIER_TO,
                                                                   [Ent.DRIVE_FOLDER, newParentNameId, Ent.DRIVE_FOLDER, f"{destName}({targetChild['id']})"],
                                                                   Msg.DUPLICATE, j, jcount)
              _incrStatistic(statistics, STAT_FOLDER_DUPLICATE)
              continue
          if ((not copyMoveOptions['sourceDriveId'] and copyMoveOptions['destDriveId']) or
              (copyMoveOptions['mergeWithParent'] or copyMoveOptions['mergeWithParentRetain'] or copyMoveOptions['retainSourceFolders']) or
              (copyMoveOptions['duplicateFolders'] == DUPLICATE_FOLDER_MERGE and _targetFilenameExists(destName, sourceMimeType, targetChildren) is not None) or
              anyFolderPermissionOperations()):
            source['oldparents'] = sourceParents
            _recursiveFolderMove(drive, user, i, count, j, jcount,
                                 source, targetChildren, destName, newParentId, newParentName, dest['modifiedTime'], True)
            continue
          entityType = Ent.DRIVE_FOLDER
# Move file
        else:
          if copyMoveOptions['duplicateFiles'] in [DUPLICATE_FILE_OVERWRITE_ALL, DUPLICATE_FILE_OVERWRITE_OLDER] and _identicalSourceTarget(fileId, targetChildren):
            entityActionNotPerformedWarning(kvList, Msg.NOT_MOVABLE_SAME_NAME_CURRENT_FOLDER_OVERWRITE, j, jcount)
            _incrStatistic(statistics, STAT_FILE_FAILED)
            continue
          if _checkForDuplicateTargetFile(drive, user, j, jcount, source, destName, targetChildren, copyMoveOptions, statistics):
            continue
          destName = source['name'] # duplicatefiles uniquename may cause rename
          entityType = Ent.DRIVE_FILE
# Simple move file/folder
        if destName != sourceName:
          body = {'name': destName}
        else:
          body = {}
# All parents removed from top level moved item as non-path parents can't be determined
        removeParents = ','.join(sourceParents)
        _moveFile(drive, user, i, count, j, jcount, entityType, fileId, sourceName, destName, newParentId, newParentName, removeParents, body)
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
              GAPI.invalid, GAPI.badRequest, GAPI.cannotCopyFile,
              GAPI.fileOwnerNotMemberOfTeamDrive, GAPI.fileOwnerNotMemberOfWriterDomain,
              GAPI.fileWriterTeamDriveMoveInDisabled,
              GAPI.cannotMoveTrashedItemIntoTeamDrive, GAPI.cannotMoveTrashedItemOutOfTeamDrive,
              GAPI.teamDrivesShortcutFileNotSupported, GAPI.crossDomainMoveRestriction) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER_ID, fileId], str(e), j, jcount)
        _incrStatistic(statistics, STAT_FILE_FAILED)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        _incrStatistic(statistics, STAT_FILE_FAILED)
        break
    Ind.Decrement()
    if copyMoveOptions['summary']:
      _printStatistics(user, statistics, i, count, False)

DELETE_DRIVEFILE_CHOICE_MAP = {'purge': 'delete', 'trash': 'trash', 'untrash': 'untrash'}
DELETE_DRIVEFILE_FUNCTION_TO_ACTION_MAP = {'delete': Act.PURGE, 'trash': Act.TRASH, 'untrash': Act.UNTRASH}
DELETE_DRIVEFILE_FUNCTION_TO_CAPABILITY_MAP = {'delete': 'canDelete', 'trash': 'canTrash', 'untrash': 'canUntrash'}

# gam <UserTypeEntity> delete drivefile <DriveFileEntity> [purge|trash|untrash] [shortcutandtarget [<Boolean>]]
def deleteDriveFile(users, function=None):
  fileIdEntity = getDriveFileEntity()
  if not function:
    function = getChoice(DELETE_DRIVEFILE_CHOICE_MAP, defaultChoice='trash', mapChoice=True)
  if checkArgumentPresent('shortcutandtarget'):
    shortcutAndTarget = getBoolean()
  else:
    shortcutAndTarget = False
  checkForExtraneousArguments()
  Act.Set(DELETE_DRIVEFILE_FUNCTION_TO_ACTION_MAP[function])
  if function != 'delete':
    trash_body = {'trashed': function == 'trash'}
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity, entityType=Ent.DRIVE_FILE_OR_FOLDER)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      try:
        fileInfoList = []
        if shortcutAndTarget:
          capability = DELETE_DRIVEFILE_FUNCTION_TO_CAPABILITY_MAP[function]
          fileInfo = (fileId, Ent.DRIVE_FILE_OR_FOLDER, Ent.DRIVE_FILE_OR_FOLDER_ID)
          result = callGAPI(drive.files(), 'get',
                            throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                            fileId=fileId, fields=f'name,mimeType,shortcutDetails,capabilities({capability})', supportsAllDrives=True)
          if result['mimeType'] == MIMETYPE_GA_SHORTCUT:
            if not result['capabilities'][capability]:
              entityActionNotPerformedWarning([Ent.USER, user, Ent.DRIVE_SHORTCUT, result['name']],
                                              Msg.SHORTCUT_TARGET_CAPABILITY_IS_FALSE.format('Shortcut', capability), j, jcount)
              continue
            fileInfoList.append((fileId, Ent.DRIVE_SHORTCUT, Ent.DRIVE_SHORTCUT_ID))
            fileId = result['shortcutDetails']['targetId']
            fileInfo = (fileId, Ent.DRIVE_FILE_OR_FOLDER, Ent.DRIVE_FILE_OR_FOLDER_ID)
            tresult = callGAPI(drive.files(), 'get',
                               throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                               fileId=fileId, fields=f'name,capabilities({capability})', supportsAllDrives=True)
            if not tresult['capabilities'][capability]:
              entityActionNotPerformedWarning([Ent.USER, user, Ent.DRIVE_SHORTCUT, result['name'], Ent.DRIVE_FILE_OR_FOLDER, tresult['name']],
                                              Msg.SHORTCUT_TARGET_CAPABILITY_IS_FALSE.format('Target', capability), j, jcount)
              continue
        fileInfoList.append((fileId, Ent.DRIVE_FILE_OR_FOLDER, Ent.DRIVE_FILE_OR_FOLDER_ID))
        for fileInfo in fileInfoList:
          fileId = fileInfo[0]
          if function != 'delete':
            result = callGAPI(drive.files(), 'update',
                              throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.FILE_NEVER_WRITABLE],
                              fileId=fileId, body=trash_body, fields='name', supportsAllDrives=True)
            if result and 'name' in result:
              fileName = result['name']
            else:
              fileName = fileId
          else:
            callGAPI(drive.files(), function,
                     throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.FILE_NEVER_WRITABLE],
                     fileId=fileId, supportsAllDrives=True)
            fileName = fileId
          entityActionPerformed([Ent.USER, user, fileInfo[1], fileName], j, jcount)
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError, GAPI.fileNeverWritable) as e:
        entityActionFailedWarning([Ent.USER, user, fileInfo[2], fileId], str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()

# gam <UserTypeEntity> purge drivefile <DriveFileEntity> [shortcutandtarget [<Boolean>]]
def purgeDriveFile(users):
  deleteDriveFile(users, 'delete')

# gam <UserTypeEntity> trash drivefile <DriveFileEntity> [shortcutandtarget [<Boolean>]]
def trashDriveFile(users):
  deleteDriveFile(users, 'trash')

# gam <UserTypeEntity> untrash drivefile <DriveFileEntity> [shortcutandtarget [<Boolean>]]
def untrashDriveFile(users):
  deleteDriveFile(users, 'untrash')

NON_DOWNLOADABLE_MIMETYPES = [MIMETYPE_GA_FORM, MIMETYPE_GA_FUSIONTABLE, MIMETYPE_GA_MAP, MIMETYPE_GA_FOLDER, MIMETYPE_GA_SHORTCUT]

GOOGLEDOC_VALID_EXTENSIONS_MAP = {
  MIMETYPE_GA_DRAWING: ['.jpeg', '.jpg', '.pdf', '.png', '.svg'],
  MIMETYPE_GA_DOCUMENT: ['.docx', '.epub', '.html', '.odt', '.pdf', '.rtf', '.txt', '.zip'],
  MIMETYPE_GA_JAM: ['.pdf'],
  MIMETYPE_GA_PRESENTATION: ['.pdf', '.pptx', '.odp', '.txt'],
  MIMETYPE_GA_SCRIPT: ['.json'],
  MIMETYPE_GA_SPREADSHEET: ['.csv', '.ods', '.pdf', '.tsv', '.xlsx', '.zip'],
  }

MICROSOFT_FORMATS_LIST = [
  {'mime': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'ext': '.docx'},
  {'mime': 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', 'ext': '.dotx'},
  {'mime': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'ext': '.pptx'},
  {'mime': 'application/vnd.openxmlformats-officedocument.presentationml.template', 'ext': '.potx'},
  {'mime': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'ext': '.xlsx'},
  {'mime': 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', 'ext': '.xltx'},
  {'mime': 'application/msword', 'ext': '.doc'},
  {'mime': 'application/msword', 'ext': '.dot'},
  {'mime': 'application/vnd.ms-powerpoint', 'ext': '.ppt'},
  {'mime': 'application/vnd.ms-powerpoint', 'ext': '.pot'},
  {'mime': 'application/vnd.ms-excel', 'ext': '.xls'},
  {'mime': 'application/vnd.ms-excel', 'ext': '.xlt'},
  ]

OPENOFFICE_FORMATS_LIST = [
  {'mime': 'application/vnd.oasis.opendocument.presentation', 'ext': '.odp'},
  {'mime': 'application/x-vnd.oasis.opendocument.spreadsheet', 'ext': '.ods'},
  {'mime': 'application/vnd.oasis.opendocument.spreadsheet', 'ext': '.ods'},
  {'mime': 'application/vnd.oasis.opendocument.text', 'ext': '.odt'},
  ]

DOCUMENT_FORMATS_MAP = {
  'csv': [{'mime': 'text/csv', 'ext': '.csv'}],
  'doc': [{'mime': 'application/msword', 'ext': '.doc'}],
  'dot': [{'mime': 'application/msword', 'ext': '.dot'}],
  'docx': [{'mime': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'ext': '.docx'}],
  'dotx': [{'mime': 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', 'ext': '.dotx'}],
  'epub': [{'mime': 'application/epub+zip', 'ext': '.epub'}],
  'html': [{'mime': 'text/html', 'ext': '.html'}],
  'jpeg': [{'mime': 'image/jpeg', 'ext': '.jpeg'}],
  'jpg': [{'mime': 'image/jpeg', 'ext': '.jpg'}],
  'json': [{'mime': MIMETYPE_GA_SCRIPT_JSON, 'ext': '.json'}],
  'mht': [{'mime': 'message/rfc822', 'ext': 'mht'}],
  'odp': [{'mime': 'application/vnd.oasis.opendocument.presentation', 'ext': '.odp'}],
  'ods': [{'mime': 'application/x-vnd.oasis.opendocument.spreadsheet', 'ext': '.ods'},
          {'mime': 'application/vnd.oasis.opendocument.spreadsheet', 'ext': '.ods'}],
  'odt': [{'mime': 'application/vnd.oasis.opendocument.text', 'ext': '.odt'}],
  'pdf': [{'mime': 'application/pdf', 'ext': '.pdf'}],
  'png': [{'mime': 'image/png', 'ext': '.png'}],
  'ppt': [{'mime': 'application/vnd.ms-powerpoint', 'ext': '.ppt'}],
  'pot': [{'mime': 'application/vnd.ms-powerpoint', 'ext': '.pot'}],
  'potx': [{'mime': 'application/vnd.openxmlformats-officedocument.presentationml.template', 'ext': '.potx'}],
  'pptx': [{'mime': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'ext': '.pptx'}],
  'rtf': [{'mime': 'application/rtf', 'ext': '.rtf'}],
  'svg': [{'mime': 'image/svg+xml', 'ext': '.svg'}],
  'tsv': [{'mime': 'text/tab-separated-values', 'ext': '.tsv'},
          {'mime': 'text/tsv', 'ext': '.tsv'}],
  'txt': [{'mime': 'text/plain', 'ext': '.txt'}],
  'xls': [{'mime': 'application/vnd.ms-excel', 'ext': '.xls'}],
  'xlt': [{'mime': 'application/vnd.ms-excel', 'ext': '.xlt'}],
  'xlsx': [{'mime': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'ext': '.xlsx'}],
  'xltx': [{'mime': 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', 'ext': '.xltx'}],
  'zip': [{'mime': 'application/zip', 'ext': '.zip'}],
  }

MIMETYPE_EXTENSION_MAP = {
  'application/epub+zip': '.epub',
  'application/msword': '.doc',
  'application/octet-stream': '',
  'application/pdf': '.pdf',
  'application/rtf': '.rtf',
  'application/vnd.ms-excel': '.xls',
  'application/vnd.ms-powerpoint': '.ppt',
  'application/vnd.oasis.opendocument.presentation': '.odp',
  'application/vnd.oasis.opendocument.spreadsheet': '.ods',
  'application/vnd.oasis.opendocument.text': '.odt',
  'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
  'application/vnd.openxmlformats-officedocument.presentationml.template': '.potx',
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
  'application/vnd.openxmlformats-officedocument.spreadsheetml.template': '.xltx',
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
  'application/vnd.openxmlformats-officedocument.wordprocessingml.template': '.dotx',
  'application/x-vnd.oasis.opendocument.spreadsheet': '.ods',
  'application/zip': '.zip',
  'image/gif': '.gif',
  'image/jpeg': '.jpg',
  'image/jpg': '.jpg',
  'image/png': '.png',
  'image/svg+xml': '.svg',
  'image/webp': '.webp',
  'message/rfc822': 'mht',
  'text/csv': '.csv',
  'text/html': '.html',
  'text/plain': '.txt',
  'text/rtf': '.rtf',
  'text/tab-separated-values': '.tsv',
  'text/tsv': '.tsv',
  }

HTTP_ERROR_PATTERN = re.compile(r'^.*returned "(.*)">$')

# gam <UserTypeEntity> get drivefile <DriveFileEntity> [revision <DriveFileRevisionID>]
#	[(format <FileFormatList>)|(gsheet|csvsheet <SheetEntity>)] [exportsheetaspdf <String>]
#	[targetfolder <FilePath>] [targetname -|<FileName>]
#	[donotfollowshortcuts [<Boolean>]] [overwrite [<Boolean>]] [showprogress [<Boolean>]]
#	[acknowledgeabuse [<Boolean>]]
def getDriveFile(users):
  def closeRemoveTargetFile(f):
    if f and not targetStdout:
      closeFile(f)
      os.remove(filename)

  fileIdEntity = getDriveFileEntity()
  sheetEntity = None
  exportSheetAsPDF = revisionId = ''
  exportFormatName = 'openoffice'
  exportFormatChoices = [exportFormatName]
  exportFormats = OPENOFFICE_FORMATS_LIST
  defaultFormats = True
  targetFolderPattern = GC.Values[GC.DRIVE_DIR]
  targetNamePattern = None
  acknowledgeAbuse = donotFollowShortcuts = overwrite = showProgress = suppressStdoutMsgs = targetStdout = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'format':
      exportFormatChoices = getString(Cmd.OB_FORMAT_LIST).replace(',', ' ').lower().split()
      exportFormats = []
      for exportFormat in exportFormatChoices:
        if exportFormat in {'ms', 'microsoft', 'micro$oft'}:
          exportFormats.extend(MICROSOFT_FORMATS_LIST)
        elif exportFormat == 'openoffice':
          exportFormats.extend(OPENOFFICE_FORMATS_LIST)
        elif exportFormat in DOCUMENT_FORMATS_MAP:
          exportFormats.extend(DOCUMENT_FORMATS_MAP[exportFormat])
        else:
          invalidChoiceExit(exportFormat, DOCUMENT_FORMATS_MAP, True)
      defaultFormats = False
    elif myarg == 'targetfolder':
      targetFolderPattern = os.path.expanduser(getString(Cmd.OB_FILE_PATH))
    elif myarg == 'targetname':
      targetNamePattern = getString(Cmd.OB_FILE_NAME)
      targetStdout = targetNamePattern == '-'
      suppressStdoutMsgs = False if not targetStdout else GM.Globals[GM.STDOUT][GM.REDIRECT_STD]
    elif myarg == 'donotfollowshortcuts':
      donotFollowShortcuts = getBoolean()
    elif myarg == 'overwrite':
      overwrite = getBoolean()
    elif myarg == 'revision':
      revisionId = getString(Cmd.OB_DRIVE_FILE_REVISION_ID)
    elif myarg in {'gsheet', 'csvsheet'}:
      sheetEntity = getSheetEntity(False)
    elif myarg == 'exportsheetaspdf':
      exportSheetAsPDF = getString(Cmd.OB_STRING, minLen=0)
    elif myarg == 'nocache':
      pass
    elif myarg == 'showprogress':
      showProgress = getBoolean()
    elif myarg == 'acknowledgeabuse':
      acknowledgeAbuse = getBoolean()
    else:
      unknownArgumentExit()
  if exportSheetAsPDF:
    exportFormatName = 'pdf'
    exportFormatChoices = [exportFormatName]
    exportFormats = DOCUMENT_FORMATS_MAP[exportFormatName]
  elif sheetEntity:
    if defaultFormats:
      exportFormatName = 'csv'
    else:
      exportFormatName = exportFormats[0]['ext'][1:]
    exportFormatChoices = [exportFormatName]
    exportFormats = DOCUMENT_FORMATS_MAP[exportFormatName]
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity, entityType=Ent.DRIVE_FILE if not suppressStdoutMsgs else None)
    if jcount == 0:
      continue
    _, userName, _ = splitEmailAddressOrUID(user)
    if exportSheetAsPDF or sheetEntity:
      _, sheet = buildGAPIServiceObject(API.SHEETS, user, i, count)
      if not sheet:
        continue
    targetFolder = _substituteForUser(targetFolderPattern, user, userName)
    if not os.path.isdir(targetFolder):
      os.makedirs(targetFolder)
    targetName = _substituteForUser(targetNamePattern, user, userName) if targetNamePattern else None
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      fileExtension = None
      try:
        result = callGAPI(drive.files(), 'get',
                          throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                          fileId=fileId, fields='name,fullFileExtension,mimeType,quotaBytesUsed,shortcutDetails', supportsAllDrives=True)
        mimeType = result['mimeType']
        if (mimeType == MIMETYPE_GA_SHORTCUT) and not donotFollowShortcuts:
          fileId = result['shortcutDetails']['targetId']
          result = callGAPI(drive.files(), 'get',
                            throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                            fileId=fileId, fields='name,fullFileExtension,mimeType,size', supportsAllDrives=True)
          mimeType = result['mimeType']
        entityValueList = [Ent.USER, user, _getEntityMimeType(result), result['name']]
        if mimeType in NON_DOWNLOADABLE_MIMETYPES:
          entityActionNotPerformedWarning(entityValueList, Msg.FORMAT_NOT_DOWNLOADABLE, j, jcount)
          continue
        if revisionId:
          callGAPI(drive.revisions(), 'get',
                   throwReasons=GAPI.DRIVE_GET_THROW_REASONS+[GAPI.REVISION_NOT_FOUND],
                   fileId=fileId, revisionId=revisionId, fields='id')
        fileExtension = result.get('fullFileExtension')
        googleDocExtensions = GOOGLEDOC_VALID_EXTENSIONS_MAP.get(mimeType)
        if googleDocExtensions:
          my_line = ['Type', 'Google Doc']
          googleDoc = True
          for exportFormat in exportFormats:
            if exportFormat['ext'] in googleDocExtensions:
              exportMimeType = exportFormat['mime']
              if fileExtension:
                extension = '.'+fileExtension
              else:
                extension = exportFormat['ext']
              break
          else:
            entityActionNotPerformedWarning(entityValueList, Msg.FORMAT_NOT_AVAILABLE.format(','.join(exportFormatChoices)), j, jcount)
            continue
        else:
          if 'quotaBytesUsed' in result:
            my_line = ['Size', formatFileSize(int(result['quotaBytesUsed']))]
          else:
            my_line = ['Size', UNKNOWN]
          googleDoc = False
          if fileExtension:
            extension = '.'+fileExtension
          else:
            extension = MIMETYPE_EXTENSION_MAP.get(mimeType, '')
        while True:
          if targetStdout:
            filename = 'stdout'
          else:
            filename, _ = uniqueFilename(targetFolder, targetName or cleanFilename(result['name']), overwrite, extension)
          spreadsheetUrl = None
          f = None
          try:
            if googleDoc:
              if (not exportSheetAsPDF and not sheetEntity) or mimeType != MIMETYPE_GA_SPREADSHEET:
                request = drive.files().export_media(fileId=fileId, mimeType=exportMimeType)
                if revisionId:
                  request.uri = f'{request.uri}&revision={revisionId}'
              else:
                spreadsheet = callGAPI(sheet.spreadsheets(), 'get',
                                       throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                                       spreadsheetId=fileId, fields='spreadsheetUrl,sheets(properties(sheetId,title))')
#                spreadsheetUrl = f'{re.sub("/edit.*$", "/export", spreadsheet["spreadsheetUrl"])}?exportFormat={exportFormatName}&format={exportFormatName}&id={fileId}'
                spreadsheetUrl = f'{re.sub("/edit.*$", "/export", spreadsheet["spreadsheetUrl"])}?format={exportFormatName}&id={fileId}'
                if sheetEntity:
                  entityValueList.extend([sheetEntity['sheetType'], sheetEntity['sheetValue']])
                  sheetId = getSheetIdFromSheetEntity(spreadsheet, sheetEntity)
                  if sheetId is None:
                    entityActionNotPerformedWarning(entityValueList, Msg.NOT_FOUND, j, jcount)
                    break
                  spreadsheetUrl += f'&gid={sheetId}'
                spreadsheetUrl += exportSheetAsPDF
            else:
              if revisionId:
                entityValueList.extend([Ent.DRIVE_FILE_REVISION, revisionId])
                request = drive.revisions().get_media(fileId=fileId, revisionId=revisionId)
              else:
                request = drive.files().get_media(fileId=fileId, acknowledgeAbuse=acknowledgeAbuse)
            if not targetStdout:
              f = open(filename, 'wb')
            else:
              f = os.fdopen(os.dup(sys.stdout.fileno()), 'wb')
            if not spreadsheetUrl:
              downloader = googleapiclient.http.MediaIoBaseDownload(f, request)
              done = False
              while not done:
                status, done = downloader.next_chunk()
                if showProgress and not suppressStdoutMsgs and status.progress() < 1.0:
                  entityActionPerformedMessage(entityValueList, f'{status.progress():>7.2%}', j, jcount)
            else:
              if GC.Values[GC.DEBUG_LEVEL] > 0:
                sys.stderr.write(f'Debug: spreadsheetUrl: {spreadsheetUrl}\n')
              maxRetries = 10
              sleepTime = 5
              for retry in range(1, maxRetries+1):
                status, content = drive._http.request(uri=spreadsheetUrl, method='GET')
                if status['status'] != '429':
                  break
                writeStderr(Msg.RETRYING_GOOGLE_SHEET_EXPORT_SLEEPING.format(retry, maxRetries, sleepTime))
                time.sleep(sleepTime)
              if status['status'] == '200':
                f.write(content)
                if targetStdout and content[-1] != '\n':
                  f.write(bytes('\n', UTF8))
              else:
                entityModifierNewValueActionFailedWarning(entityValueList, Act.MODIFIER_TO, filename, f'HTTP Error: {status["status"]}', j, jcount)
                closeRemoveTargetFile(f)
                break
            if not targetStdout:
              closeFile(f)
            if not suppressStdoutMsgs:
              entityModifierNewValueKeyValueActionPerformed(entityValueList, Act.MODIFIER_TO, filename, my_line[0], my_line[1], j, jcount)
            break
          except (IOError, httplib2.HttpLib2Error) as e:
            entityModifierNewValueActionFailedWarning(entityValueList, Act.MODIFIER_TO, filename, str(e), j, jcount)
          except googleapiclient.http.HttpError as e:
            mg = HTTP_ERROR_PATTERN.match(str(e))
            if mg:
              entityModifierNewValueActionFailedWarning(entityValueList, Act.MODIFIER_TO, filename, mg.group(1), j, jcount)
            else:
              entityModifierNewValueActionFailedWarning(entityValueList, Act.MODIFIER_TO, filename, str(e), j, jcount)
          closeRemoveTargetFile(f)
          break
      except GAPI.fileNotFound:
        entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER_ID, fileId], Msg.DOES_NOT_EXIST, j, jcount)
      except GAPI.revisionNotFound:
        entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER_ID, fileId, Ent.DRIVE_FILE_REVISION, revisionId], Msg.DOES_NOT_EXIST, j, jcount)
      except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
              GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
              GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SPREADSHEET, fileId], str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()

SUGGESTIONS_VIEW_MODE_CHOICE_MAP = {
  'default': 'DEFAULT_FOR_CURRENT_ACCESS',
  'suggestionsinline': 'SUGGESTIONS_INLINE',
  'previewsuggestionsaccepted': 'PREVIEW_SUGGESTIONS_ACCEPTED',
  'previewwithoutsuggestions': 'PREVIEW_WITHOUT_SUGGESTIONS'
  }

# gam <UserTypeEntity> get document <DriveFileEntity>
#	[viewmode default|suggestions_inline|preview_suggestions_accepted|preview_without_suggestions]
#	[targetfolder <FilePath>] [targetname <FileName>]
#	[donotfollowshortcuts [<Boolean>]] [overwrite [<Boolean>]]
def getGoogleDocument(users):
  fileIdEntity = getDriveFileEntity()
  suggestionsViewMode = SUGGESTIONS_VIEW_MODE_CHOICE_MAP['default']
  targetFolderPattern = GC.Values[GC.DRIVE_DIR]
  targetNamePattern = None
  donotFollowShortcuts = overwrite = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'viewmode':
      suggestionsViewMode = getChoice(SUGGESTIONS_VIEW_MODE_CHOICE_MAP, mapChoice=True)
    elif myarg == 'targetfolder':
      targetFolderPattern = os.path.expanduser(getString(Cmd.OB_FILE_PATH))
    elif myarg == 'targetname':
      targetNamePattern = getString(Cmd.OB_FILE_NAME)
    elif myarg == 'donotfollowshortcuts':
      donotFollowShortcuts = getBoolean()
    elif myarg == 'overwrite':
      overwrite = getBoolean()
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity, entityType=Ent.DOCUMENT)
    if jcount == 0:
      continue
    _, docs = buildGAPIServiceObject(API.DOCS, user, i, count)
    if not docs:
      continue
    _, userName, _ = splitEmailAddressOrUID(user)
    targetFolder = _substituteForUser(targetFolderPattern, user, userName)
    if not os.path.isdir(targetFolder):
      os.makedirs(targetFolder)
    targetName = _substituteForUser(targetNamePattern, user, userName) if targetNamePattern else None
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      try:
        result = callGAPI(drive.files(), 'get',
                          throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                          fileId=fileId, fields='name,mimeType,shortcutDetails', supportsAllDrives=True)
        mimeType = result['mimeType']
        if (mimeType == MIMETYPE_GA_SHORTCUT) and not donotFollowShortcuts:
          fileId = result['shortcutDetails']['targetId']
          result = callGAPI(drive.files(), 'get',
                            throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                            fileId=fileId, fields='name,mimeType', supportsAllDrives=True)
          mimeType = result['mimeType']
        docName = result['name']
        if mimeType != MIMETYPE_GA_DOCUMENT:
          entityActionNotPerformedWarning([Ent.USER, user, Ent.DRIVE_FILE, docName],
                                          Msg.INVALID_MIMETYPE.format(mimeType, MIMETYPE_GA_DOCUMENT), j, jcount)
          continue
        filename, _ = uniqueFilename(targetFolder, targetName or cleanFilename(docName), overwrite)
        result = callGAPI(docs.documents(), 'get',
                          throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                          documentId=fileId, suggestionsViewMode=suggestionsViewMode)
        if writeFile(filename, json.dumps(result, indent=2, sort_keys=True)+'\n', continueOnError=True):
          entityModifierNewValueActionPerformed([Ent.USER, user, Ent.DOCUMENT, f'{docName}({fileId})'], Act.MODIFIER_TO, filename, j, jcount)
      except GAPI.fileNotFound:
        entityActionFailedWarning([Ent.USER, user, Ent.DOCUMENT, fileId], Msg.DOES_NOT_EXIST, j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()

# gam <UserTypeEntity> update docuument <DriveFileEntity>
#	((json [charset <Charset>] <SpreadsheetJSONUpdateRequest>) |
#	 (json file <FileName> [charset <Charset>]))
#	[formatjson]
def updateGoogleDocument(users):
  fileIdEntity = getDriveFileEntity()
  body = {}
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'json':
      body = getJSON([])
    else:
      FJQC.GetFormatJSON(myarg)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, _, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity, entityType=Ent.DOCUMENT if not FJQC.formatJSON else None)
    if jcount == 0:
      continue
    _, docs = buildGAPIServiceObject(API.DOCS, user, i, count)
    if not docs:
      continue
    Ind.Increment()
    j = 0
    for documentId in fileIdEntity['list']:
      j += 1
      try:
        result = callGAPI(docs.documents(), 'batchUpdate',
                          throwReasons=GAPI.DOCS_ACCESS_THROW_REASONS,
                          documentId=documentId, body=body)
        if FJQC.formatJSON:
          printLine('{'+f'"User": "{user}", "documentId": "{documentId}", "JSON": {json.dumps(result, ensure_ascii=False, sort_keys=False)}'+'}')
          continue
        entityActionPerformed([Ent.USER, user, Ent.DOCUMENT, documentId], j, jcount)
        Ind.Increment()
        for field in ['replies', 'writeControl']:
          if field in result:
            showJSON(field, result[field])
        Ind.Decrement()
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.permissionDenied,
              GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
              GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.DOCUMENT, documentId], str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()

# gam <UserTypeEntity> collect orphans
#	[(targetuserfoldername <DriveFolderName>)(targetuserfolderid <DriveFolderID>)]
#	[useshortcuts [<Boolean>]]
#	(orderby <DriveFileOrderByFieldName> [ascending|descending])*
#	[preview] [todrive <ToDriveAttribute>*]
def collectOrphans(users):
  OBY = OrderBy(DRIVEFILE_ORDERBY_CHOICE_MAP)
  csvPF = None
  targetParms = initDriveFileAttributes()
  targetUserFolderId = None
  targetUserFolderPattern = '#user# orphaned files'
  targetParentBody = {}
  query = ME_IN_OWNERS_AND+'trashed = false'
  useShortcuts = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'orderby':
      OBY.GetChoice()
    elif myarg == 'targetuserfoldername':
      targetUserFolderPattern = getString(Cmd.OB_DRIVE_FOLDER_NAME)
      targetUserFolderId = None
    elif myarg == 'targetuserfolderid':
      targetUserFolderId = getString(Cmd.OB_DRIVE_FOLDER_ID)
      targetUserFolderPattern = None
    elif myarg == 'useshortcuts':
      useShortcuts = getBoolean()
    elif myarg == 'preview':
      csvPF = CSVPrintFile(['Owner', 'type', 'id', 'name', 'action'])
    elif csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
    if not drive:
      continue
    userName, _ = splitEmailAddress(user)
    try:
      printGettingAllEntityItemsForWhom(Ent.DRIVE_FILE_OR_FOLDER, Ent.TypeName(Ent.USER, user), i, count, query=query)
      feed = callGAPIpages(drive.files(), 'list', 'files',
                           pageMessage=getPageMessageForWhom(),
                           throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                           retryReasons=[GAPI.UNKNOWN_ERROR],
                           q=query, orderBy=OBY.orderBy,
                           fields='nextPageToken,files(id,name,parents,mimeType,sharedWithMeTime,capabilities(canMoveItemWithinDrive))',
                           pageSize=GC.Values[GC.DRIVE_MAX_RESULTS])
      if targetUserFolderPattern:
        trgtUserFolderName = _substituteForUser(targetUserFolderPattern, user, userName)
        targetParms[DFA_PARENTQUERY] = MY_NON_TRASHED_FOLDER_NAME.format(escapeDriveFileName(trgtUserFolderName))
      else:
        targetParms[DFA_PARENTID] = targetUserFolderId
        trgtUserFolderName = targetUserFolderId
      if not _getDriveFileParentInfo(drive, user, i, count, targetParentBody, targetParms, True, False):
        continue
      orphanDriveFiles = []
      for fileEntry in feed:
        if not fileEntry.get('parents') and 'sharedWithMeTime' not in fileEntry:
          orphanDriveFiles.append(fileEntry)
      jcount = len(orphanDriveFiles)
      entityPerformActionNumItemsModifier([Ent.USER, user], jcount, Ent.DRIVE_ORPHAN_FILE_OR_FOLDER,
                                          f'{Act.MODIFIER_INTO} {Ent.Singular(Ent.DRIVE_FOLDER)}: {trgtUserFolderName}', i, count)
      if jcount == 0:
        continue
      if not csvPF:
        if 'parents' not in targetParentBody or not targetParentBody['parents']:
          try:
            newParentId = callGAPI(drive.files(), 'create',
                                   throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                               GAPI.UNKNOWN_ERROR, GAPI.STORAGE_QUOTA_EXCEEDED,
                                                                               GAPI. TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP],
                                   body={'name': trgtUserFolderName, 'mimeType': MIMETYPE_GA_FOLDER}, fields='id')['id']
          except (GAPI.forbidden, GAPI.insufficientPermissions, GAPI.insufficientParentPermissions,
                  GAPI.unknownError, GAPI.storageQuotaExceeded, GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep) as e:
            entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FOLDER, trgtUserFolderName], str(e), i, count)
            continue
        else:
          newParentId = targetParentBody['parents'][0]
      setSysExitRC(ORPHANS_COLLECTED_RC)
      Ind.Increment()
      j = 0
      for fileEntry in orphanDriveFiles:
        j += 1
        fileId = fileEntry['id']
        fileName = fileEntry['name']
        fileType = _getEntityMimeType(fileEntry)
# Deleted 6.26.16
#        if fileType == Ent.DRIVE_FOLDER and not fileEntry['capabilities']['canAddMyDriveParent']:
#          # Typically Google Backup & Sync images of laptops
#          continue
        if not useShortcuts and fileEntry['capabilities']['canMoveItemWithinDrive']:
          if csvPF:
            csvPF.WriteRow({'Owner': user, 'type': Ent.Singular(fileType), 'id': fileId, 'name': fileName, 'action': 'changeParent'})
            continue
          try:
            callGAPI(drive.files(), 'update',
                     bailOnInternalError=True,
                     throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND,
                                                                 GAPI.INTERNAL_ERROR, GAPI.INSUFFICIENT_PARENT_PERMISSIONS],
                     retryReasons=[GAPI.FILE_NOT_FOUND],
                     fileId=fileId, body={}, addParents=newParentId, fields='')
            entityModifierNewValueItemValueListActionPerformed([Ent.USER, user, fileType, fileName],
                                                               Act.MODIFIER_INTO, None, [Ent.DRIVE_FOLDER, trgtUserFolderName], j, jcount)
          except (GAPI.badRequest, GAPI.fileNotFound, GAPI.internalError, GAPI.insufficientParentPermissions,) as e:
            entityActionFailedWarning([Ent.USER, user, fileType, fileName], str(e), j, jcount)
          except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
            userDriveServiceNotEnabledWarning(user, str(e), i, count)
            break
        else:
          if csvPF:
            csvPF.WriteRow({'Owner': user, 'type': Ent.Singular(fileType), 'id': fileId, 'name': fileName, 'action': 'createShortcut'})
            continue
          try:
            # Check for existing shortcut, do not duplicate
            files = callGAPIitems(drive.files(), 'list', 'files',
                                  throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID],
                                  q=f"'me' in owners and name = '{escapeDriveFileName(fileName)}' and mimeType = '{MIMETYPE_GA_SHORTCUT}' and '{newParentId}' in parents and trashed = false",
                                  fields='files(id,shortcutDetails(targetId))')
            existingShortcut = False
            for f_file in files:
              if f_file['shortcutDetails']['targetId'] == fileId:
                entityActionNotPerformedWarning([Ent.USER, user, fileType, fileName, Ent.DRIVE_FILE_SHORTCUT, f"{fileName}({f_file['id']})"],
                                                Msg.ALREADY_EXISTS_IN_TARGET_FOLDER.format(Ent.Singular(Ent.DRIVE_FOLDER), trgtUserFolderName), j, jcount)
                existingShortcut = True
                break
            if existingShortcut:
              continue
            body = {'name': fileName, 'mimeType': MIMETYPE_GA_SHORTCUT, 'parents': [newParentId], 'shortcutDetails': {'targetId': fileId}}
            result = callGAPI(drive.files(), 'create',
                              throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                          GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND, GAPI.UNKNOWN_ERROR,
                                                                          GAPI.STORAGE_QUOTA_EXCEEDED, GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP,
                                                                          GAPI.SHORTCUT_TARGET_INVALID],
                              body=body, fields='id,name', supportsAllDrives=True)
            entityModifierNewValueItemValueListActionPerformed([Ent.USER, user, fileType, fileName, Ent.DRIVE_FILE_SHORTCUT, f'{result["name"]}({result["id"]})'],
                                                               Act.MODIFIER_INTO, None, [Ent.DRIVE_FOLDER, trgtUserFolderName], j, jcount)

          except GAPI.invalidQuery:
            entityActionFailedWarning([Ent.USER, user, fileType, fileName], invalidQuery(query), j, jcount)
          except (GAPI.forbidden, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions, GAPI.invalid, GAPI.badRequest,
                  GAPI.fileNotFound, GAPI.unknownError, GAPI.storageQuotaExceeded, GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep,
                  GAPI.shortcutTargetInvalid) as e:
            entityActionFailedWarning([Ent.USER, user, fileType, fileName, Ent.DRIVE_FILE_SHORTCUT, body['name']], str(e), j, jcount)
          except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
            userDriveServiceNotEnabledWarning(user, str(e), i, count)
            break
      Ind.Decrement()
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
  if csvPF:
    csvPF.writeCSVfile('Orphans to Collect')

TRANSFER_DRIVEFILE_ACL_ROLES_MAP = {
  'commenter': 'commenter',
  'contentmanager': 'fileOrganizer',
  'contributor': 'writer',
  'editor': 'writer',
  'fileorganizer': 'fileOrganizer',
  'fileorganiser': 'fileOrganizer',
  'manager': 'organizer',
  'organizer': 'organizer',
  'organiser': 'organizer',
  'owner': 'organizer',
  'read': 'reader',
  'reader': 'reader',
  'viewer': 'reader',
  'writer': 'writer',
  'current': 'current',
  'none': 'none',
  'source': 'source',
  }

# gam <UserTypeEntity> transfer drive <UserItem> [select <DriveFileEntity>]
#	[(targetfolderid <DriveFolderID>)|(targetfoldername <DriveFolderName>)]
#	[targetuserfoldername <DriveFolderName>] [targetuserorphansfoldername <DriveFolderName>]
#	[mergewithtarget [<Boolean>]]
#	[createshortcutsfornonmovablefiles [<Boolean>]]
#	[skipids <DriveFileEntity>]
#	[keepuser | (retainrole reader|commenter|writer|editor|fileorganizer|none)] [noretentionmessages]
#	[nonowner_retainrole reader|commenter|writer|editor|fileorganizer|current|none]
#	[nonowner_targetrole reader|commenter|writer|editor|fileorganizer|current|none|source]
#	(orderby <DriveFileOrderByFieldName> [ascending|descending])*
#	[enforceexpansiveaccess [<Boolean>]]
#	[preview] [todrive <ToDriveAttribute>*]
def transferDrive(users):

  def _getOwnerUser(childEntryInfo):
    if 'owners' not in childEntryInfo or not childEntryInfo['owners']:
      return (UNKNOWN, None)
    ownerUser = childEntryInfo['owners'][0]['emailAddress']
    if ownerUser not in thirdPartyOwners:
      _, ownerDrive = buildGAPIServiceObject(API.DRIVE3, ownerUser, displayError=False)
      thirdPartyOwners[ownerUser] = ownerDrive
    else:
      ownerDrive = thirdPartyOwners[ownerUser]
    return (ownerUser, ownerDrive)

  TARGET_PARENT_ID = 0
  TARGET_ORPHANS_PARENT_ID = 1

  def _buildTargetFile(folderName, folderParentId):
    try:
      op = 'Find Target Folder'
      result = callGAPIpages(targetDrive.files(), 'list', 'files',
                             throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.BAD_REQUEST],
                             retryReasons=[GAPI.UNKNOWN_ERROR],
                             orderBy=OBY.orderBy,
                             q=MY_NON_TRASHED_FOLDER_NAME_WITH_PARENTS.format(escapeDriveFileName(folderName), folderParentId),
                             fields='nextPageToken,files(id)')
      if result:
        return result[0]['id']
      op = 'Create Target Folder'
      return callGAPI(targetDrive.files(), 'create',
                      throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                  GAPI.UNKNOWN_ERROR, GAPI.BAD_REQUEST, GAPI.STORAGE_QUOTA_EXCEEDED,
                                                                  GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP],
                      body={'parents': [folderParentId], 'name': folderName, 'mimeType': MIMETYPE_GA_FOLDER}, fields='id')['id']
    except (GAPI.forbidden, GAPI.insufficientPermissions, GAPI.insufficientParentPermissions, GAPI.unknownError, GAPI.badRequest,
            GAPI.storageQuotaExceeded, GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep) as e:
      entityActionFailedWarning([Ent.USER, targetUser, Ent.DRIVE_FOLDER, folderName], f'{op}: {str(e)}')
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(targetUser, str(e))
    return None

  def _buildTargetUserFolder():
    folderName = _substituteForUser(targetUserFolderPattern, sourceUser, sourceUserName)
    if not folderName:
      return targetFolderId
    return _buildTargetFile(folderName, targetFolderId)

  def _buildTargetUserOrphansFolder():
    folderName = _substituteForUser(targetUserOrphansFolderPattern, sourceUser, sourceUserName)
    if folderName:
      targetIds[TARGET_ORPHANS_PARENT_ID] = _buildTargetFile(folderName, targetIds[TARGET_PARENT_ID])
      if targetIds[TARGET_ORPHANS_PARENT_ID] is None:
        targetIds[TARGET_ORPHANS_PARENT_ID] = targetIds[TARGET_PARENT_ID]
    else:
      targetIds[TARGET_ORPHANS_PARENT_ID] = targetIds[TARGET_PARENT_ID]

  def _getMappedParentForRootParentOrOrphan(childEntryInfo, atSelectTop):
    if 'parents' not in childEntryInfo or not childEntryInfo['parents']:
      return (None, targetIds[TARGET_ORPHANS_PARENT_ID])
    if atSelectTop:
      return (childEntryInfo['parents'], targetIds[TARGET_PARENT_ID])
    for parentId in childEntryInfo['parents']:
      if parentId == sourceRootId:
        return ([sourceRootId], targetIds[TARGET_PARENT_ID])
    return (None, None)

  def _setUpdateRole(permission):
    return {'role': permission['role']}

  def _makeXferShortcut(drive, user, j, jcount, entityType, childId, childName, newParentId):
    kvList = [Ent.USER, user, entityType, f'{childName}({childId})']
    targetEntityType = Ent.DRIVE_FILE_SHORTCUT if entityType == Ent.DRIVE_FILE else Ent.DRIVE_FOLDER_SHORTCUT
    action = Act.Get()
    existingShortcut = _checkForExistingShortcut(drive, childId, childName, newParentId)
    if existingShortcut:
      Act.Set(Act.CREATE_SHORTCUT)
      entityModifierItemValueListActionPerformed(kvList, Act.MODIFIER_PREVIOUSLY_IN,
                                                 [Ent.DRIVE_FOLDER, newParentId, targetEntityType, f"{childName}({existingShortcut})"],
                                                 j, jcount)
      Act.Set(action)
      return
    body = {'name': childName, 'mimeType': MIMETYPE_GA_SHORTCUT,
            'parents': [newParentId], 'shortcutDetails': {'targetId': childId}}
    try:
      result = callGAPI(drive.files(), 'create',
                        throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                    GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND, GAPI.UNKNOWN_ERROR,
                                                                    GAPI.STORAGE_QUOTA_EXCEEDED, GAPI.TEAMDRIVES_SHARING_RESTRICTION_NOT_ALLOWED,
                                                                    GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP, GAPI.SHORTCUT_TARGET_INVALID],
                        body=body, fields='id', supportsAllDrives=True)
      Act.Set(Act.CREATE_SHORTCUT)
      entityModifierItemValueListActionPerformed(kvList, Act.MODIFIER_IN,
                                                 [Ent.DRIVE_FOLDER, newParentId, targetEntityType, f"{childName}({result['id']})"],
                                                 j, jcount)
    except (GAPI.forbidden, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions, GAPI.invalid, GAPI.badRequest,
            GAPI.fileNotFound, GAPI.unknownError, GAPI.storageQuotaExceeded, GAPI.teamDrivesSharingRestrictionNotAllowed,
	    GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep, GAPI.shortcutTargetInvalid) as e:
      entityActionFailedWarning(kvList+[Ent.DRIVE_FILE_SHORTCUT, childName], str(e), j, jcount)
    Act.Set(action)

# Recreate source user shortcut in target user
  def _transferShortcut(j, jcount, childEntryInfo, childId, childName, newParentId):
    entityType =  Ent.DRIVE_FOLDER_SHORTCUT if childEntryInfo['shortcutDetails']['targetMimeType'] == MIMETYPE_GA_FOLDER else Ent.DRIVE_FILE_SHORTCUT
    kvList = [Ent.USER, sourceUser, entityType, f'{childName}({childId})']
    action = Act.Get()
    body = {'name': childName, 'mimeType': MIMETYPE_GA_SHORTCUT,
            'parents': [newParentId], 'shortcutDetails': {'targetId': childEntryInfo['shortcutDetails']['targetId']}}
    Act.Set(Act.RECREATE)
    try:
      result = callGAPI(targetDrive.files(), 'create',
                        throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                    GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND, GAPI.UNKNOWN_ERROR,
                                                                    GAPI.STORAGE_QUOTA_EXCEEDED, GAPI.TEAMDRIVES_SHARING_RESTRICTION_NOT_ALLOWED,
                                                                    GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP, GAPI.SHORTCUT_TARGET_INVALID],
                        body=body, fields='id', supportsAllDrives=True)
      shortcutId = result['id']
      entityModifierNewValueItemValueListActionPerformed(kvList, Act.MODIFIER_IN, None, [Ent.USER, targetUser,
                                                                                         Ent.DRIVE_FOLDER, newParentId, entityType, f"{shortcutId})"],
                                                         j, jcount)
    except (GAPI.forbidden, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions, GAPI.invalid, GAPI.badRequest,
            GAPI.fileNotFound, GAPI.unknownError, GAPI.storageQuotaExceeded, GAPI.teamDrivesSharingRestrictionNotAllowed,
            GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep, GAPI.shortcutTargetInvalid) as e:
      entityActionFailedWarning(kvList+[Ent.DRIVE_FILE_SHORTCUT, childName], str(e), j, jcount)
      Act.Set(action)
      return
    if ownerRetainRoleBody['role'] == 'none':
      Act.Set(Act.DELETE_SHORTCUT)
      kvList = [Ent.USER, sourceUser, entityType, f'{childName}({childId})']
      try:
        callGAPI(sourceDrive.files(), 'delete',
                 throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.FILE_NEVER_WRITABLE],
                 fileId=childId, supportsAllDrives=True)
        entityActionPerformed(kvList, j, jcount)
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError, GAPI.fileNeverWritable) as e:
        entityActionFailedWarning(kvList, str(e), j, jcount)
    Act.Set(action)

  def _transferFile(childEntry, i, count, j, jcount, atSelectTop):
    childEntryInfo = childEntry['info']
    childFileId = childEntryInfo['id']
    childFileName = childEntryInfo['name']
    childFileType = _getEntityMimeType(childEntryInfo)
# Owned files
    if childEntryInfo['ownedByMe']:
      childEntryInfo['sourcePermission'] = {'role': 'owner'}
      for permission in childEntryInfo.get('permissions', []):
        if targetPermissionId == permission['id']:
          childEntryInfo['targetPermission'] = _setUpdateRole(permission)
          updateTargetPermission = True
          break
      else:
        childEntryInfo['targetPermission'] = {'role': 'none'}
        updateTargetPermission = False
      if csvPF:
        csvPF.WriteRow({'OldOwner': sourceUser, 'NewOwner': targetUser, 'type': Ent.Singular(childFileType), 'id': childFileId, 'name': childFileName, 'role': 'owner'})
        return
      Act.Set(Act.TRANSFER_OWNERSHIP)
      addTargetParent = None
      removeSourceParents = set()
      removeTargetParents = set()
      childParents = childEntryInfo.get('parents', [])
      if childParents:
        for parentId in childParents:
          if parentId in parentIdMap:
            addTargetParent = parentIdMap[parentId]
            if parentId != sourceRootId:
              removeSourceParents.add(parentId)
            elif not mergeWithTarget and targetFolderId != targetRootId:
              removeTargetParents.add(targetRootId)
      else:
        if targetIds[TARGET_ORPHANS_PARENT_ID] is None:
          _buildTargetUserOrphansFolder()
        addTargetParent = targetIds[TARGET_ORPHANS_PARENT_ID]
        removeTargetParents.add(targetRootId)
      try:
        actionUser = sourceUser
        if childEntryInfo['mimeType'] != MIMETYPE_GA_SHORTCUT:
          if not updateTargetPermission:
            op = 'Create Source ACL'
            callGAPI(sourceDrive.permissions(), 'create',
                     throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST, GAPI.INVALID_SHARING_REQUEST, GAPI.SHARING_RATE_LIMIT_EXCEEDED],
                     fileId=childFileId, sendNotificationEmail=False, body=targetWriterPermissionsBody, fields='')
          op = 'Update Source ACL'
          callGAPI(sourceDrive.permissions(), 'update',
                   throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST, GAPI.INVALID_OWNERSHIP_TRANSFER,
                                                                 GAPI.PERMISSION_NOT_FOUND, GAPI.SHARING_RATE_LIMIT_EXCEEDED],
                   enforceExpansiveAccess=enforceExpansiveAccess,
                   fileId=childFileId, permissionId=targetPermissionId,
                   transferOwnership=True, body={'role': 'owner'}, fields='')
          if removeSourceParents:
            op = 'Remove Source Parents'
            callGAPI(sourceDrive.files(), 'update',
                     throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS, retryReasons=[GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND], triesLimit=3,
                     fileId=childFileId, removeParents=','.join(removeSourceParents), fields='')
          actionUser = targetUser
          if addTargetParent or removeTargetParents:
            op = 'Add/Remove Target Parents'
            callGAPI(targetDrive.files(), 'update',
                     throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.CANNOT_ADD_PARENT, GAPI.INSUFFICIENT_PARENT_PERMISSIONS],
                     retryReasons=[GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND], triesLimit=3,
                     fileId=childFileId,
                     addParents=addTargetParent, removeParents=','.join(removeTargetParents), fields='')
          entityModifierNewValueItemValueListActionPerformed([Ent.USER, sourceUser, childFileType, childFileName], Act.MODIFIER_TO, None, [Ent.USER, targetUser], j, jcount)
        else:
          if topSourceId in childParents:
            _transferShortcut(j, jcount, childEntryInfo, childFileId, childFileName, addTargetParent)
          else:
            entityModifierNewValueItemValueListActionPerformed([Ent.USER, sourceUser, childFileType, childFileName], Act.MODIFIER_TO, None, [Ent.USER, targetUser], j, jcount)
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.unknownError,
              GAPI.badRequest, GAPI.sharingRateLimitExceeded, GAPI.cannotAddParent, GAPI.insufficientParentPermissions) as e:
        entityActionFailedWarning([Ent.USER, actionUser, childFileType, childFileName], f'{op}: {str(e)}', j, jcount)
      except (GAPI.insufficientFilePermissions, GAPI.fileOwnerNotMemberOfWriterDomain, GAPI.crossDomainMoveRestriction) as e:
        if not createShortcutsForNonmovableFiles:
          entityActionFailedWarning([Ent.USER, actionUser, childFileType, childFileName], f'{op}: {str(e)}', j, jcount)
        else:
          _makeXferShortcut(targetDrive, targetUser, j, jcount, childFileType, childFileId, childFileName, addTargetParent)
      except GAPI.permissionNotFound:
        entityDoesNotHaveItemWarning([Ent.USER, actionUser, childFileType, childFileName, Ent.PERMISSION_ID, targetPermissionId], j, jcount)
      except GAPI.invalidSharingRequest as e:
        entityActionFailedWarning([Ent.USER, actionUser, childFileType, childFileName], Ent.TypeNameMessage(Ent.PERMISSION_ID, targetPermissionId, str(e)), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(actionUser, str(e), i, count)
# Non-owned files
    else:
      Act.Set(Act.PROCESS)
      for permission in childEntryInfo.get('permissions', []):
        if sourcePermissionId == permission['id']:
          childEntryInfo['sourcePermission'] = _setUpdateRole(permission)
          getSourcePermissionFromOwner = False
          break
      else:
        childEntryInfo['sourcePermission'] = nonOwnerRetainRoleBody
        getSourcePermissionFromOwner = True
      for permission in childEntryInfo.get('permissions', []):
        if targetPermissionId == permission['id']:
          childEntryInfo['targetPermission'] = _setUpdateRole(permission)
          getTargetPermissionFromOwner = False
          break
      else:
        childEntryInfo['targetPermission'] = {'role': 'none'}
        getTargetPermissionFromOwner = True
      ownerUser, ownerDrive = _getOwnerUser(childEntryInfo)
      if not ownerDrive:
        entityActionNotPerformedWarning([Ent.USER, sourceUser, childFileType, childFileName],
                                        Msg.SERVICE_NOT_APPLICABLE_THIS_ADDRESS.format(ownerUser), j, jcount)
        return
      if getSourcePermissionFromOwner or getTargetPermissionFromOwner:
        try:
          permissions = callGAPIpages(ownerDrive.permissions(), 'list', 'permissions',
                                      throwReasons=GAPI.DRIVE3_GET_ACL_REASONS+[GAPI.BAD_REQUEST],
                                      retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                      fileId=childFileId, fields='nextPageToken,permissions')
          if getSourcePermissionFromOwner:
            for permission in permissions:
              if sourcePermissionId == permission['id']:
                childEntryInfo['sourcePermission'] = _setUpdateRole(permission)
                break
            else:
              childEntryInfo['sourcePermission'] = nonOwnerRetainRoleBody
          if getTargetPermissionFromOwner:
            for permission in permissions:
              if targetPermissionId == permission['id']:
                childEntryInfo['targetPermission'] = _setUpdateRole(permission)
                break
            else:
              childEntryInfo['targetPermission'] = {'role': 'none'}
        except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError,
                GAPI.insufficientAdministratorPrivileges, GAPI.insufficientFilePermissions,
                GAPI.unknownError, GAPI.invalid, GAPI.badRequest) as e:
          entityActionFailedWarning([Ent.USER, ownerUser, childFileType, childFileName], str(e), j, jcount)
          return
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          userDriveServiceNotEnabledWarning(ownerUser, str(e), i, count)
          return
      if csvPF:
        csvPF.WriteRow({'OldOwner': sourceUser, 'NewOwner': targetUser, 'type': Ent.Singular(childFileType),
                        'id': childFileId, 'name': childFileName, 'role': childEntryInfo['sourcePermission']['role']})
        return
      if (childFileType == Ent.DRIVE_FOLDER) and (childEntryInfo['targetPermission']['role'] == 'none') and (ownerRetainRoleBody['role'] == 'none'):
        if targetIds[TARGET_ORPHANS_PARENT_ID] is None:
          _buildTargetUserOrphansFolder()
        parentIdMap[childFileId] = _buildTargetFile(childFileName, targetIds[TARGET_ORPHANS_PARENT_ID])
        entityActionPerformed([Ent.USER, sourceUser, childFileType, childFileName], j, jcount)
        return
      existingParentIds, mappedParentId = _getMappedParentForRootParentOrOrphan(childEntryInfo, atSelectTop)
      if mappedParentId is not None:
# Give temporary writer access to target user so other actions can be performed
        if childEntryInfo['targetPermission']['role'] in {'none', 'reader'}:
          try:
            callGAPI(ownerDrive.permissions(), 'create',
                     throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.INVALID_SHARING_REQUEST, GAPI.SHARING_RATE_LIMIT_EXCEEDED],
                     fileId=childFileId, sendNotificationEmail=False, body=targetWriterPermissionsBody, fields='')
          except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
                  GAPI.badRequest, GAPI.sharingRateLimitExceeded) as e:
            entityActionFailedWarning([Ent.USER, ownerUser, childFileType, childFileName], str(e), j, jcount)
            return
          except GAPI.invalidSharingRequest as e:
            entityActionFailedWarning([Ent.USER, ownerUser, childFileType, childFileName],
                                      Ent.TypeNameMessage(Ent.PERMISSION_ID, sourcePermissionId, str(e)), j, jcount)
            return
          except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
            userDriveServiceNotEnabledWarning(ownerUser, str(e), i, count)
            return
        if existingParentIds is not None:
# We have to make a shortcut to a non-owned non-orphan as we can't change the parents
          try:
            body = {'name': childFileName, 'mimeType': MIMETYPE_GA_SHORTCUT, 'parents': [mappedParentId], 'shortcutDetails': {'targetId': childFileId}}
            callGAPI(targetDrive.files(), 'create',
                     throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                 GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND, GAPI.UNKNOWN_ERROR,
                                                                 GAPI.STORAGE_QUOTA_EXCEEDED, GAPI.TEAMDRIVES_SHARING_RESTRICTION_NOT_ALLOWED,
                                                                 GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP],
                     body=body, fields='', supportsAllDrives=True)
          except (GAPI.forbidden, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions, GAPI.invalid, GAPI.badRequest,
                  GAPI.fileNotFound, GAPI.unknownError, GAPI.storageQuotaExceeded, GAPI.teamDrivesSharingRestrictionNotAllowed,
                  GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep) as e:
            entityActionFailedWarning([Ent.USER, targetUser, childFileType, childFileName, Ent.DRIVE_FILE_SHORTCUT, body['name']], str(e), j, jcount)
            return
# Delete existing parents
          try:
            callGAPI(sourceDrive.files(), 'update',
                     throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST],
                     retryReasons=[GAPI.FILE_NOT_FOUND], triesLimit=3,
                     fileId=childFileId,
                     removeParents=','.join(existingParentIds), body={}, fields='')
          except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
                  GAPI.badRequest) as e:
            entityActionFailedWarning([Ent.USER, sourceUser, childFileType, childFileName], str(e), j, jcount)
            return
          except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
            userDriveServiceNotEnabledWarning(sourceUser, str(e), i, count)
            return
# 5.31.03 - Non-owned files without parents are SharedWithMe, parents can not be changed
#        else:
## We can add a parent when transferring an orphan
#          try:
#            callGAPI(targetDrive.files(), 'update',
#                     throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST, GAPI.CANNOT_ADD_PARENT, GAPI.INSUFFICIENT_PARENT_PERMISSIONS],
#                     retryReasons=[GAPI.FILE_NOT_FOUND], triesLimit=3,
#                     fileId=childFileId,
#                     addParents=mappedParentId, body={}, fields='')
#          except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions, GAPI.unknownError,
#                  GAPI.badRequest, GAPI.cannotAddParent) as e:
#            entityActionFailedWarning([Ent.USER, targetUser, childFileType, childFileName], str(e), j, jcount)
#            return
#          except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
#            userDriveServiceNotEnabledWarning(targetUser, str(e), i, count)
#            return
      entityActionPerformed([Ent.USER, sourceUser, childFileType, childFileName], j, jcount)

  def _manageRoleRetention(childEntry, i, count, j, jcount, atSelectTop):
    def _setTargetInsertBody(permission):
      return {'role': permission['role'], 'type': 'user', 'emailAddress': targetUser}

    def _checkForDiminishedTargetRole(currentPermission, newPermission):
      if currentPermission['role'] in {'owner', 'organizer', 'fileOrganizer', 'writer'}:
        return False
      if (currentPermission['role'] == 'commenter') and (newPermission['role'] == 'reader'):
        return False
      return True

    childEntryInfo = childEntry['info']
    childFileId = childEntryInfo['id']
    childFileName = childEntryInfo['name']
    childFileType = _getEntityMimeType(childEntryInfo)
    if childEntryInfo['mimeType'] == MIMETYPE_GA_SHORTCUT:
      if showRetentionMessages:
        entityActionNotPerformedWarning([Ent.USER, sourceUser, childFileType, childFileName, Ent.ROLE, ownerRetainRoleBody['role']], Msg.NOT_APPROPRIATE, j, jcount)
      return
    if childEntryInfo['ownedByMe']:
      try:
        if ownerRetainRoleBody['role'] != 'none':
          if ownerRetainRoleBody['role'] != 'writer':
            callGAPI(targetDrive.permissions(), 'update',
                     throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.PERMISSION_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.SHARING_RATE_LIMIT_EXCEEDED],
                     enforceExpansiveAccess=enforceExpansiveAccess,
                     fileId=childFileId, permissionId=sourcePermissionId, body=ownerRetainRoleBody, fields='')
        else:
          callGAPI(targetDrive.permissions(), 'delete',
                   throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.PERMISSION_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.SHARING_RATE_LIMIT_EXCEEDED, GAPI.CANNOT_REMOVE_OWNER],
                   fileId=childFileId, permissionId=sourcePermissionId)
        if showRetentionMessages:
          entityActionPerformed([Ent.USER, sourceUser, childFileType, childFileName, Ent.ROLE, ownerRetainRoleBody['role']], j, jcount)
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
              GAPI.badRequest, GAPI.sharingRateLimitExceeded, GAPI.cannotRemoveOwner, GAPI.cannotDeletePermission) as e:
        entityActionFailedWarning([Ent.USER, sourceUser, childFileType, childFileName], str(e), j, jcount)
      except GAPI.permissionNotFound:
        entityDoesNotHaveItemWarning([Ent.USER, sourceUser, childFileType, childFileName, Ent.PERMISSION_ID, sourcePermissionId], j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(sourceUser, str(e), i, count)
    else:
      ownerUser, ownerDrive = _getOwnerUser(childEntryInfo)
      if not ownerDrive:
        return
      if nonOwnerRetainRoleBody['role'] == 'current':
        sourceUpdateRole = childEntryInfo['sourcePermission']
      else:
        sourceUpdateRole = nonOwnerRetainRoleBody
      if 'targetPermission' not in childEntryInfo:
        childEntryInfo['targetPermission'] = {'role': 'current'}
        errorTargetRole = True
      else:
        errorTargetRole = False
      if nonOwnerTargetRoleBody['role'] == 'current':
        targetInsertBody = _setTargetInsertBody(childEntryInfo['targetPermission'])
        resetTargetRole = False
      elif nonOwnerTargetRoleBody['role'] == 'source':
        targetInsertBody = _setTargetInsertBody(childEntryInfo['sourcePermission'])
        resetTargetRole = True
      else:
        targetInsertBody = _setTargetInsertBody(nonOwnerTargetRoleBody)
        resetTargetRole = True
      if not errorTargetRole:
        if resetTargetRole:
          resetTargetRole = _checkForDiminishedTargetRole(childEntryInfo['targetPermission'], targetInsertBody)
        else:
          _, mappedParentId = _getMappedParentForRootParentOrOrphan(childEntryInfo, atSelectTop)
          if mappedParentId is not None and childEntryInfo['targetPermission']['role'] in {'none', 'reader'}:
            resetTargetRole = True
# Update owner permissions
      try:
        if nonOwnerRetainRoleBody['role'] != 'none':
          if nonOwnerRetainRoleBody['role'] != 'current':
            callGAPI(ownerDrive.permissions(), 'update',
                     throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.PERMISSION_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.SHARING_RATE_LIMIT_EXCEEDED],
                     enforceExpansiveAccess=enforceExpansiveAccess,
                     fileId=childFileId, permissionId=sourcePermissionId, body=sourceUpdateRole, fields='')
        else:
          try:
            callGAPI(ownerDrive.permissions(), 'delete',
                     throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.PERMISSION_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.SHARING_RATE_LIMIT_EXCEEDED, GAPI.CANNOT_REMOVE_OWNER],
                     fileId=childFileId, permissionId=sourcePermissionId)
          except GAPI.permissionNotFound:
            pass
        if showRetentionMessages:
          entityActionPerformed([Ent.USER, sourceUser, childFileType, childFileName, Ent.ROLE, sourceUpdateRole['role']], j, jcount)
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
              GAPI.badRequest, GAPI.sharingRateLimitExceeded, GAPI.cannotRemoveOwner, GAPI.cannotDeletePermission) as e:
        entityActionFailedWarning([Ent.USER, ownerUser, childFileType, childFileName], str(e), j, jcount)
      except GAPI.permissionNotFound:
        entityDoesNotHaveItemWarning([Ent.USER, ownerUser, childFileType, childFileName, Ent.PERMISSION_ID, sourcePermissionId], j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(ownerUser, str(e), i, count)
# Update target permissions
      if resetTargetRole and targetUser != ownerUser:
        try:
          if nonOwnerTargetRoleBody['role'] != 'none':
            if nonOwnerTargetRoleBody['role'] != 'current' and targetInsertBody['role'] not in {'current', 'none'}:
              callGAPI(ownerDrive.permissions(), 'create',
                       throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.INVALID_SHARING_REQUEST, GAPI.SHARING_RATE_LIMIT_EXCEEDED],
                       fileId=childFileId, sendNotificationEmail=False, body=targetInsertBody, fields='')
          else:
            try:
              callGAPI(ownerDrive.permissions(), 'delete',
                       throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.PERMISSION_NOT_FOUND, GAPI.BAD_REQUEST, GAPI.SHARING_RATE_LIMIT_EXCEEDED],
                       fileId=childFileId, permissionId=targetPermissionId)
            except GAPI.permissionNotFound:
              pass
          if showRetentionMessages:
            entityActionPerformed([Ent.USER, targetUser, childFileType, childFileName, Ent.ROLE, targetInsertBody['role']], j, jcount)
        except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
                GAPI.badRequest, GAPI.sharingRateLimitExceeded, GAPI.cannotDeletePermission) as e:
          entityActionFailedWarning([Ent.USER, ownerUser, childFileType, childFileName], str(e), j, jcount)
        except GAPI.invalidSharingRequest as e:
          entityActionFailedWarning([Ent.USER, ownerUser, childFileType, childFileName], Ent.TypeNameMessage(Ent.PERMISSION_ID, targetPermissionId, str(e)), j, jcount)
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          userDriveServiceNotEnabledWarning(sourceUser, str(e), i, count)
      elif showRetentionMessages:
        entityActionPerformed([Ent.USER, targetUser, childFileType, childFileName, Ent.ROLE, childEntryInfo['targetPermission']['role']], j, jcount)

  def _transferDriveFilesFromTree(fileEntry, i, count):
    jcount = len(fileEntry['children'])
    if jcount == 0:
      return
    j = 0
    for childFileId in fileEntry['children']:
      j += 1
      childEntry = fileTree.get(childFileId)
      if not childEntry or childFileId in filesTransferred:
        continue
      if childFileId in skipFileIdEntity['list']:
        entityActionNotPerformedWarning([Ent.USER, sourceUser, _getEntityMimeType(childEntry['info']), f'{childEntry["info"]["name"]} ({childFileId})'],
                                        Msg.IN_SKIPIDS, j, jcount)
        continue
      filesTransferred.add(childFileId)
      _transferFile(childEntry, i, count, j, jcount, False)
      if childEntry['info']['mimeType'] == MIMETYPE_GA_FOLDER:
        Ind.Increment()
        _transferDriveFilesFromTree(childEntry, i, count)
        Ind.Decrement()

  def _manageRoleRetentionDriveFilesFromTree(fileEntry, i, count):
    jcount = len(fileEntry['children'])
    if jcount == 0:
      return
    j = 0
    for childFileId in fileEntry['children']:
      j += 1
      childEntry = fileTree.get(childFileId)
      if not childEntry or childFileId in filesTransferred or childFileId in skipFileIdEntity['list']:
        continue
      filesTransferred.add(childFileId)
      _manageRoleRetention(childEntry, i, count, j, jcount, False)
      if childEntry['info']['mimeType'] == MIMETYPE_GA_FOLDER:
        Ind.Increment()
        _manageRoleRetentionDriveFilesFromTree(childEntry, i, count)
        Ind.Decrement()

  def _identifyDriveFileAndChildren(fileEntry, i, count):
    fileId = fileEntry['id']
    if fileId not in fileTree:
      fileTree[fileId] = {'info': fileEntry, 'children': []}
    if fileEntry['mimeType'] != MIMETYPE_GA_FOLDER:
      return
    try:
      children = callGAPIpages(sourceDrive.files(), 'list', 'files',
                               throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                               retryReasons=[GAPI.UNKNOWN_ERROR],
                               orderBy=OBY.orderBy, q=WITH_PARENTS.format(fileId),
                               fields='nextPageToken,files(id,name,parents,mimeType,ownedByMe,trashed,owners(emailAddress,permissionId),permissions(id,role),shortcutDetails)',
                               pageSize=GC.Values[GC.DRIVE_MAX_RESULTS])
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(sourceUser, str(e), i, count)
      return
    for childEntry in children:
      if not childEntry['trashed']:
        childId = childEntry['id']
        if childId in skipFileIdEntity['list']:
          entityActionNotPerformedWarning([Ent.USER, sourceUser, _getEntityMimeType(childEntry), f'{childEntry["name"]} ({childId})'],
                                          Msg.IN_SKIPIDS)
          continue
        fileTree[fileId]['children'].append(childId)
        _identifyDriveFileAndChildren(childEntry, i, count)

  def _transferDriveFileAndChildren(fileEntry, i, count, j, jcount, atSelectTop):
    fileId = fileEntry['info']['id']
    if fileId in filesTransferred:
      return
    if fileEntry['info']['name'] != MY_DRIVE:
      filesTransferred.add(fileId)
      if not atSelectTop or not mergeWithTarget:
        _transferFile(fileEntry, i, count, j, jcount, atSelectTop)
    kcount = len(fileEntry['children'])
    if kcount == 0:
      return
    k = 0
    for childFileId in fileEntry['children']:
      k += 1
      childEntry = fileTree.get(childFileId)
      if childEntry:
        Ind.Increment()
        _transferDriveFileAndChildren(childEntry, i, count, k, kcount, False)
        Ind.Decrement()

  def _manageRoleRetentionDriveFileAndChildren(fileEntry, i, count, j, jcount, atSelectTop):
    fileId = fileEntry['info']['id']
    if fileId in filesTransferred:
      return
    if fileEntry['info']['name'] != MY_DRIVE:
      filesTransferred.add(fileId)
      if not atSelectTop or  not mergeWithTarget:
        _manageRoleRetention(fileEntry, i, count, j, jcount, atSelectTop)
    kcount = len(fileEntry['children'])
    if kcount == 0:
      return
    k = 0
    for childFileId in fileEntry['children']:
      k += 1
      childEntry = fileTree.get(childFileId)
      if childEntry:
        Ind.Increment()
        _manageRoleRetentionDriveFileAndChildren(childEntry, i, count, k, kcount, False)
        Ind.Decrement()

  targetUser = getEmailAddress()
  buildTree = True
  csvPF = None
  OBY = OrderBy(DRIVEFILE_ORDERBY_CHOICE_MAP)
  ownerRetainRoleBody = {'role': 'none'}
  nonOwnerRetainRoleBody = {}
  nonOwnerTargetRoleBody = {'role': 'source'}
  showRetentionMessages = True
  targetFolderId = targetFolderName = None
  targetUserFolderPattern = '#user# old files'
  targetUserOrphansFolderPattern = '#user# orphaned files'
  targetIds = [None, None]
  createShortcutsForNonmovableFiles = enforceExpansiveAccess = False
  mergeWithTarget = False
  thirdPartyOwners = {}
  skipFileIdEntity = initDriveFileEntity()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'keepuser':
      ownerRetainRoleBody['role'] = 'writer'
    elif myarg == 'retainrole':
      ownerRetainRoleBody['role'] = getChoice(TRANSFER_DRIVEFILE_ACL_ROLES_MAP, mapChoice=True)
      if ownerRetainRoleBody['role'] in {'source', 'current'}:
        ownerRetainRoleBody['role'] = 'writer'
    elif myarg == 'nonownerretainrole':
      nonOwnerRetainRoleBody['role'] = getChoice(TRANSFER_DRIVEFILE_ACL_ROLES_MAP, mapChoice=True)
      if nonOwnerRetainRoleBody['role'] == 'source':
        nonOwnerRetainRoleBody['role'] = 'current'
    elif myarg == 'nonownertargetrole':
      nonOwnerTargetRoleBody['role'] = getChoice(TRANSFER_DRIVEFILE_ACL_ROLES_MAP, mapChoice=True)
    elif myarg == 'enforceexpansiveaccess':
      enforceExpansiveAccess = getBoolean()
    elif myarg == 'noretentionmessages':
      showRetentionMessages = False
    elif myarg == 'orderby':
      OBY.GetChoice()
    elif myarg == 'targetfolderid':
      targetFolderIdLocation = Cmd.Location()
      targetFolderId = getString(Cmd.OB_DRIVE_FILE_ID, minLen=0)
    elif myarg == 'targetfoldername':
      targetFolderNameLocation = Cmd.Location()
      targetFolderName = getString(Cmd.OB_DRIVE_FILE_NAME, minLen=0)
    elif myarg == 'targetuserfoldername':
      targetUserFolderPattern = getString(Cmd.OB_DRIVE_FILE_NAME, minLen=0)
    elif myarg == 'targetuserorphansfoldername':
      targetUserOrphansFolderPattern = getString(Cmd.OB_DRIVE_FILE_NAME, minLen=0)
    elif myarg == 'select':
      fileIdEntity = getDriveFileEntity()
      buildTree = False
    elif myarg == 'mergewithtarget':
      mergeWithTarget = getBoolean()
    elif myarg == 'createshortcutsfornonmovablefiles':
      createShortcutsForNonmovableFiles = getBoolean()
    elif myarg == 'skipids':
      skipFileIdEntity = getDriveFileEntity()
    elif myarg == 'preview':
      csvPF = CSVPrintFile(['OldOwner', 'NewOwner', 'type', 'id', 'name', 'role'])
    elif csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    else:
      unknownArgumentExit()
  if not nonOwnerRetainRoleBody:
    nonOwnerRetainRoleBody = ownerRetainRoleBody
  if not OBY.orderBy:
    OBY.SetItems('folder,createdTime')
  targetUser, targetDrive = buildGAPIServiceObject(API.DRIVE3, targetUser)
  if not targetDrive:
    return
  try:
    result = callGAPI(targetDrive.about(), 'get',
                      throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                      fields='storageQuota,user(permissionId)')
    if result['storageQuota'].get('limit'):
      targetDriveFree = int(result['storageQuota']['limit'])-int(result['storageQuota']['usageInDrive'])
    else:
      targetDriveFree = None
    targetPermissionId = result['user']['permissionId']
    result = callGAPI(targetDrive.files(), 'get',
                      throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                      fileId=ROOT, fields='id,name')
    targetRootId = result['id']
    if not targetFolderId and not targetFolderName:
      targetFolderId = targetRootId
      targetFolderName = result['name']
    else:
      if targetFolderId:
        targetFolder = callGAPI(targetDrive.files(), 'get',
                                throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                                fileId=targetFolderId, fields='id,name,mimeType,ownedByMe')
        if targetFolder['mimeType'] != MIMETYPE_GA_FOLDER:
          Cmd.SetLocation(targetFolderIdLocation)
          usageErrorExit(formatKeyValueList(Ind.Spaces(),
                                            [Ent.Singular(Ent.USER), targetUser,
                                             Ent.Singular(Ent.DRIVE_FOLDER_ID), targetFolderId,
                                             Msg.NOT_AN_ENTITY.format(Ent.Singular(Ent.DRIVE_FOLDER))],
                                            '\n'))
        if not targetFolder['ownedByMe']:
          Cmd.SetLocation(targetFolderIdLocation)
          usageErrorExit(formatKeyValueList(Ind.Spaces(),
                                            [Ent.Singular(Ent.USER), targetUser,
                                             Ent.Singular(Ent.DRIVE_FOLDER_ID), targetFolderId,
                                             Msg.NOT_OWNED_BY.format(targetUser)],
                                            '\n'))
        targetFolderId = targetFolder['id']
        targetFolderName = targetFolder['name']
      elif targetFolderName:
        result = callGAPIpages(targetDrive.files(), 'list', 'files',
                               throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                               retryReasons=[GAPI.UNKNOWN_ERROR],
                               q=MY_NON_TRASHED_FOLDER_NAME.format(escapeDriveFileName(targetFolderName)),
                               fields='nextPageToken,files(id)')
        if not result:
          Cmd.SetLocation(targetFolderNameLocation)
          usageErrorExit(formatKeyValueList(Ind.Spaces(),
                                            [Ent.Singular(Ent.USER), targetUser,
                                             Ent.Singular(Ent.DRIVE_FOLDER), targetFolderName,
                                             Msg.DOES_NOT_EXIST],
                                            '\n'))
        targetFolderId = result[0]['id']
  except GAPI.fileNotFound:
    Cmd.SetLocation(targetFolderIdLocation)
    usageErrorExit(formatKeyValueList(Ind.Spaces(),
                                      [Ent.Singular(Ent.USER), targetUser,
                                       Ent.Singular(Ent.DRIVE_FOLDER_ID), targetFolderId,
                                       Msg.DOES_NOT_EXIST],
                                      '\n'))
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
    userDriveServiceNotEnabledWarning(targetUser, str(e))
    return
  targetWriterPermissionsBody = {'role': 'writer', 'type': 'user', 'emailAddress': targetUser}
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    if buildTree:
      sourceUser, sourceDrive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
      if not sourceDrive:
        continue
    else:
      sourceUser, sourceDrive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity, entityType=Ent.DRIVE_FOLDER)
      if jcount == 0:
        continue
    sourceUserName, _ = splitEmailAddress(sourceUser)
    try:
      result = callGAPI(sourceDrive.about(), 'get',
                        throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                        fields='storageQuota,user(permissionId)')
      sourceDriveSize = int(result['storageQuota']['usageInDrive'])
      sourcePermissionId = result['user']['permissionId']
      sourceRootId = callGAPI(sourceDrive.files(), 'get',
                              throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                              fileId=ROOT, fields='id')['id']
      if (targetDriveFree is not None) and (targetDriveFree < sourceDriveSize):
        printWarningMessage(TARGET_DRIVE_SPACE_ERROR_RC,
                            (f'{Msg.NO_TRANSFER_LACK_OF_DISK_SPACE} '
                             f'{formatKeyValueList("", ["Source drive size", formatFileSize(sourceDriveSize), "Target drive free", formatFileSize(targetDriveFree)], "")}'))
        continue
      printKeyValueList(['Source drive size', formatFileSize(sourceDriveSize),
                         'Target drive free', formatFileSize(targetDriveFree) if targetDriveFree is not None else 'UNLIMITED'])
      if targetDriveFree is not None:
        targetDriveFree = targetDriveFree-sourceDriveSize # prep targetDriveFree for next user
      if not csvPF:
        targetIds[TARGET_PARENT_ID] = _buildTargetUserFolder()
        if targetIds[TARGET_PARENT_ID] is None:
          return
      Ind.Increment()
      if skipFileIdEntity['query'] or skipFileIdEntity[ROOT]:
        _validateUserGetFileIDs(origUser, i, count, skipFileIdEntity, drive=sourceDrive)
      if buildTree:
        topSourceId = sourceRootId
        parentIdMap = {sourceRootId: targetIds[TARGET_PARENT_ID]}
        printGettingAllEntityItemsForWhom(Ent.DRIVE_FILE_OR_FOLDER, Ent.TypeName(Ent.SOURCE_USER, user), i, count)
        feed = callGAPIpages(sourceDrive.files(), 'list', 'files',
                             pageMessage=getPageMessageForWhom(),
                             throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                             retryReasons=[GAPI.UNKNOWN_ERROR],
                             orderBy=OBY.orderBy, q=NON_TRASHED,
                             fields='nextPageToken,files(id,name,parents,mimeType,ownedByMe,owners(emailAddress,permissionId),permissions(id,role),shortcutDetails)',
                             pageSize=GC.Values[GC.DRIVE_MAX_RESULTS])
        fileTree = buildFileTree(feed, sourceDrive)
        del feed
        filesTransferred = set()
        _transferDriveFilesFromTree(fileTree[sourceRootId], i, count)
        if fileTree[ORPHANS]['children']:
          if not csvPF:
            _buildTargetUserOrphansFolder()
          _transferDriveFilesFromTree(fileTree[ORPHANS], i, count)
        if not csvPF:
          Act.Set(Act.RETAIN)
          filesTransferred = set()
          _manageRoleRetentionDriveFilesFromTree(fileTree[sourceRootId], i, count)
          if fileTree[ORPHANS]['children']:
            _manageRoleRetentionDriveFilesFromTree(fileTree[ORPHANS], i, count)
      else:
        j = 0
        for fileId in fileIdEntity['list']:
          j += 1
          fileTree = {}
          parentIdMap = {sourceRootId: targetIds[TARGET_PARENT_ID]}
          Act.Set(Act.TRANSFER_OWNERSHIP)
          try:
            fileEntry = callGAPI(sourceDrive.files(), 'get',
                                 throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                                 fileId=fileId,
                                 fields='id,name,parents,mimeType,ownedByMe,trashed,owners(emailAddress,permissionId),permissions(id,role),shortcutDetails')
            entityType = _getEntityMimeType(fileEntry)
            if fileId in skipFileIdEntity['list']:
              entityActionNotPerformedWarning([Ent.USER, sourceUser, entityType, f'{fileEntry["name"]} ({fileId})'],
                                              Msg.IN_SKIPIDS, j, jcount)
              continue
            entityPerformActionItemValue([Ent.USER, sourceUser], entityType, f'{fileEntry["name"]} ({fileId})', j, jcount)
            if not mergeWithTarget:
              topSourceId = None
              for parentId in fileEntry.get('parents', []):
                parentIdMap[parentId] = targetIds[TARGET_PARENT_ID]
            else:
              topSourceId = fileId
              parentIdMap[fileId] = targetIds[TARGET_PARENT_ID]
            _identifyDriveFileAndChildren(fileEntry, i, count)
            filesTransferred = set()
            _transferDriveFileAndChildren(fileTree[fileId], i, count, j, jcount, True)
            if not csvPF:
              Act.Set(Act.RETAIN)
              filesTransferred = set()
              _manageRoleRetentionDriveFileAndChildren(fileTree[fileId], i, count, j, jcount, True)
          except GAPI.fileNotFound:
            entityActionFailedWarning([Ent.USER, sourceUser, Ent.DRIVE_FILE_OR_FOLDER, fileId], Msg.NOT_FOUND, j, jcount)
          except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
            userDriveServiceNotEnabledWarning(sourceUser, str(e), i, count)
            break
      Ind.Decrement()
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(sourceUser, str(e), i, count)
  if csvPF:
    csvPF.writeCSVfile('Files to Transfer')

def validateUserGetPermissionId(user, i=0, count=0, drive=None):
  if drive is None:
    _, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
  if drive:
    try:
      result = callGAPI(drive.about(), 'get',
                        throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                        fields='user(permissionId)')
      return (drive, result['user']['permissionId'])
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
  return (None, None)

def getPermissionIdForEmail(user, i, count, email):
  currentSvcAcctAPI = GM.Globals[GM.CURRENT_SVCACCT_API]
  currentSvcAcctAPIScopes = GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES]
  _, drive = buildGAPIServiceObject(API.DRIVE2, user, i, count)
  GM.Globals[GM.CURRENT_SVCACCT_API] = currentSvcAcctAPI
  GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES] = currentSvcAcctAPIScopes
  if drive:
    try:
      return callGAPI(drive.permissions(), 'getIdForEmail',
                      throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                      email=email, fields='id')['id']
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy):
      entityActionNotPerformedWarning([Ent.USER, user], Msg.UNABLE_TO_GET_PERMISSION_ID.format(email), i, count)
      systemErrorExit(GM.Globals[GM.SYSEXITRC], None)
  return None

# gam <UserTypeEntity> transfer ownership <DriveFileEntity> <UserItem>
#	[<DriveFileParentAttribute>] [includetrashed] [norecursion [<Boolean>]]
#	(orderby <DriveFileOrderByFieldName> [ascending|descending])*
#	[preview] [filepath] [pathdelimiter <Character>] [buildtree]
#	[enforceexpansiveaccess [<Boolean>]]
#	[todrive <ToDriveAttribute>*]
def transferOwnership(users):
  def _identifyFilesToTransfer(fileEntry):
    for childFileId in fileEntry['children']:
      if childFileId in filesTransferred:
        continue
      filesTransferred.add(childFileId)
      childEntry = fileTree.get(childFileId)
      if childEntry:
        childEntryInfo = childEntry['info']
        if includeTrashed or not childEntryInfo['trashed']:
          if childEntryInfo['ownedByMe']:
            filesToTransfer[childFileId] = {'name': childEntryInfo['name'], 'type': _getEntityMimeType(childEntryInfo)}
          if childEntryInfo['mimeType'] == MIMETYPE_GA_FOLDER:
            _identifyFilesToTransfer(childEntry)

  def _identifyChildrenToTransfer(fileEntry, user, i, count):
    q = WITH_PARENTS.format(fileEntry['id'])
    setGettingAllEntityItemsForWhom(Ent.DRIVE_FILE_OR_FOLDER, user, query=q)
    pageMessage = getPageMessageForWhom(clearLastGotMsgLen=False)
    try:
      children = callGAPIpages(drive.files(), 'list', 'files',
                               pageMessage=pageMessage, noFinalize=True,
                               throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                               retryReasons=[GAPI.UNKNOWN_ERROR],
                               orderBy=OBY.orderBy, q=q,
                               fields='nextPageToken,files(id,name,parents,mimeType,ownedByMe,trashed)',
                               pageSize=GC.Values[GC.DRIVE_MAX_RESULTS])
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      return
    for childEntryInfo in children:
      childFileId = childEntryInfo['id']
      if filepath:
        fileTree[childFileId] = {'info': childEntryInfo}
      if childFileId in filesTransferred:
        continue
      filesTransferred.add(childFileId)
      if includeTrashed or not childEntryInfo['trashed']:
        if childEntryInfo['ownedByMe']:
          filesToTransfer[childFileId] = {'name': childEntryInfo['name'], 'type': _getEntityMimeType(childEntryInfo)}
        if childEntryInfo['mimeType'] == MIMETYPE_GA_FOLDER:
          _identifyChildrenToTransfer(childEntryInfo, user, i, count)

  fileIdEntity = getDriveFileEntity()
  body = {}
  newOwner = getEmailAddress()
  OBY = OrderBy(DRIVEFILE_ORDERBY_CHOICE_MAP)
  changeParents = enforceExpansiveAccess = filepath = includeTrashed = noRecursion = False
  pathDelimiter = '/'
  csvPF = fileTree = None
  addParents = ''
  parentBody = {}
  parentParms = initDriveFileAttributes()
  buildTree = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'includetrashed':
      includeTrashed = True
    elif myarg == 'norecursion':
      noRecursion = getBoolean()
    elif myarg == 'orderby':
      OBY.GetChoice()
    elif myarg == 'filepath':
      filepath = True
    elif myarg == 'pathdelimiter':
      pathDelimiter = getCharacter()
    elif myarg == 'buildtree':
      buildTree = True
    elif myarg == 'preview':
      csvPF = CSVPrintFile(['OldOwner', 'NewOwner', 'type', 'id', 'name'])
    elif csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif getDriveFileParentAttribute(myarg, parentParms):
      changeParents = True
    elif myarg == 'enforceexpansiveaccess':
      enforceExpansiveAccess = getBoolean()
    else:
      unknownArgumentExit()
  Act.Set(Act.TRANSFER_OWNERSHIP)
  targetDrive, permissionId = validateUserGetPermissionId(newOwner)
  if not permissionId:
    return
  if changeParents:
    if not _getDriveFileParentInfo(targetDrive, newOwner, 0, 0, parentBody, parentParms):
      return
    addParents = ','.join(parentBody['parents'])
  if csvPF:
    if filepath:
      csvPF.AddTitles('paths')
  else:
    filepath = False
  body = {'role': 'owner'}
  bodyAdd = {'role': 'writer', 'type': 'user', 'emailAddress': newOwner}
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity, entityType=Ent.DRIVE_FILE_OR_FOLDER)
    if jcount == 0:
      continue
    if filepath:
      filePathInfo = initFilePathInfo(pathDelimiter)
    filesTransferred = set()
    if buildTree:
      printGettingAllEntityItemsForWhom(Ent.DRIVE_FILE_OR_FOLDER, user, i, count)
      try:
        feed = callGAPIpages(drive.files(), 'list', 'files',
                             pageMessage=getPageMessageForWhom(),
                             throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                             retryReasons=[GAPI.UNKNOWN_ERROR],
                             orderBy=OBY.orderBy,
                             fields='nextPageToken,files(id,name,parents,mimeType,ownedByMe,trashed)',
                             pageSize=GC.Values[GC.DRIVE_MAX_RESULTS])
        fileTree = buildFileTree(feed, drive)
        del feed
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        continue
    else:
      fileTree = {}
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      kvList = [Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER, fileId]
      if buildTree:
        fileEntry = fileTree.get(fileId)
        if not fileEntry:
          entityActionFailedWarning(kvList, Msg.NOT_FOUND, j, jcount)
          continue
        fileEntryInfo = fileEntry['info']
      else:
        try:
          fileEntryInfo = callGAPI(drive.files(), 'get',
                                   throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                                   fileId=fileId, fields='id,name,parents,mimeType,ownedByMe,trashed,shortcutDetails')
        except GAPI.fileNotFound:
          entityActionFailedWarning(kvList, Msg.NOT_FOUND, j, jcount)
          continue
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          userDriveServiceNotEnabledWarning(user, str(e), i, count)
          break
        if filepath:
          fileTree[fileId] = {'info': fileEntryInfo}
      entityType = _getEntityMimeType(fileEntryInfo)
      entityPerformActionItemValue([Ent.USER, user], entityType, f'{fileEntryInfo["name"]} ({fileId})', j, jcount)
      if fileId in filesTransferred:
        continue
      filesTransferred.add(fileId)
      filesToTransfer = {}
      if includeTrashed or not fileEntryInfo['trashed']:
        if fileEntryInfo['ownedByMe'] and fileEntryInfo['name'] != MY_DRIVE:
          filesToTransfer[fileId] = {'name': fileEntryInfo['name'], 'type': entityType}
          if changeParents:
            filesToTransfer[fileId]['addParents'] = addParents
            filesToTransfer[fileId]['removeParents'] = ','.join(fileEntryInfo.get('parents', []))
          if fileEntryInfo['mimeType'] == MIMETYPE_GA_SHORTCUT and entityType != Ent.DRIVE_SHORTCUT:
            filesToTransfer[fileId]['shortcutDetails'] = fileEntryInfo['shortcutDetails']
        if fileEntryInfo['mimeType'] == MIMETYPE_GA_FOLDER and not noRecursion:
          if buildTree:
            _identifyFilesToTransfer(fileEntry)
          else:
            _identifyChildrenToTransfer(fileEntryInfo, user, i, count)
      if csvPF:
        for xferFileId, fileInfo in iter(filesToTransfer.items()):
          row = {'OldOwner': user, 'NewOwner': newOwner, 'type': Ent.Singular(fileInfo['type']), 'id': xferFileId, 'name': fileInfo['name']}
          if filepath:
            addFilePathsToRow(drive, fileTree, fileTree[xferFileId]['info'], filePathInfo, csvPF, row)
          csvPF.WriteRow(row)
        continue
      Ind.Increment()
      kcount = len(filesToTransfer)
      entityPerformActionNumItemsModifier([Ent.USER, user], kcount, Ent.DRIVE_FILE_OR_FOLDER, f'{Act.MODIFIER_TO} {Ent.Singular(Ent.USER)}: {newOwner}', i, count)
      Ind.Increment()
      k = 0
      for xferFileId, fileInfo in iter(filesToTransfer.items()):
        k += 1
        entityType = fileInfo['type']
        fileDesc = f'{fileInfo["name"]} ({xferFileId})'
        kvList = [Ent.USER, user, entityType, fileDesc]
        try:
          if entityType not in {Ent.DRIVE_SHORTCUT, Ent.DRIVE_FILE_SHORTCUT, Ent.DRIVE_FOLDER_SHORTCUT}:
            if changeParents:
              removeParents = fileInfo.get('removeParents', '')
              if removeParents:
                callGAPI(drive.files(), 'update',
                         throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS,
                         fileId=xferFileId, removeParents=removeParents, fields='', supportsAllDrives=True)
                action = Act.Get()
                Act.Set(Act.REMOVE)
                entityModifierNewValueItemValueListActionPerformed(kvList, Act.MODIFIER_FROM, None, [Ent.DRIVE_FOLDER, fileInfo['removeParents']], k, kcount)
                Act.Set(action)
            callGAPI(drive.permissions(), 'update',
                     throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.PERMISSION_NOT_FOUND],
                     enforceExpansiveAccess=enforceExpansiveAccess,
                     fileId=xferFileId, permissionId=permissionId, transferOwnership=True, body=body, fields='')
            entityModifierNewValueItemValueListActionPerformed(kvList, Act.MODIFIER_TO, None, [Ent.USER, newOwner], k, kcount)
          else:
            if changeParents and entityType != Ent.DRIVE_SHORTCUT:
              callGAPI(drive.files(), 'delete',
                       throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS,
                       fileId=xferFileId, supportsAllDrives=True)
              action = Act.Get()
              Act.Set(Act.DELETE_SHORTCUT)
              entityActionPerformed(kvList, k, kcount)
              Act.Set(action)
            else:
              entityModifierNewValueItemValueListActionPerformed(kvList, Act.MODIFIER_TO, None, [Ent.USER, newOwner], k, kcount)
        except GAPI.permissionNotFound:
          # this might happen if target user isn't explicitly in ACL (i.e. shared with anyone)
          try:
            callGAPI(drive.permissions(), 'create',
                     throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.INVALID_SHARING_REQUEST],
                     fileId=xferFileId, sendNotificationEmail=False, body=bodyAdd, fields='')
            callGAPI(drive.permissions(), 'update',
                     throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.PERMISSION_NOT_FOUND],
                     enforceExpansiveAccess=enforceExpansiveAccess,
                     fileId=xferFileId, permissionId=permissionId, transferOwnership=True, body=body, fields='')
            entityModifierNewValueItemValueListActionPerformed(kvList, Act.MODIFIER_TO, None, [Ent.USER, newOwner], k, kcount)
          except GAPI.invalidSharingRequest as e:
            entityActionFailedWarning(kvList, Ent.TypeNameMessage(Ent.PERMISSION_ID, permissionId, str(e)), k, kcount)
            continue
          except GAPI.permissionNotFound:
            entityDoesNotHaveItemWarning(kvList+[Ent.PERMISSION_ID, permissionId], k, kcount)
            continue
          except GAPI.fileNotFound:
            entityActionFailedWarning(kvList, Msg.DOES_NOT_EXIST, k, kcount)
            continue
          except (GAPI.forbidden, GAPI.insufficientFilePermissions) as e:
            entityActionFailedWarning(kvList, str(e), k, kcount)
            continue
          except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
            userDriveServiceNotEnabledWarning(user, str(e), i, count)
            continue
        except GAPI.fileNotFound:
          entityActionFailedWarning(kvList, Msg.DOES_NOT_EXIST, k, kcount)
          continue
        except (GAPI.forbidden, GAPI.insufficientFilePermissions) as e:
          entityActionFailedWarning(kvList, str(e), k, kcount)
          continue
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          userDriveServiceNotEnabledWarning(user, str(e), i, count)
          break
        kvList = [Ent.USER, newOwner, entityType, fileDesc]
        try:
          if entityType not in {Ent.DRIVE_SHORTCUT, Ent.DRIVE_FILE_SHORTCUT, Ent.DRIVE_FOLDER_SHORTCUT}:
            if changeParents and 'addParents' in fileInfo:
              if entityType != Ent.DRIVE_FILE_SHORTCUT:
                callGAPI(targetDrive.files(), 'update',
                         throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.CANNOT_ADD_PARENT, GAPI.INSUFFICIENT_PARENT_PERMISSIONS],
                         fileId=xferFileId, addParents=fileInfo['addParents'], fields='', supportsAllDrives=True)
                action = Act.Get()
                Act.Set(Act.ADD)
                entityModifierNewValueItemValueListActionPerformed(kvList, Act.MODIFIER_TO, None, [Ent.DRIVE_FOLDER, fileInfo['addParents']], k, kcount)
                Act.Set(action)
          else:
            if changeParents and 'addParents' in fileInfo and entityType != Ent.DRIVE_SHORTCUT:
              body = {'name': fileInfo['name'], 'mimeType': MIMETYPE_GA_SHORTCUT,
                      'parents': [fileInfo['addParents']], 'shortcutDetails': {'targetId': fileInfo['shortcutDetails']['targetId']}}
              callGAPI(targetDrive.files(), 'create',
                       throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                   GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND, GAPI.UNKNOWN_ERROR,
                                                                   GAPI.STORAGE_QUOTA_EXCEEDED, GAPI.TEAMDRIVES_SHARING_RESTRICTION_NOT_ALLOWED,
                                                                   GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP, GAPI.SHORTCUT_TARGET_INVALID,
                                                                   GAPI.TARGET_USER_ROLE_LIMITED_BY_LICENSE_RESTRICTION],
                                body=body, fields='id', supportsAllDrives=True)
              Act.Set(Act.CREATE_SHORTCUT)
              entityModifierNewValueItemValueListActionPerformed(kvList, Act.MODIFIER_IN, None, [Ent.DRIVE_FOLDER, fileInfo['addParents']], k, kcount)
              Act.Set(action)
        except GAPI.fileNotFound:
          entityActionFailedWarning(kvList, Msg.DOES_NOT_EXIST, k, kcount)
        except (GAPI.forbidden, GAPI.cannotAddParent, GAPI.insufficientPermissions, GAPI.insufficientParentPermissions,
                GAPI.invalid, GAPI.badRequest, GAPI.unknownError) as e:
          entityActionFailedWarning(kvList, str(e), k, kcount)
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          userDriveServiceNotEnabledWarning(newOwner, str(e), 0, 0)
      Ind.Decrement()
      Ind.Decrement()
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('Files to Transfer Ownership')

# gam <UserTypeEntity> claim ownership <DriveFileEntity>
#	[<DriveFileParentAttribute>] [includetrashed]
#	[skipids <DriveFileEntity>] [onlyUsers|skipusers <UserTypeEntity>] [subdomains <DomainNameEntity>]
#	[restricted [<Boolean>]] [writerscanshare|writerscantshare [<Boolean>]]
#	[keepuser | (retainrole reader|commenter|writer|editor|none)] [noretentionmessages]
#	(orderby <DriveFileOrderByFieldName> [ascending|descending])*
#	[preview] [filepath] [pathdelimiter <Character>] [buildtree]
#	[enforceexpansiveaccess [<Boolean>]]
#	[todrive <ToDriveAttribute>*]
def claimOwnership(users):
  def _identifyFilesToClaim(fileEntry):
    for childFileId in fileEntry['children']:
      if childFileId in filesTransferred or childFileId in skipFileIdEntity['list']:
        continue
      filesTransferred.add(childFileId)
      childEntry = fileTree.get(childFileId)
      if childEntry:
        childEntryInfo = childEntry['info']
        if includeTrashed or not childEntryInfo['trashed']:
          owner = childEntryInfo['owners'][0]['emailAddress']
          if (not childEntryInfo['ownedByMe']) and ((not checkOwner) or (checkOnly and owner in onlyOwners) or (checkSkip and owner not in skipOwners)):
            oldOwnerPermissionIds[owner] = childEntryInfo['owners'][0]['permissionId']
            filesToClaim.setdefault(owner, {})
            if childFileId not in filesToClaim[owner]:
              filesToClaim[owner][childFileId] = {'name': childEntryInfo['name'], 'type': _getEntityMimeType(childEntryInfo)}
          if childEntryInfo['mimeType'] == MIMETYPE_GA_FOLDER:
            _identifyFilesToClaim(childEntry)

  def _identifyChildrenToClaim(fileEntry, user, i, count):
    q = WITH_PARENTS.format(fileEntry['id'])
    setGettingAllEntityItemsForWhom(Ent.DRIVE_FILE_OR_FOLDER, user, query=q)
    pageMessage = getPageMessageForWhom(clearLastGotMsgLen=False)
    try:
      children = callGAPIpages(drive.files(), 'list', 'files',
                               pageMessage=pageMessage, noFinalize=True,
                               throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                               retryReasons=[GAPI.UNKNOWN_ERROR],
                               orderBy=OBY.orderBy, q=q,
                               fields='nextPageToken,files(id,name,parents,mimeType,ownedByMe,trashed,owners(emailAddress,permissionId))',
                               pageSize=GC.Values[GC.DRIVE_MAX_RESULTS])
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      return
    for childEntryInfo in children:
      childFileId = childEntryInfo['id']
      if childFileId in filesTransferred or childFileId in skipFileIdEntity['list']:
        continue
      filesTransferred.add(childFileId)
      if includeTrashed or not childEntryInfo['trashed']:
        if filepath:
          fileTree[childFileId] = {'info': childEntryInfo}
        owner = childEntryInfo['owners'][0]['emailAddress']
        if (not childEntryInfo['ownedByMe']) and ((not checkOwner) or (checkOnly and owner in onlyOwners) or (checkSkip and owner not in skipOwners)):
          oldOwnerPermissionIds[owner] = childEntryInfo['owners'][0]['permissionId']
          filesToClaim.setdefault(owner, {})
          if childFileId not in filesToClaim[owner]:
            filesToClaim[owner][childFileId] = {'name': childEntryInfo['name'], 'type': _getEntityMimeType(childEntryInfo)}
        if childEntryInfo['mimeType'] == MIMETYPE_GA_FOLDER:
          _identifyChildrenToClaim(childEntryInfo, user, i, count)

  def _processRetainedRole(user, i, count, oldOwner, entityType, ofileId, fileDesc, l, lcount):
    oldOwnerPermissionId = oldOwnerPermissionIds[oldOwner]
    Act.Set(Act.RETAIN)
    try:
      if sourceRetainRoleBody['role'] != 'none':
        if sourceRetainRoleBody['role'] != 'writer':
          callGAPI(sourceDrive.permissions(), 'update',
                   throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.PERMISSION_NOT_FOUND, GAPI.BAD_REQUEST],
                   enforceExpansiveAccess=enforceExpansiveAccess,
                   fileId=ofileId, permissionId=oldOwnerPermissionId, body=sourceRetainRoleBody, fields='')
      else:
        callGAPI(sourceDrive.permissions(), 'delete',
                 throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.PERMISSION_NOT_FOUND, GAPI.BAD_REQUEST],
                 fileId=ofileId, permissionId=oldOwnerPermissionId)
      if showRetentionMessages:
        entityActionPerformed([Ent.USER, oldOwner, entityType, fileDesc, Ent.ROLE, sourceRetainRoleBody['role']], l, lcount)
    except GAPI.permissionNotFound:
      entityDoesNotHaveItemWarning([Ent.USER, oldOwner, entityType, fileDesc, Ent.PERMISSION_ID, oldOwnerPermissionId], l, lcount)
    except (GAPI.badRequest, GAPI.insufficientFilePermissions, GAPI.cannotDeletePermission) as e:
      entityActionFailedWarning([Ent.USER, oldOwner, entityType, fileDesc], str(e), l, lcount)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
    Act.Set(Act.CLAIM_OWNERSHIP)

  fileIdEntity = getDriveFileEntity()
  skipFileIdEntity = initDriveFileEntity()
  OBY = OrderBy(DRIVEFILE_ORDERBY_CHOICE_MAP)
  body = {}
  checkOnly = checkSkip = False
  onlyOwners = set()
  skipOwners = set()
  subdomains = []
  enforceExpansiveAccess = filepath = includeTrashed = False
  pathDelimiter = '/'
  addParents = ''
  parentBody = {}
  parentParms = initDriveFileAttributes()
  sourceRetainRoleBody = {'role': 'writer'}
  showRetentionMessages = True
  oldOwnerPermissionIds = {}
  csvPF = fileTree = None
  buildTree = changeParents = False
  bodyShare = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'keepuser':
      sourceRetainRoleBody['role'] = 'writer'
    elif myarg == 'retainrole':
      sourceRetainRoleBody['role'] = getChoice(TRANSFER_DRIVEFILE_ACL_ROLES_MAP, mapChoice=True)
    elif myarg == 'noretentionmessages':
      showRetentionMessages = False
    elif myarg == 'skipids':
      skipFileIdEntity = getDriveFileEntity()
    elif myarg == 'onlyusers':
      _, userList = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)
      checkOnly = True
      onlyOwners = set(userList)
    elif myarg == 'skipusers':
      _, userList = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)
      checkSkip = len(userList) > 0
      skipOwners = set(userList)
    elif myarg == 'subdomains':
      subdomains = getEntityList(Cmd.OB_DOMAIN_NAME_ENTITY)
    elif myarg == 'includetrashed':
      includeTrashed = True
    elif myarg == 'orderby':
      OBY.GetChoice()
    elif myarg == 'enforceexpansiveaccess':
      enforceExpansiveAccess = getBoolean()
    elif myarg == 'restricted':
      bodyShare['copyRequiresWriterPermission'] = getBoolean()
    elif myarg == 'writerscanshare':
      bodyShare['writersCanShare'] = getBoolean()
    elif myarg == 'writerscantshare':
      bodyShare['writersCanShare'] = not getBoolean()
    elif myarg == 'filepath':
      filepath = True
    elif myarg == 'pathdelimiter':
      pathDelimiter = getCharacter()
    elif myarg == 'buildtree':
      buildTree = True
    elif myarg == 'preview':
      csvPF = CSVPrintFile(['OldOwner', 'NewOwner', 'type', 'id', 'name'])
    elif csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif getDriveFileParentAttribute(myarg, parentParms):
      changeParents = True
    else:
      unknownArgumentExit()
  if checkOnly and checkSkip:
    usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format('onlyusers', 'skipusers'))
  checkOwner = checkOnly or checkSkip
  Act.Set(Act.CLAIM_OWNERSHIP)
  if csvPF:
    if filepath:
      csvPF.AddTitles('paths')
  else:
    filepath = False
  body = {'role': 'owner'}
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity)
    if not drive:
      continue
    _, permissionId = validateUserGetPermissionId(user, i, count, drive)
    if not permissionId:
      continue
    if changeParents:
      if not _getDriveFileParentInfo(drive, user, i, count, parentBody, parentParms):
        return
      addParents = ','.join(parentBody['parents'])
    entityPerformActionNumItems([Ent.USER, user], jcount, Ent.DRIVE_FILE_OR_FOLDER, i, count)
    if jcount == 0:
      continue
    if filepath:
      filePathInfo = initFilePathInfo(pathDelimiter)
    filesTransferred = set()
    bodyAdd = {'role': 'writer', 'type': 'user', 'emailAddress': user}
    if skipFileIdEntity['query'] or skipFileIdEntity[ROOT]:
      _validateUserGetFileIDs(origUser, i, count, skipFileIdEntity, drive=drive)
    if buildTree:
      printGettingAllEntityItemsForWhom(Ent.DRIVE_FILE_OR_FOLDER, user, i, count)
      try:
        feed = callGAPIpages(drive.files(), 'list', 'files',
                             pageMessage=getPageMessageForWhom(),
                             throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                             retryReasons=[GAPI.UNKNOWN_ERROR],
                             orderBy=OBY.orderBy,
                             fields='nextPageToken,files(id,name,parents,mimeType,ownedByMe,trashed,owners(emailAddress,permissionId))',
                             pageSize=GC.Values[GC.DRIVE_MAX_RESULTS])
        fileTree = buildFileTree(feed, drive)
        del feed
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        continue
    else:
      fileTree = {}
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      filesToClaim = {}
      kvList = [Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER, fileId]
      if buildTree:
        fileEntry = fileTree.get(fileId)
        if not fileEntry:
          entityActionFailedWarning(kvList, Msg.NOT_FOUND, j, jcount)
          continue
        fileEntryInfo = fileEntry['info']
      else:
        try:
          fileEntryInfo = callGAPI(drive.files(), 'get',
                                   throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                                   fileId=fileId,
                                   fields='id,name,parents,mimeType,ownedByMe,trashed,shortcutDetails,owners(emailAddress,permissionId)')
        except GAPI.fileNotFound:
          entityActionFailedWarning(kvList, Msg.NOT_FOUND, j, jcount)
          continue
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          userDriveServiceNotEnabledWarning(user, str(e), i, count)
          break
        if filepath:
          fileTree[fileId] = {'info': fileEntryInfo}
      entityType = _getEntityMimeType(fileEntryInfo)
      if fileId in skipFileIdEntity['list']:
        entityActionNotPerformedWarning([Ent.USER, user, entityType, f'{fileEntryInfo["name"]} ({fileId})'],
                                        Msg.IN_SKIPIDS, j, jcount)
        continue
      entityPerformActionItemValue([Ent.USER, user], entityType, f'{fileEntryInfo["name"]} ({fileId})', j, jcount)
      if fileId in filesTransferred:
        continue
      filesTransferred.add(fileId)
      if fileId not in skipFileIdEntity['list'] and (includeTrashed or not fileEntryInfo['trashed']):
        owner = fileEntryInfo['owners'][0]['emailAddress']
        if (not fileEntryInfo['ownedByMe']) and ((not checkOwner) or (checkOnly and owner in onlyOwners) or (checkSkip and owner not in skipOwners)):
          oldOwnerPermissionIds[owner] = fileEntryInfo['owners'][0]['permissionId']
          filesToClaim.setdefault(owner, {})
          if fileId not in filesToClaim[owner]:
            filesToClaim[owner][fileId] = {'name': fileEntryInfo['name'], 'type': entityType}
            if changeParents:
              filesToClaim[owner][fileId]['addParents'] = addParents
              filesToClaim[owner][fileId]['removeParents'] = ','.join(fileEntryInfo.get('parents', []))
            if fileEntryInfo['mimeType'] == MIMETYPE_GA_SHORTCUT and entityType != Ent.DRIVE_SHORTCUT:
              filesToClaim[owner][fileId]['shortcutDetails'] = fileEntryInfo['shortcutDetails']
        if fileEntryInfo['mimeType'] == MIMETYPE_GA_FOLDER:
          if buildTree:
            _identifyFilesToClaim(fileEntry)
          else:
            _identifyChildrenToClaim(fileEntryInfo, user, i, count)
      if csvPF:
        for oldOwner in filesToClaim:
          for claimFileId, fileInfo in iter(filesToClaim[oldOwner].items()):
            row = {'NewOwner': user, 'OldOwner': oldOwner, 'type': Ent.Singular(fileInfo['type']), 'id': claimFileId, 'name': fileInfo['name']}
            if filepath:
              addFilePathsToRow(drive, fileTree, fileTree[claimFileId]['info'], filePathInfo, csvPF, row)
            csvPF.WriteRow(row)
        continue
      Ind.Increment()
      kcount = len(filesToClaim)
      entityPerformActionNumItems([Ent.USER, user], kcount, Ent.USER, i, count)
      Ind.Increment()
      k = 0
      for oldOwner in filesToClaim:
        k += 1
        _, userDomain = splitEmailAddress(oldOwner)
        lcount = len(filesToClaim[oldOwner])
        if userDomain == GC.Values[GC.DOMAIN] or userDomain in subdomains:
          _, sourceDrive = buildGAPIServiceObject(API.DRIVE3, oldOwner, k, kcount)
          if not sourceDrive:
            continue
          entityPerformActionNumItemsModifier([Ent.USER, user], lcount, Ent.DRIVE_FILE_OR_FOLDER,
                                              f'{Act.MODIFIER_FROM} {Ent.Singular(Ent.USER)}: {oldOwner}', k, kcount)
          Ind.Increment()
          l = 0
          for xferFileId, fileInfo in iter(filesToClaim[oldOwner].items()):
            l += 1
            entityType = fileInfo['type']
            fileDesc = f'{fileInfo["name"]} ({xferFileId})'
            kvList = [Ent.USER, oldOwner, entityType, fileDesc]
            try:
              if entityType not in {Ent.DRIVE_SHORTCUT, Ent.DRIVE_FILE_SHORTCUT, Ent.DRIVE_FOLDER_SHORTCUT}:
                if bodyShare:
                  callGAPI(sourceDrive.files(), 'update',
                           fileId=xferFileId, body=bodyShare, fields='')
                if changeParents:
                  removeParents = fileInfo.get('removeParents', '')
                  if removeParents:
                    callGAPI(sourceDrive.files(), 'update',
                             throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS,
                             fileId=xferFileId, removeParents=removeParents, fields='', supportsAllDrives=True)
                    action = Act.Get()
                    Act.Set(Act.REMOVE)
                    entityModifierNewValueItemValueListActionPerformed(kvList, Act.MODIFIER_FROM, None,
                                                                       [Ent.DRIVE_FOLDER, removeParents], l, lcount)
                    Act.Set(action)
                callGAPI(sourceDrive.permissions(), 'update',
                         throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.PERMISSION_NOT_FOUND],
                         enforceExpansiveAccess=enforceExpansiveAccess,
                         fileId=xferFileId, permissionId=permissionId, transferOwnership=True, body=body, fields='')
                kvList = [Ent.USER, user, entityType, fileDesc]
                entityModifierNewValueItemValueListActionPerformed(kvList, Act.MODIFIER_FROM, None, [Ent.USER, oldOwner], l, lcount)
                _processRetainedRole(user, i, count, oldOwner, entityType, xferFileId, fileDesc, l, lcount)
              else:
                if changeParents and entityType != Ent.DRIVE_SHORTCUT:
                  callGAPI(sourceDrive.files(), 'delete',
                           throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS,
                           fileId=xferFileId, supportsAllDrives=True)
                  action = Act.Get()
                  Act.Set(Act.DELETE_SHORTCUT)
                  entityActionPerformed(kvList, l, lcount)
                  Act.Set(action)
                else:
                  kvList = [Ent.USER, user, entityType, fileDesc]
                  entityModifierNewValueItemValueListActionPerformed(kvList, Act.MODIFIER_FROM, None, [Ent.USER, oldOwner], l, lcount)
            except GAPI.permissionNotFound:
              # if claimer not in ACL (file might be visible for all with link)
              try:
                callGAPI(sourceDrive.permissions(), 'create',
                         throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.INVALID_SHARING_REQUEST],
                         fileId=xferFileId, sendNotificationEmail=False, body=bodyAdd, fields='')
                callGAPI(sourceDrive.permissions(), 'update',
                         throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.PERMISSION_NOT_FOUND],
                         enforceExpansiveAccess=enforceExpansiveAccess,
                         fileId=xferFileId, permissionId=permissionId, transferOwnership=True, body=body, fields='')
                entityModifierNewValueItemValueListActionPerformed(kvList, Act.MODIFIER_FROM, None, [Ent.USER, oldOwner], l, lcount)
                _processRetainedRole(user, i, count, oldOwner, entityType, xferFileId, fileDesc, l, lcount)
              except GAPI.invalidSharingRequest as e:
                entityActionFailedWarning(kvList, Ent.TypeNameMessage(Ent.PERMISSION_ID, permissionId, str(e)), l, lcount)
                continue
              except GAPI.permissionNotFound:
                entityDoesNotHaveItemWarning(kvList+[Ent.PERMISSION_ID, permissionId], l, lcount)
                continue
              except GAPI.fileNotFound:
                entityActionFailedWarning(kvList, Msg.DOES_NOT_EXIST, l, lcount)
                continue
              except (GAPI.forbidden, GAPI.insufficientFilePermissions) as e:
                entityActionFailedWarning(kvList, str(e), l, lcount)
                continue
              except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
                userDriveServiceNotEnabledWarning(user, str(e), i, count)
                continue
            except GAPI.fileNotFound:
              entityActionFailedWarning(kvList, Msg.DOES_NOT_EXIST, l, lcount)
              continue
            except (GAPI.forbidden, GAPI.insufficientFilePermissions) as e:
              entityActionFailedWarning(kvList, str(e), l, lcount)
              continue
            except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
              userDriveServiceNotEnabledWarning(user, str(e), i, count)
              break
            kvList = [Ent.USER, user, entityType, fileDesc]
            try:
              if entityType not in {Ent.DRIVE_SHORTCUT, Ent.DRIVE_FILE_SHORTCUT, Ent.DRIVE_FOLDER_SHORTCUT}:
                if changeParents and 'addParents' in fileInfo:
                  callGAPI(drive.files(), 'update',
                           throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.CANNOT_ADD_PARENT, GAPI.INSUFFICIENT_PARENT_PERMISSIONS],
                           fileId=xferFileId, addParents=fileInfo['addParents'], fields='', supportsAllDrives=True)
                  action = Act.Get()
                  Act.Set(Act.ADD)
                  entityModifierNewValueItemValueListActionPerformed(kvList, Act.MODIFIER_TO, None, [Ent.DRIVE_FOLDER, fileInfo['addParents']], l, lcount)
                  Act.Set(action)
              else:
                if changeParents and 'addParents' in fileInfo and entityType != Ent.DRIVE_SHORTCUT:
                  body = {'name': fileInfo['name'], 'mimeType': MIMETYPE_GA_SHORTCUT,
                          'parents': [fileInfo['addParents']], 'shortcutDetails': {'targetId': fileInfo['shortcutDetails']['targetId']}}
                  callGAPI(drive.files(), 'create',
                           throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                       GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.FILE_NOT_FOUND, GAPI.UNKNOWN_ERROR,
                                                                       GAPI.STORAGE_QUOTA_EXCEEDED, GAPI.TEAMDRIVES_SHARING_RESTRICTION_NOT_ALLOWED,
                                                                       GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP, GAPI.SHORTCUT_TARGET_INVALID,
                                                                       GAPI.TARGET_USER_ROLE_LIMITED_BY_LICENSE_RESTRICTION],
                           body=body, fields='id', supportsAllDrives=True)
                  Act.Set(Act.CREATE_SHORTCUT)
                  entityModifierNewValueItemValueListActionPerformed(kvList, Act.MODIFIER_IN, None, [Ent.DRIVE_FOLDER, fileInfo['addParents']], l, lcount)
                  Act.Set(action)
            except GAPI.fileNotFound:
              entityActionFailedWarning(kvList, Msg.DOES_NOT_EXIST, l, lcount)
            except (GAPI.forbidden, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions, GAPI.cannotAddParent) as e:
              entityActionFailedWarning(kvList, str(e), l, lcount)
            except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
              userDriveServiceNotEnabledWarning(user, str(e), i, count)
          Ind.Decrement()
        else:
          entityPerformActionModifierNumItemsModifier([Ent.USER, user], 'Not Performed', kcount, Ent.DRIVE_FILE_OR_FOLDER,
                                                      f'{Act.MODIFIER_FROM} {Ent.Singular(Ent.USER)}: {oldOwner}', j, jcount)
          Ind.Increment()
          l = 0
          for xferFileId, fileInfo in iter(filesToClaim[oldOwner].items()):
            l += 1
            entityActionNotPerformedWarning([Ent.USER, user, fileInfo['type'], f'{fileInfo["name"]} ({xferFileId})'],
                                            Msg.USER_IN_OTHER_DOMAIN.format(Ent.Singular(Ent.USER), oldOwner), l, lcount)
          Ind.Decrement()
      Ind.Decrement()
      Ind.Decrement()
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('Files to Claim Ownership')

# gam <UserTypeEntity> print emptydrivefolders [todrive <ToDriveAttribute>*]
#	[select <DriveFileEntity>]
#	[pathdelimiter <Character>]
def printEmptyDriveFolders(users):
  def _checkChildDriveFolderContents(drive, fileEntry, user, i, count, pathList):
    query = WITH_PARENTS.format(fileEntry ['id'])
    try:
      children = callGAPIpages(drive.files(), 'list', 'files',
                               throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID,
                                                                           GAPI.BAD_REQUEST],
                               retryReasons=[GAPI.UNKNOWN_ERROR],
                               q=query, fields='nextPageToken,files(id,name,mimeType,ownedByMe)',
                               pageSize=GC.Values[GC.DRIVE_MAX_RESULTS], **fileIdEntity['shareddrive'])
      if not children:
        if sharedDriveId or fileEntry.get('ownedByMe', False):
          row = {'User': user, 'id': fileEntry['id'], 'name': pathDelimiter.join(pathList)}
          if sharedDriveId:
            row['driveId'] = sharedDriveId
          csvPF.WriteRow(row)
        return
      for childEntryInfo in children:
        if childEntryInfo['mimeType'] == MIMETYPE_GA_FOLDER:
          _checkChildDriveFolderContents(drive, childEntryInfo, user, i, count, pathList+[childEntryInfo['name']])
    except (GAPI.invalidQuery, GAPI.invalid, GAPI.badRequest):
      entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE, None], invalidQuery(query), i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)

  csvPF = CSVPrintFile(['User', 'id', 'name']) if Act.csvFormat() else None
  fileIdEntity = {}
  pathDelimiter = '/'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'select':
      DLP = DriveListParameters({'allowChoose': False, 'allowCorpora': False, 'allowQuery': False, 'mimeTypeInQuery': True})
      fileIdEntity = getDriveFileEntity(DLP=DLP)
    elif myarg == 'pathdelimiter':
      pathDelimiter = getCharacter()
    else:
      unknownArgumentExit()
  if not fileIdEntity:
    fileIdEntity = initDriveFileEntity()
    cleanFileIDsList(fileIdEntity, [ROOT])
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = _validateUserSharedDrive(user, i, count, fileIdEntity)
    if not drive:
      continue
    fileId = sharedDriveId = fileIdEntity.get('shareddrive', {}).get('driveId', '')
    if not sharedDriveId:
      fileId = fileIdEntity['list'][0]
    try:
      printGettingAllEntityItemsForWhom(Ent.DRIVE_FOLDER, user, i, count)
      fileEntryInfo = callGAPI(drive.files(), 'get',
                               throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FILE_NOT_FOUND, GAPI.NOT_FOUND, GAPI.TEAMDRIVE_MEMBERSHIP_REQUIRED],
                               retryReasons=[GAPI.UNKNOWN_ERROR],
                               fileId=fileId, fields='id,name,mimeType,ownedByMe,driveId', supportsAllDrives=True)
      if 'driveId' in fileEntryInfo:
        sharedDriveId = fileEntryInfo['driveId']
        fileIdEntity['shareddrive'] = {'driveId': sharedDriveId, 'corpora': 'drive', 'includeItemsFromAllDrives': True, 'supportsAllDrives': True}
        csvPF.AddTitles(['driveId'])
        csvPF.MoveTitlesToEnd(['name'])
        pathList = [f'{SHARED_DRIVES}/{_getSharedDriveNameFromId(drive, sharedDriveId)}']
      else:
        pathList = [fileEntryInfo['name']]
      mimeType = fileEntryInfo['mimeType']
      if mimeType != MIMETYPE_GA_FOLDER:
        entityValueList = [Ent.USER, user, _getEntityMimeType(fileEntryInfo), fileEntryInfo['name']]
        entityActionNotPerformedWarning(entityValueList, Msg.INVALID_MIMETYPE.format(mimeType, MIMETYPE_GA_FOLDER), i, count)
        continue
      _checkChildDriveFolderContents(drive, fileEntryInfo, user, i, count, pathList)
    except (GAPI.fileNotFound, GAPI.notFound) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FOLDER, fileId], str(e), i, count)
    except GAPI.teamDriveMembershipRequired as e:
      entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, fileId], str(e), i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
  if csvPF:
    csvPF.writeCSVfile('Empty Folders')

# gam <UserTypeEntity> delete emptydrivefolders
#	[select <DriveFileEntity>]
#	[<SharedDriveEntity>]
#	[pathdelimiter <Character>]
def deleteEmptyDriveFolders(users):
  def _deleteEmptyChildDriveFolders(drive, fileEntry, user, i, count, pathList, atTop):
    query = WITH_PARENTS.format(fileEntry ['id'])
    try:
      children = callGAPIpages(drive.files(), 'list', 'files',
                               throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID,
                                                                           GAPI.BAD_REQUEST],
                               retryReasons=[GAPI.UNKNOWN_ERROR],
                               q=query, fields='nextPageToken,files(id,name,mimeType,ownedByMe)',
                               pageSize=GC.Values[GC.DRIVE_MAX_RESULTS], **fileIdEntity['shareddrive'])
      numChildren = len(children)
      for childEntryInfo in children:
        if childEntryInfo['mimeType'] == MIMETYPE_GA_FOLDER:
          numChildren -= _deleteEmptyChildDriveFolders(drive, childEntryInfo, user, i, count, pathList+[childEntryInfo['name']], False)
      if numChildren == 0 and not atTop:
        try:
          callGAPI(drive.files(), 'delete',
                   throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS,
                   fileId=fileEntry['id'], supportsAllDrives=True)
          entityActionPerformed([Ent.USER, user, Ent.DRIVE_FOLDER_ID, fileEntry['id'],
                                 Ent.DRIVE_FOLDER, pathDelimiter.join(pathList)], i, count)
          return 1
        except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FOLDER, fileEntry['name']], str(e), i, count)
    except (GAPI.invalidQuery, GAPI.invalid, GAPI.badRequest):
      entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE, None], invalidQuery(query), i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
    return 0

  Act.Set(Act.DELETE_EMPTY)
  fileIdEntity = {}
  pathDelimiter = '/'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'select':
      DLP = DriveListParameters({'allowChoose': False, 'allowCorpora': False, 'allowQuery': False, 'mimeTypeInQuery': True})
      fileIdEntity = getDriveFileEntity(DLP=DLP)
    elif myarg == 'pathdelimiter':
      pathDelimiter = getCharacter()
    else:
      fileIdEntity = getSharedDriveEntity()
  if not fileIdEntity:
    fileIdEntity = initDriveFileEntity()
    cleanFileIDsList(fileIdEntity, [ROOT])
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = _validateUserSharedDrive(user, i, count, fileIdEntity)
    if not drive:
      continue
    printEntityKVList([Ent.USER, user],
                      [f'{Act.ToPerform()} {Ent.Plural(Ent.DRIVE_FILE_OR_FOLDER)}'],
                      i, count)
    Ind.Increment()
    fileId = sharedDriveId = fileIdEntity.get('shareddrive', {}).get('driveId', '')
    if not sharedDriveId:
      fileId = fileIdEntity['list'][0]
    try:
      printGettingAllEntityItemsForWhom(Ent.DRIVE_FOLDER, user, i, count)
      fileEntryInfo = callGAPI(drive.files(), 'get',
                               throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FILE_NOT_FOUND, GAPI.NOT_FOUND, GAPI.TEAMDRIVE_MEMBERSHIP_REQUIRED],
                               retryReasons=[GAPI.UNKNOWN_ERROR],
                               fileId=fileId, fields='id,name,mimeType,ownedByMe,driveId', supportsAllDrives=True)
      if 'driveId' in fileEntryInfo:
        sharedDriveId = fileEntryInfo['driveId']
        fileIdEntity['shareddrive'] = {'driveId': sharedDriveId, 'corpora': 'drive', 'includeItemsFromAllDrives': True, 'supportsAllDrives': True}
        pathList = [f'{SHARED_DRIVES}/{_getSharedDriveNameFromId(drive, sharedDriveId)}']
      else:
        pathList = [fileEntryInfo['name']]
      mimeType = fileEntryInfo['mimeType']
      if mimeType != MIMETYPE_GA_FOLDER:
        entityValueList = [Ent.USER, user, _getEntityMimeType(fileEntryInfo), fileEntryInfo['name']]
        entityActionNotPerformedWarning(entityValueList, Msg.INVALID_MIMETYPE.format(mimeType, MIMETYPE_GA_FOLDER), i, count)
        continue
      _deleteEmptyChildDriveFolders(drive, fileEntryInfo, user, i, count, pathList, True)
    except (GAPI.fileNotFound, GAPI.notFound) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FOLDER, fileId], str(e), i, count)
    except GAPI.teamDriveMembershipRequired as e:
      entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, fileId], str(e), i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
    Ind.Decrement()

# gam <UserTypeEntity> empty drivetrash [<SharedDriveEntity>]
def emptyDriveTrash(users):
  if Cmd.ArgumentsRemaining():
    fileIdEntity = getSharedDriveEntity()
  else:
    fileIdEntity = {}
  checkForExtraneousArguments()
  kwargs = {'driveId': None}
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    if not fileIdEntity:
      user, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
      if not drive:
        continue
    else:
      user, drive = _validateUserSharedDrive(user, i, count, fileIdEntity)
      if not drive:
        continue
      kwargs['driveId'] = fileIdEntity['shareddrive']['driveId']
    try:
      callGAPI(drive.files(), 'emptyTrash',
               throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.INSUFFICIENT_FILE_PERMISSIONS],
               **kwargs)
      entityActionPerformed([Ent.USER, user, Ent.DRIVE_TRASH, kwargs['driveId']], i, count)
    except (GAPI.notFound, GAPI.insufficientFilePermissions) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, kwargs['driveId']], str(e), i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)

def _getDriveFileACLPrintKeysTimeObjects():
  printKeys = ['id', 'type', 'emailAddress', 'domain', 'role', 'permissionDetails',
               'expirationTime', 'photoLink', 'allowFileDiscovery', 'deleted',
               'pendingOwner', 'view']
  timeObjects = ['expirationTime']
  if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES]:
    _mapDrive3TitlesToDrive2(printKeys, API.DRIVE3_TO_DRIVE2_FILES_FIELDS_MAP)
    _mapDrive3TitlesToDrive2(timeObjects, API.DRIVE3_TO_DRIVE2_FILES_FIELDS_MAP)
  return (printKeys, set(timeObjects))

# DriveFileACL commands utilities
def _showDriveFilePermissionJSON(user, fileId, fileName, createdTime, permission, timeObjects):
  _mapDrivePermissionNames(permission)
  row = {'Owner': user, 'id': fileId, 'permission': permission}
  if createdTime is not None:
    row['createdTime'] = createdTime
  if fileId != fileName:
    row['name'] = fileName
  printLine(json.dumps(cleanJSON(row, timeObjects=timeObjects), ensure_ascii=False, sort_keys=True))

def _showDriveFilePermissionsJSON(user, fileId, fileName, createdTime, permissions, timeObjects):
  for permission in permissions:
    _mapDrivePermissionNames(permission)
  row = {'Owner': user, 'id': fileId, 'permissions': permissions}
  if createdTime is not None:
    row['createdTime'] = createdTime
  if fileId != fileName:
    row['name'] = fileName
  printLine(json.dumps(cleanJSON(row, timeObjects=timeObjects), ensure_ascii=False, sort_keys=True))

def _showDriveFilePermission(permission, printKeys, timeObjects, i=0, count=0):
  if permission.get('displayName'):
    name = permission['displayName']
  elif 'id' in permission:
    if permission['id'] == 'anyone':
      name = 'Anyone'
    elif permission['id'] == 'anyoneWithLink':
      name = 'Anyone with Link'
    else:
      name = permission['id']
  else:
    name = 'Permission'
  _mapDrivePermissionNames(permission)
  printKeyValueListWithCount([name], i, count)
  Ind.Increment()
  for key in printKeys:
    value = permission.get(key)
    if value is None:
      continue
    if key == 'permissionDetails':
      printKeyValueList([key, ''])
      Ind.Increment()
      for detail in value:
        printKeyValueList(['role', detail['role']])
        Ind.Increment()
        printKeyValueList(['type', detail['permissionType']])
        if 'additionalRoles' in detail:
          printKeyValueList(['additionalRoles', ','.join(detail['additionalRoles'])])
        printKeyValueList(['inherited', detail['inherited']])
        if detail['inherited']:
          printKeyValueList(['inheritedFrom', detail.get('inheritedFrom', UNKNOWN)])
        Ind.Decrement()
      Ind.Decrement()
    elif key not in timeObjects:
      printKeyValueList([key, value])
    else:
      printKeyValueList([key, formatLocalTime(value)])
  Ind.Decrement()

def _showDriveFilePermissions(entityType, fileName, permissions, printKeys, timeObjects, j, jcount):
  kcount = len(permissions)
  entityPerformActionNumItems([entityType, fileName], kcount, Ent.PERMITTEE, j, jcount)
  Ind.Increment()
  k = 0
  for permission in permissions:
    k += 1
    _showDriveFilePermission(permission, printKeys, timeObjects, k, kcount)
  Ind.Decrement()

def _checkFileIdEntityDomainAccess(fileIdEntity, useDomainAdminAccess):
  if useDomainAdminAccess and fileIdEntity['nonDomainAdminAccess']:
    Cmd.SetLocation(fileIdEntity['location'])
    usageErrorExit(Msg.INVALID_FILE_SELECTION_WITH_ADMIN_ACCESS)

# gam [<UserTypeEntity>] create drivefileacl <DriveFileEntity> [asadmin]
#	anyone|(user <UserItem>)|(group <GroupItem>)|(domain <DomainName>)  (role <DriveFileACLRole>)]
#	[withlink|(allowfilediscovery|discoverable [<Boolean>])] [expiration <Time>]
#	(mappermissionsdomain <DomainName> <DomainName>)*
#	[moveToNewOwnersRoot [<Boolean>]]
#	[updatesheetprotectedranges  [<Boolean>]]
#	[sendemail] [emailmessage <String>]
#	[showtitles] [nodetails|(csv [todrive <ToDriveAttribute>*] [formatjson [quotechar <Character>]])]
def createDriveFileACL(users, useDomainAdminAccess=False):
  moveToNewOwnersRoot = False
  sendNotificationEmail = showTitles = _transferOwnership = updateSheetProtectedRanges = False
  roleLocation = withLinkLocation = expirationLocation = None
  emailMessage = None
  showDetails = True
  csvPF = None
  FJQC = FormatJSONQuoteChar(csvPF)
  fileNameTitle = 'title' if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES] else 'name'
  fileIdEntity = getDriveFileEntity()
  body = {}
  body['type'] = permType = getChoice(DRIVEFILE_ACL_PERMISSION_TYPES)
  if permType != 'anyone':
    if permType != 'domain':
      body['emailAddress'] = permissionId = getEmailAddress()
    else:
      body['domain'] = permissionId = getString(Cmd.OB_DOMAIN_NAME)
  else:
    permissionId = 'anyone'
  mapPermissionsDomains = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'withlink':
      withLinkLocation = Cmd.Location()
      body['allowFileDiscovery'] = False
    elif myarg in {'allowfilediscovery', 'discoverable'}:
      withLinkLocation = Cmd.Location()
      body['allowFileDiscovery'] = getBoolean()
    elif myarg == 'role':
      roleLocation = Cmd.Location()
      body['role'] = getChoice(DRIVEFILE_ACL_ROLES_MAP, mapChoice=True)
      if body['role'] == 'owner':
        sendNotificationEmail = _transferOwnership = True
    elif myarg == 'mappermissionsdomain':
      oldDomain = getString(Cmd.OB_DOMAIN_NAME).lower()
      mapPermissionsDomains[oldDomain] = getString(Cmd.OB_DOMAIN_NAME).lower()
    elif myarg == 'enforcesingleparent':
      deprecatedArgument(myarg)
    elif myarg == 'movetonewownersroot':
      moveToNewOwnersRoot = getBoolean()
    elif myarg in {'expiration', 'expires'}:
      expirationLocation = Cmd.Location()
      body['expirationTime'] = getTimeOrDeltaFromNow()
    elif myarg == 'sendemail':
      sendNotificationEmail = True
    elif myarg == 'emailmessage':
      sendNotificationEmail = True
      emailMessage = getString(Cmd.OB_STRING)
    elif myarg == 'showtitles':
      showTitles = True
    elif myarg == 'updatesheetprotectedranges':
      updateSheetProtectedRanges = getBoolean()
    elif myarg == 'nodetails':
      showDetails = False
    elif myarg == 'csv':
      csvPF = CSVPrintFile(['Owner', 'id'], 'sortall')
      FJQC.SetCsvPF(csvPF)
    elif csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in ADMIN_ACCESS_OPTIONS:
      useDomainAdminAccess = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg)
  _checkFileIdEntityDomainAccess(fileIdEntity, useDomainAdminAccess)
  if 'role' not in body:
    missingArgumentExit(f'role {formatChoiceList(DRIVEFILE_ACL_ROLES_MAP)}')
  if mapPermissionsDomains:
    if permType != 'anyone':
      if permType != 'domain':
        atLoc = permissionId.find('@')
        if atLoc != -1:
          mappedDomain = mapPermissionsDomains.get(permissionId[atLoc+1:], None)
          if mappedDomain:
            body['emailAddress'] = permissionId = f"{permissionId[:atLoc]}@{mappedDomain}"
      else:
        mappedDomain = mapPermissionsDomains.get(permissionId, None)
        if mappedDomain:
          body['domain'] = permissionId = mappedDomain
  _validateACLOwnerType(roleLocation, body)
  _validateACLAttributes('allowfilediscovery/withlink', withLinkLocation, body, 'allowFileDiscovery', ['anyone', 'domain'])
  _validateACLAttributes('expiration', expirationLocation, body, 'expirationTime', ['user', 'group'])
  printKeys, timeObjects = _getDriveFileACLPrintKeysTimeObjects()
  if csvPF:
    if showTitles:
      csvPF.AddTitles(fileNameTitle)
      csvPF.SetSortAllTitles()
    if FJQC.formatJSON:
      csvPF.SetJSONTitles(csvPF.titlesList+['JSON'])
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity,
                                                  entityType=Ent.DRIVE_FILE_OR_FOLDER_ACL if not csvPF else None,
                                                  useDomainAdminAccess=useDomainAdminAccess)
    if jcount == 0:
      continue
    sheet = None
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      try:
        fileName = fileId
        entityType = Ent.DRIVE_FILE_OR_FOLDER_ID
        if showTitles or updateSheetProtectedRanges:
          fileName, entityType, mimeType = _getDriveFileNameFromId(drive, fileId, combineTitleId=not csvPF)
          if updateSheetProtectedRanges and mimeType == MIMETYPE_GA_SPREADSHEET:
            if not sheet:
              _, sheet = buildGAPIServiceObject(API.SHEETS, user, i, count)
              if not sheet:
                break
        permission = callGAPI(drive.permissions(), 'create',
                              bailOnInternalError=True,
                              throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+GAPI.DRIVE3_CREATE_ACL_THROW_REASONS+[GAPI.FILE_NEVER_WRITABLE],
                              moveToNewOwnersRoot=moveToNewOwnersRoot,
                              useDomainAdminAccess=useDomainAdminAccess,
                              fileId=fileId, sendNotificationEmail=sendNotificationEmail, emailMessage=emailMessage,
                              transferOwnership=_transferOwnership, body=body, fields='*', supportsAllDrives=True)
        if updateSheetProtectedRanges and mimeType == MIMETYPE_GA_SPREADSHEET:
          _updateSheetProtectedRangesACLchange(sheet, user, i, count, j, jcount, fileId, fileName, True, permission)
        if csvPF:
          baserow = {'Owner': user, 'id': fileId}
          if showTitles:
            baserow[fileNameTitle] = fileName
          row = baserow.copy()
          _mapDrivePermissionNames(permission)
          flattenJSON({'permission': permission}, flattened=row, timeObjects=timeObjects)
          if not FJQC.formatJSON:
            csvPF.WriteRowTitles(row)
          elif csvPF.CheckRowTitles(row):
            row = baserow.copy()
            row['JSON'] = json.dumps(cleanJSON({'permission': permission}, timeObjects=timeObjects),
                                     ensure_ascii=False, sort_keys=True)
            csvPF.WriteRowNoFilter(row)
        else:
          entityActionPerformed([Ent.USER, user, entityType, fileName, Ent.PERMISSION_ID, permissionId], j, jcount)
          if showDetails:
            _showDriveFilePermission(permission, printKeys, timeObjects)
      except (GAPI.badRequest, GAPI.invalid, GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError,
              GAPI.cannotSetExpiration, GAPI.cannotSetExpirationOnAnyoneOrDomain,
              GAPI.expirationDateNotAllowedForSharedDriveMembers, GAPI.expirationDatesMustBeInTheFuture,
              GAPI.insufficientFilePermissions, GAPI.unknownError, GAPI.ownershipChangeAcrossDomainNotPermitted,
              GAPI.teamDriveDomainUsersOnlyRestriction, GAPI.teamDriveTeamMembersOnlyRestriction,
              GAPI.targetUserRoleLimitedByLicenseRestriction, GAPI.insufficientAdministratorPrivileges, GAPI.sharingRateLimitExceeded,
              GAPI.publishOutNotPermitted, GAPI.shareInNotPermitted, GAPI.shareOutNotPermitted, GAPI.shareOutNotPermittedToUser,
              GAPI.cannotShareTeamDriveTopFolderWithAnyoneOrDomains, GAPI.cannotShareTeamDriveWithNonGoogleAccounts,
              GAPI.ownerOnTeamDriveItemNotSupported,
              GAPI.organizerOnNonTeamDriveNotSupported, GAPI.organizerOnNonTeamDriveItemNotSupported,
              GAPI.fileOrganizerNotYetEnabledForThisTeamDrive,
              GAPI.fileOrganizerOnFoldersInSharedDriveOnly,
              GAPI.fileOrganizerOnNonTeamDriveNotSupported,
              GAPI.teamDrivesFolderSharingNotSupported, GAPI.invalidLinkVisibility,
              GAPI.invalidSharingRequest, GAPI.fileNeverWritable, GAPI.abusiveContentRestriction) as e:
        entityActionFailedWarning([Ent.USER, user, entityType, fileName, Ent.PERMISSION_ID, permissionId], str(e), j, jcount)
      except GAPI.notFound as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE, fileName], str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('Drive File ACLs')

def doCreateDriveFileACL():
  createDriveFileACL([_getAdminEmail()], True)

# gam [<UserTypeEntity>] update drivefileacl <DriveFileEntity> <DriveFilePermissionIDorEmail> [asadmin]
#	(role <DriveFileACLRole>) [expiration <Time>] [removeexpiration [<Boolean>]]
#	[updatesheetprotectedranges [<Boolean>]] [enforceexpansiveaccess [<Boolean>]]
#	[showtitles] [nodetails|(csv [todrive <ToDriveAttribute>*] [formatjson [quotechar <Character>]])]
def updateDriveFileACLs(users, useDomainAdminAccess=False):
  fileIdEntity = getDriveFileEntity()
  isEmail, permissionId = getPermissionId()
  enforceExpansiveAccess = None
  removeExpiration = showTitles = updateSheetProtectedRanges = False
  showDetails = True
  csvPF = None
  FJQC = FormatJSONQuoteChar(csvPF)
  fileNameTitle = 'title' if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES] else 'name'
  body = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'role':
      body['role'] = getChoice(DRIVEFILE_ACL_ROLES_MAP, mapChoice=True)
    elif myarg in {'expiration', 'expires'}:
      body['expirationTime'] = getTimeOrDeltaFromNow()
    elif myarg == 'removeexpiration':
      removeExpiration = getBoolean()
    elif myarg == 'showtitles':
      showTitles = True
    elif myarg == 'updatesheetprotectedranges':
      updateSheetProtectedRanges = getBoolean()
    elif myarg == 'enforceexpansiveaccess':
      enforceExpansiveAccess = getBoolean()
    elif myarg == 'nodetails':
      showDetails = False
    elif myarg == 'csv':
      csvPF = CSVPrintFile(['Owner', 'id'], 'sortall')
      FJQC.SetCsvPF(csvPF)
    elif csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'transferownership':
      deprecatedArgument(myarg)
      getBoolean()
    elif myarg in ADMIN_ACCESS_OPTIONS:
      useDomainAdminAccess = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg)
  _checkFileIdEntityDomainAccess(fileIdEntity, useDomainAdminAccess)
  if 'role' not in body:
    missingArgumentExit(f'role {formatChoiceList(DRIVEFILE_ACL_ROLES_MAP)}')
  updateKwargs = {'useDomainAdminAccess': useDomainAdminAccess}
  if enforceExpansiveAccess is not None:
    updateKwargs['enforceExpansiveAccess'] = enforceExpansiveAccess
  printKeys, timeObjects = _getDriveFileACLPrintKeysTimeObjects()
  if csvPF and showTitles:
    csvPF.AddTitles(fileNameTitle)
    csvPF.SetSortAllTitles()
    if FJQC.formatJSON:
      csvPF.SetJSONTitles(csvPF.titlesList+['JSON'])
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity,
                                                  entityType=Ent.DRIVE_FILE_OR_FOLDER_ACL if not csvPF else None,
                                                  useDomainAdminAccess=useDomainAdminAccess)
    if jcount == 0:
      continue
    if isEmail:
      permissionId = getPermissionIdForEmail(user, i, count, permissionId)
      if not permissionId:
        return
      isEmail = False
    sheet = None
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      try:
        fileName = fileId
        entityType = Ent.DRIVE_FILE_OR_FOLDER_ID
        if showTitles or updateSheetProtectedRanges:
          fileName, entityType, mimeType = _getDriveFileNameFromId(drive, fileId, combineTitleId=not csvPF)
          if updateSheetProtectedRanges and mimeType == MIMETYPE_GA_SPREADSHEET:
            if not sheet:
              _, sheet = buildGAPIServiceObject(API.SHEETS, user, i, count)
              if not sheet:
                break
        permission = callGAPI(drive.permissions(), 'update',
                              bailOnInternalError=True,
                              throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+GAPI.DRIVE3_UPDATE_ACL_THROW_REASONS+[GAPI.FILE_NEVER_WRITABLE],
                              **updateKwargs,
                              fileId=fileId, permissionId=permissionId, removeExpiration=removeExpiration,
                              transferOwnership=body.get('role', '') == 'owner', body=body, fields='*', supportsAllDrives=True)
        if updateSheetProtectedRanges and mimeType == MIMETYPE_GA_SPREADSHEET:
          _updateSheetProtectedRangesACLchange(sheet, user, i, count, j, jcount, fileId, fileName, True, permission)
        if csvPF:
          baserow = {'Owner': user, 'id': fileId}
          if showTitles:
            baserow[fileNameTitle] = fileName
          row = baserow.copy()
          _mapDrivePermissionNames(permission)
          flattenJSON({'permission': permission}, flattened=row, timeObjects=timeObjects)
          if not FJQC.formatJSON:
            csvPF.WriteRowTitles(row)
          elif csvPF.CheckRowTitles(row):
            row = baserow.copy()
            row['JSON'] = json.dumps(cleanJSON({'permission': permission}, timeObjects=timeObjects),
                                     ensure_ascii=False, sort_keys=True)
            csvPF.WriteRowNoFilter(row)
        else:
          entityActionPerformed([Ent.USER, user, entityType, fileName, Ent.PERMISSION_ID, permissionId], j, jcount)
          if showDetails:
            _showDriveFilePermission(permission, printKeys, timeObjects)
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
              GAPI.cannotSetExpiration, GAPI.cannotSetExpirationOnAnyoneOrDomain,
              GAPI.expirationDateNotAllowedForSharedDriveMembers, GAPI.expirationDatesMustBeInTheFuture,
              GAPI.badRequest, GAPI.invalidOwnershipTransfer, GAPI.cannotRemoveOwner,
              GAPI.fileNeverWritable, GAPI.ownershipChangeAcrossDomainNotPermitted, GAPI.sharingRateLimitExceeded,
              GAPI.targetUserRoleLimitedByLicenseRestriction, GAPI.insufficientAdministratorPrivileges,
              GAPI.publishOutNotPermitted, GAPI.shareInNotPermitted, GAPI.shareOutNotPermitted, GAPI.shareOutNotPermittedToUser,
              GAPI.organizerOnNonTeamDriveItemNotSupported, GAPI.fileOrganizerOnNonTeamDriveNotSupported,
              GAPI.cannotUpdatePermission, GAPI.cannotModifyInheritedTeamDrivePermission, GAPI.fieldNotWritable) as e:
        entityActionFailedWarning([Ent.USER, user, entityType, fileName], str(e), j, jcount)
      except (GAPI.notFound, GAPI.teamDriveDomainUsersOnlyRestriction, GAPI.teamDriveTeamMembersOnlyRestriction,
              GAPI.cannotShareTeamDriveTopFolderWithAnyoneOrDomains, GAPI.ownerOnTeamDriveItemNotSupported,
              GAPI.fileOrganizerNotYetEnabledForThisTeamDrive, GAPI.fileOrganizerOnFoldersInSharedDriveOnly) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE, fileName], str(e), j, jcount)
      except GAPI.permissionNotFound:
        entityDoesNotHaveItemWarning([Ent.USER, user, entityType, fileName, Ent.PERMISSION_ID, permissionId], j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('Drive File ACLs')

def doUpdateDriveFileACLs():
  updateDriveFileACLs([_getAdminEmail()], True)

# gam [<UserTypeEntity>] create permissions <DriveFileEntity> <DriveFilePermissionsEntity> [asadmin]
#	[expiration <Time>] [sendmail] [emailmessage <String>]
#	[moveToNewOwnersRoot [<Boolean>]]
#	<PermissionMatch>* [<PermissionMatchAction>]
def createDriveFilePermissions(users, useDomainAdminAccess=False):
  def convertJSONPermissions(jsonPermissions):
    permissions = []
    for permission in PM.GetMatchingPermissions(jsonPermissions):
      if permission.get('deleted', False):
        continue
      if permission['type'] in {'anyone', 'domain'}:
        permItem = permission['type'] if permission['allowFileDiscovery'] else permission['type']+'withlink'
        if permission['type'] == 'domain':
          permItem += ':'+permission['domain']
      else:
        permItem = permission['type']+':'+permission['emailAddress']
      permissions.append(permItem+';'+permission['role'].lower())
    return permissions

  def _makePermissionBody(permission):
    body = {}
    try:
      scope, role = permission.split(';', 1)
      scope = scope.lower()
      role = role.lower()
      if scope in {'anyone', 'anyonewithlink'}:
        body['type'] = 'anyone'
        body['allowFileDiscovery'] = scope != 'anyonewithlink'
      else:
        body['type'], value = scope.split(':', 1)
        if body['type'] == 'domainwithlink':
          body['allowFileDiscovery'] = False
          body['type'] = 'domain'
        if body['type'] not in DRIVEFILE_ACL_PERMISSION_TYPES[1:]:
          return None
        if body['type'] != 'domain':
          body['emailAddress'] = value
        else:
          body['domain'] = value
      body['role'] = DRIVEFILE_ACL_ROLES_MAP.get(role)
      if not body['role']:
        return None
      if expiration:
        body['expirationTime'] = expiration
      return body
    except ValueError:
      return None

  def _callbackCreatePermission(request_id, _, exception):
    ri = request_id.splitlines()
    if int(ri[RI_J]) == 1:
      entityPerformActionNumItems([Ent.DRIVE_FILE_OR_FOLDER_ID, ri[RI_ENTITY]], int(ri[RI_JCOUNT]), Ent.PERMITTEE, int(ri[RI_I]), int(ri[RI_COUNT]))
      Ind.Increment()
    if exception is None:
      entityActionPerformed([Ent.DRIVE_FILE_OR_FOLDER_ID, ri[RI_ENTITY], Ent.PERMITTEE, ri[RI_ITEM]], int(ri[RI_J]), int(ri[RI_JCOUNT]))
    else:
      http_status, reason, message = checkGAPIError(exception)
      if reason not in GAPI.DEFAULT_RETRY_REASONS+[GAPI.SERVICE_LIMIT]:
        if reason in GAPI.DRIVE_ACCESS_THROW_REASONS+GAPI.DRIVE3_CREATE_ACL_THROW_REASONS:
          entityActionFailedWarning([Ent.DRIVE_FILE_OR_FOLDER_ID, ri[RI_ENTITY], Ent.PERMITTEE, ri[RI_ITEM]], message, int(ri[RI_J]), int(ri[RI_JCOUNT]))
        else:
          errMsg = getHTTPError({}, http_status, reason, message)
          entityActionFailedWarning([Ent.DRIVE_FILE_OR_FOLDER_ID, ri[RI_ENTITY], Ent.PERMITTEE, ri[RI_ITEM]], errMsg, int(ri[RI_J]), int(ri[RI_JCOUNT]))
        if int(ri[RI_J]) == int(ri[RI_JCOUNT]):
          Ind.Decrement()
        return
      waitOnFailure(1, 10, reason, message)
      try:
        callGAPI(drive.permissions(), 'create',
                 bailOnInternalError=True,
                 throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+GAPI.DRIVE3_CREATE_ACL_THROW_REASONS, retryReasons=[GAPI.SERVICE_LIMIT],
                 useDomainAdminAccess=useDomainAdminAccess,
                 fileId=ri[RI_ENTITY], sendNotificationEmail=sendNotificationEmail, emailMessage=emailMessage,
                 body=_makePermissionBody(ri[RI_ITEM]), fields='', supportsAllDrives=True)
        entityActionPerformed([Ent.DRIVE_FILE_OR_FOLDER_ID, ri[RI_ENTITY], Ent.PERMITTEE, ri[RI_ITEM]], int(ri[RI_J]), int(ri[RI_JCOUNT]))
      except (GAPI.badRequest, GAPI.invalid, GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError,
              GAPI.cannotSetExpiration, GAPI.cannotSetExpirationOnAnyoneOrDomain,
              GAPI.expirationDateNotAllowedForSharedDriveMembers, GAPI.expirationDatesMustBeInTheFuture,
              GAPI.insufficientFilePermissions, GAPI.unknownError, GAPI.ownershipChangeAcrossDomainNotPermitted,
              GAPI.teamDriveDomainUsersOnlyRestriction, GAPI.teamDriveTeamMembersOnlyRestriction,
              GAPI.targetUserRoleLimitedByLicenseRestriction, GAPI.insufficientAdministratorPrivileges, GAPI.sharingRateLimitExceeded,
              GAPI.publishOutNotPermitted, GAPI.shareInNotPermitted, GAPI.shareOutNotPermitted, GAPI.shareOutNotPermittedToUser,
              GAPI.cannotShareTeamDriveTopFolderWithAnyoneOrDomains, GAPI.cannotShareTeamDriveWithNonGoogleAccounts,
              GAPI.ownerOnTeamDriveItemNotSupported,
              GAPI.organizerOnNonTeamDriveNotSupported, GAPI.organizerOnNonTeamDriveItemNotSupported,
              GAPI.fileOrganizerNotYetEnabledForThisTeamDrive,
              GAPI.fileOrganizerOnFoldersInSharedDriveOnly,
              GAPI.fileOrganizerOnNonTeamDriveNotSupported,
              GAPI.teamDrivesFolderSharingNotSupported, GAPI.invalidLinkVisibility,
              GAPI.invalidSharingRequest, GAPI.fileNeverWritable, GAPI.abusiveContentRestriction,
              GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        entityActionFailedWarning([Ent.DRIVE_FILE_OR_FOLDER_ID, ri[RI_ENTITY], Ent.PERMITTEE, ri[RI_ITEM]], str(e), int(ri[RI_J]), int(ri[RI_JCOUNT]))
      except GAPI.notFound as e:
        entityActionFailedWarning([Ent.SHAREDDRIVE, ri[RI_ENTITY], Ent.PERMITTEE, ri[RI_ITEM]], str(e), int(ri[RI_J]), int(ri[RI_JCOUNT]))
    if int(ri[RI_J]) == int(ri[RI_JCOUNT]):
      Ind.Decrement()

  moveToNewOwnersRoot = False
  sendNotificationEmail = False
  emailMessage = expiration = None
  fileIdEntity = getDriveFileEntity()
  if not checkArgumentPresent('json'):
    permissions = getEntityList(Cmd.OB_DRIVE_FILE_PERMISSION_ENTITY)
    jsonData = None
    PM = None
  else:
    permissions = []
    jsonData = getJSON([])
    PM = PermissionMatch()
    PM.SetDefaultMatch(False, {'role': 'owner'})
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'enforcesingleparent':
      deprecatedArgument(myarg)
    elif myarg == 'movetonewownersroot':
      moveToNewOwnersRoot = getBoolean()
    elif myarg in {'expiration', 'expires'}:
      expiration = getTimeOrDeltaFromNow()
    elif myarg == 'sendemail':
      sendNotificationEmail = True
    elif myarg == 'emailmessage':
      sendNotificationEmail = True
      emailMessage = getString(Cmd.OB_STRING)
    elif myarg in ADMIN_ACCESS_OPTIONS:
      useDomainAdminAccess = True
    elif PM and PM.ProcessArgument(myarg):
      pass
    else:
      unknownArgumentExit()
  _checkFileIdEntityDomainAccess(fileIdEntity, useDomainAdminAccess)
  if jsonData:
    if 'permission' in jsonData:
      permissions = convertJSONPermissions([jsonData['permission']])
    elif 'permissions' in jsonData:
      permissions = convertJSONPermissions(jsonData['permissions'])
  permissionsLists = permissions if isinstance(permissions, dict) else None
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity, useDomainAdminAccess=useDomainAdminAccess)
    if not drive:
      continue
    entityPerformActionSubItemModifierNumItems([Ent.USER, user], Ent.DRIVE_FILE_OR_FOLDER_ACL, Act.MODIFIER_TO, jcount, Ent.DRIVE_FILE_OR_FOLDER, i, count)
    if jcount == 0:
      continue
    try:
      callGAPI(drive.about(), 'get',
               throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
               fields='kind')
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      continue
    Ind.Increment()
    svcargs = dict([('fileId', None), ('sendNotificationEmail', sendNotificationEmail), ('emailMessage', emailMessage),
                    ('useDomainAdminAccess', useDomainAdminAccess),
                    ('body', None), ('fields', ''), ('supportsAllDrives', True)]+GM.Globals[GM.EXTRA_ARGS_LIST])
    method = getattr(drive.permissions(), 'create')
    dbatch = drive.new_batch_http_request(callback=_callbackCreatePermission)
    bcount = 0
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      if permissionsLists:
        if not GM.Globals[GM.CSV_SUBKEY_FIELD]:
          permissions = permissionsLists[fileId]
        else:
          permissions = permissionsLists[origUser][fileId]
      kcount = len(permissions)
      if kcount == 0:
        continue
      k = 0
      for permission in permissions:
        k += 1
        svcparms = svcargs.copy()
        svcparms['fileId'] = fileId
        svcparms['body'] = _makePermissionBody(permission)
        if not svcparms['body']:
          entityActionFailedWarning([Ent.DRIVE_FILE_OR_FOLDER_ID, fileId, Ent.PERMITTEE, permission], Msg.INVALID, k, kcount)
          continue
        if svcparms['body']['role'] == 'owner':
          svcparms['moveToNewOwnersRoot'] = moveToNewOwnersRoot
          svcparms['transferOwnership'] = svcparms['sendNotificationEmail'] = True
        dbatch.add(method(**svcparms), request_id=batchRequestID(fileId, j, jcount, k, kcount, permission))
        bcount += 1
        if bcount >= GC.Values[GC.BATCH_SIZE]:
          executeBatch(dbatch)
          dbatch = drive.new_batch_http_request(callback=_callbackCreatePermission)
          bcount = 0
    if bcount > 0:
      dbatch.execute()
    Ind.Decrement()

def doCreatePermissions():
  createDriveFilePermissions([_getAdminEmail()], True)

# gam [<UserTypeEntity>] delete drivefileacl <DriveFileEntity> <DriveFilePermissionIDorEmail> [asadmin]
#	[updatesheetprotectedranges  [<Boolean>]] [enforceexpansiveaccess [<Boolean>]]
#	[showtitles]
def deleteDriveFileACLs(users, useDomainAdminAccess=False):
  fileIdEntity = getDriveFileEntity()
  isEmail, permissionId = getPermissionId()
  enforceExpansiveAccess = None
  showTitles = updateSheetProtectedRanges = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'showtitles':
      showTitles = getBoolean()
    elif myarg == 'updatesheetprotectedranges':
      updateSheetProtectedRanges = getBoolean()
    elif myarg == 'enforceexpansiveaccess':
      enforceExpansiveAccess = getBoolean()
    elif myarg in ADMIN_ACCESS_OPTIONS:
      useDomainAdminAccess = True
    else:
      unknownArgumentExit()
  _checkFileIdEntityDomainAccess(fileIdEntity, useDomainAdminAccess)
  deleteKwargs = {'useDomainAdminAccess': useDomainAdminAccess}
  if enforceExpansiveAccess is not None:
    deleteKwargs['enforceExpansiveAccess'] = enforceExpansiveAccess
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity, entityType=Ent.DRIVE_FILE_OR_FOLDER_ACL, useDomainAdminAccess=useDomainAdminAccess)
    if jcount == 0:
      continue
    if isEmail:
      permissionId = getPermissionIdForEmail(user, i, count, permissionId)
      if not permissionId:
        return
      isEmail = False
    sheet = None
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      try:
        fileName = fileId
        entityType = Ent.DRIVE_FILE_OR_FOLDER_ID
        if showTitles or updateSheetProtectedRanges:
          fileName, entityType, mimeType = _getDriveFileNameFromId(drive, fileId)
          if updateSheetProtectedRanges and mimeType == MIMETYPE_GA_SPREADSHEET:
            permission = callGAPI(drive.permissions(), 'get',
                                  throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST, GAPI.PERMISSION_NOT_FOUND, GAPI.INSUFFICIENT_ADMINISTRATOR_PRIVILEGES],
                                  useDomainAdminAccess=useDomainAdminAccess,
                                  fileId=fileId, permissionId=permissionId, fields='type,emailAddress,domain,role', supportsAllDrives=True)
            if not sheet:
              _, sheet = buildGAPIServiceObject(API.SHEETS, user, i, count)
              if not sheet:
                break
        callGAPI(drive.permissions(), 'delete',
                 throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+GAPI.DRIVE3_DELETE_ACL_THROW_REASONS+[GAPI.FILE_NEVER_WRITABLE],
                 **deleteKwargs,
                 fileId=fileId, permissionId=permissionId, supportsAllDrives=True)
        entityActionPerformed([Ent.USER, user, entityType, fileName, Ent.PERMISSION_ID, permissionId], j, jcount)
        if updateSheetProtectedRanges and mimeType == MIMETYPE_GA_SPREADSHEET:
          _updateSheetProtectedRangesACLchange(sheet, user, i, count, j, jcount, fileId, fileName, False, permission)
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
              GAPI.fileNeverWritable, GAPI.badRequest, GAPI.cannotRemoveOwner, GAPI.cannotModifyInheritedTeamDrivePermission,
              GAPI.insufficientAdministratorPrivileges, GAPI.sharingRateLimitExceeded, GAPI.cannotDeletePermission) as e:
        entityActionFailedWarning([Ent.USER, user, entityType, fileName], str(e), j, jcount)
      except GAPI.notFound as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE, fileName], str(e), j, jcount)
      except GAPI.permissionNotFound:
        entityDoesNotHaveItemWarning([Ent.USER, user, entityType, fileName, Ent.PERMISSION_ID, permissionId], j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()

def doDeleteDriveFileACLs():
  deleteDriveFileACLs([_getAdminEmail()], True)

# gam [<UserTypeEntity>] delete permissions <DriveFileEntity> <DriveFilePermissionIDEntity> [asadmin]
#	<PermissionMatch>* [<PermissionMatchAction>]
#	[enforceexpansiveaccess [<Boolean>]]
def deletePermissions(users, useDomainAdminAccess=False):
  def convertJSONPermissions(jsonPermissions):
    permissionIds = []
    for permission in PM.GetMatchingPermissions(jsonPermissions):
      if permission.get('role', '') == 'owner':
        continue
      permissionIds.append(permission['id'])
    return permissionIds

  def _callbackDeletePermissionId(request_id, _, exception):
    ri = request_id.splitlines()
    if int(ri[RI_J]) == 1:
      entityPerformActionNumItems([Ent.DRIVE_FILE_OR_FOLDER_ID, ri[RI_ENTITY]], int(ri[RI_JCOUNT]), Ent.PERMISSION_ID, int(ri[RI_I]), int(ri[RI_COUNT]))
      Ind.Increment()
    if exception is None:
      entityActionPerformed([Ent.DRIVE_FILE_OR_FOLDER_ID, ri[RI_ENTITY], Ent.PERMISSION_ID, ri[RI_ITEM]], int(ri[RI_J]), int(ri[RI_JCOUNT]))
    else:
      http_status, reason, message = checkGAPIError(exception)
      if reason not in GAPI.DEFAULT_RETRY_REASONS+[GAPI.SERVICE_LIMIT]:
        if reason == GAPI.PERMISSION_NOT_FOUND:
          entityDoesNotHaveItemWarning([Ent.DRIVE_FILE_OR_FOLDER_ID, ri[RI_ENTITY], Ent.PERMISSION_ID, ri[RI_ITEM]], int(ri[RI_J]), int(ri[RI_JCOUNT]))
        elif reason in GAPI.DRIVE3_DELETE_ACL_THROW_REASONS:
          entityActionFailedWarning([Ent.DRIVE_FILE_OR_FOLDER_ID, ri[RI_ENTITY], Ent.PERMISSION_ID, ri[RI_ITEM]], message, int(ri[RI_J]), int(ri[RI_JCOUNT]))
        else:
          errMsg = getHTTPError({}, http_status, reason, message)
          entityActionFailedWarning([Ent.DRIVE_FILE_OR_FOLDER_ID, ri[RI_ENTITY], Ent.PERMISSION_ID, ri[RI_ITEM]], errMsg, int(ri[RI_J]), int(ri[RI_JCOUNT]))
        if int(ri[RI_J]) == int(ri[RI_JCOUNT]):
          Ind.Decrement()
        return
      waitOnFailure(1, 10, reason, message)
      try:
        callGAPI(drive.permissions(), 'delete',
                 throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+GAPI.DRIVE3_DELETE_ACL_THROW_REASONS,
                 retryReasons=[GAPI.SERVICE_LIMIT],
                 useDomainAdminAccess=useDomainAdminAccess, enforceExpansiveAccess=enforceExpansiveAccess,
                 fileId=ri[RI_ENTITY], permissionId=ri[RI_ITEM], supportsAllDrives=True)
        entityActionPerformed([Ent.DRIVE_FILE_OR_FOLDER_ID, ri[RI_ENTITY], Ent.PERMISSION_ID, ri[RI_ITEM]], int(ri[RI_J]), int(ri[RI_JCOUNT]))
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
              GAPI.badRequest, GAPI.cannotRemoveOwner, GAPI.cannotModifyInheritedTeamDrivePermission,
              GAPI.insufficientAdministratorPrivileges, GAPI.sharingRateLimitExceeded, GAPI.permissionNotFound, GAPI.cannotDeletePermission,
              GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        entityActionFailedWarning([Ent.DRIVE_FILE_OR_FOLDER_ID, ri[RI_ENTITY], Ent.PERMISSION_ID, ri[RI_ITEM]], str(e), int(ri[RI_J]), int(ri[RI_JCOUNT]))
    if int(ri[RI_J]) == int(ri[RI_JCOUNT]):
      Ind.Decrement()

  fileIdEntity = getDriveFileEntity()
  if not checkArgumentPresent('json'):
    permissionIds = getEntityList(Cmd.OB_DRIVE_FILE_PERMISSION_ID_ENTITY)
    jsonData = None
    PM = None
  else:
    permissionIds = []
    jsonData = getJSON([])
    PM = PermissionMatch()
    PM.SetDefaultMatch(False, {'role': 'owner'})
  enforceExpansiveAccess = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in ADMIN_ACCESS_OPTIONS:
      useDomainAdminAccess = True
    elif PM and PM.ProcessArgument(myarg):
      pass
    elif myarg == 'enforceexpansiveaccess':
      enforceExpansiveAccess = getBoolean()
    else:
      unknownArgumentExit()
  _checkFileIdEntityDomainAccess(fileIdEntity, useDomainAdminAccess)
  if jsonData:
    if 'permission' in jsonData:
      permissionIds = convertJSONPermissions([jsonData['permission']])
    elif 'permissions' in jsonData:
      permissionIds = convertJSONPermissions(jsonData['permissions'])
  permissionIdsLists = permissionIds if isinstance(permissionIds, dict) else None
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity, useDomainAdminAccess=useDomainAdminAccess)
    if not drive:
      continue
    entityPerformActionSubItemModifierNumItems([Ent.USER, user], Ent.DRIVE_FILE_OR_FOLDER_ACL, Act.MODIFIER_FROM, jcount, Ent.DRIVE_FILE_OR_FOLDER, i, count)
    if jcount == 0:
      continue
    try:
      callGAPI(drive.about(), 'get',
               throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
               fields='kind')
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      continue
    Ind.Increment()
    svcargs = dict([('fileId', None), ('permissionId', None), ('useDomainAdminAccess', useDomainAdminAccess), ('supportsAllDrives', True)]+GM.Globals[GM.EXTRA_ARGS_LIST])
    method = getattr(drive.permissions(), 'delete')
    dbatch = drive.new_batch_http_request(callback=_callbackDeletePermissionId)
    bcount = 0
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      if permissionIdsLists:
        if not GM.Globals[GM.CSV_SUBKEY_FIELD]:
          permissionIds = permissionIdsLists[fileId]
        else:
          permissionIds = permissionIdsLists[origUser][fileId]
      kcount = len(permissionIds)
      if kcount == 0:
        continue
      k = 0
      for permissionId in permissionIds:
        k += 1
        svcparms = svcargs.copy()
        svcparms['fileId'] = fileId
        svcparms['permissionId'] = permissionId
        dbatch.add(method(**svcparms), request_id=batchRequestID(fileId, j, jcount, k, kcount, permissionId))
        bcount += 1
        if bcount >= GC.Values[GC.BATCH_SIZE]:
          executeBatch(dbatch)
          dbatch = drive.new_batch_http_request(callback=_callbackDeletePermissionId)
          bcount = 0
    if bcount > 0:
      dbatch.execute()
    Ind.Decrement()

def doDeletePermissions():
  deletePermissions([_getAdminEmail()], True)

# gam [<UserTypeEntity>] info drivefileacl <DriveFileEntity> <DriveFilePermissionIDorEmail> [asadmin]
#	[showtitles] [formatjson]
def infoDriveFileACLs(users, useDomainAdminAccess=False):
  fileIdEntity = getDriveFileEntity()
  isEmail, permissionId = getPermissionId()
  showTitles = False
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'showtitles':
      showTitles = getBoolean()
    elif myarg in ADMIN_ACCESS_OPTIONS:
      useDomainAdminAccess = True
    else:
      FJQC.GetFormatJSON(myarg)
  _checkFileIdEntityDomainAccess(fileIdEntity, useDomainAdminAccess)
  printKeys, timeObjects = _getDriveFileACLPrintKeysTimeObjects()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity, entityType=[Ent.DRIVE_FILE_OR_FOLDER_ACL, None][FJQC.formatJSON],
                                                  useDomainAdminAccess=useDomainAdminAccess)
    if jcount == 0:
      continue
    if isEmail:
      permissionId = getPermissionIdForEmail(user, i, count, permissionId)
      if not permissionId:
        return
      isEmail = False
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      try:
        fileName = fileId
        entityType = Ent.DRIVE_FILE_OR_FOLDER_ID
        if showTitles:
          fileName, entityType, _ = _getDriveFileNameFromId(drive, fileId, not FJQC.formatJSON, useDomainAdminAccess)
        permission = callGAPI(drive.permissions(), 'get',
                              throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.BAD_REQUEST, GAPI.PERMISSION_NOT_FOUND, GAPI.INSUFFICIENT_ADMINISTRATOR_PRIVILEGES],
                              useDomainAdminAccess=useDomainAdminAccess,
                              fileId=fileId, permissionId=permissionId, fields='*', supportsAllDrives=True)
        if not FJQC.formatJSON:
          entityPerformActionNumItems([entityType, fileName], jcount, Ent.PERMITTEE)
          Ind.Increment()
          _showDriveFilePermission(permission, printKeys, timeObjects, j, jcount)
          Ind.Decrement()
        else:
          _showDriveFilePermissionJSON(user, fileId, fileName, None, permission, timeObjects)
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.unknownError,
              GAPI.badRequest, GAPI.insufficientAdministratorPrivileges) as e:
        entityActionFailedWarning([Ent.USER, user, entityType, fileName], str(e), j, jcount)
      except GAPI.permissionNotFound:
        entityDoesNotHaveItemWarning([Ent.USER, user, entityType, fileName, Ent.PERMISSION_ID, permissionId], j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()

def doInfoDriveFileACLs():
  infoDriveFileACLs([_getAdminEmail()], True)

DRIVEFILE_PERMISSIONS_FOR_VIEW_CHOICES = ['published']

def getDriveFilePermissionsFields(myarg, fieldsList):
  if myarg in DRIVE_PERMISSIONS_SUBFIELDS_CHOICE_MAP:
    fieldsList.append(DRIVE_PERMISSIONS_SUBFIELDS_CHOICE_MAP[myarg])
  elif myarg == 'basicpermissions':
    fieldsList.extend(DRIVEFILE_BASIC_PERMISSION_FIELDS[:-1])
  elif myarg == 'fields':
    for field in _getFieldsList():
      if field in DRIVE_PERMISSIONS_SUBFIELDS_CHOICE_MAP:
        fieldsList.append(DRIVE_PERMISSIONS_SUBFIELDS_CHOICE_MAP[field])
      elif field == 'basicpermissions':
        fieldsList.extend(DRIVEFILE_BASIC_PERMISSION_FIELDS[:-1])
      else:
        invalidChoiceExit(field, DRIVE_PERMISSIONS_SUBFIELDS_CHOICE_MAP, True)
  else:
    return False
  return True

# gam [<UserTypeEntity>] print drivefileacls <DriveFileEntity> [todrive <ToDriveAttribute>*]
#	(role|roles <DriveFileACLRoleList>)*
#	<PermissionMatch>* [<PermissionMatchAction>] [pmselect]
#	[includepermissionsforview published]
#	[oneitemperrow] [<DrivePermissionsFieldName>*|(fields <DrivePermissionsFieldNameList>)]
#	[showtitles|(addtitle <String>)]]
#	(orderby <DriveFileOrderByFieldName> [ascending|descending])*
#	[formatjson [quotechar <Character>]] [asadmin]
# gam [<UserTypeEntity>] show drivefileacls <DriveFileEntity>
#	(role|roles <DriveFileACLRoleList>)*
#	<PermissionMatch>* [<PermissionMatchAction>] [pmselect]
#	[includepermissionsforview published]
#	[oneitemperrow] [<DrivePermissionsFieldName>*|(fields <DrivePermissionsFieldNameList>)]
#	[showtitles|(addtitle <String>)]]
#	(orderby <DriveFileOrderByFieldName> [ascending|descending])*
#	[formatjson] [asadmin]
def printShowDriveFileACLs(users, useDomainAdminAccess=False):
  def _printPermissionRow(baserow, permission):
    row = baserow.copy()
    flattenJSON({'permission': permission}, flattened=row, timeObjects=timeObjects)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      row = baserow.copy()
      row['JSON'] = json.dumps(cleanJSON({'permission': permission}, timeObjects=timeObjects),
                               ensure_ascii=False, sort_keys=True)
      csvPF.WriteRowNoFilter(row)

  csvPF = CSVPrintFile(['Owner', 'id'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  fileIdEntity = getDriveFileEntity()
  aclRolesMap = DRIVEFILE_ACL_ROLES_MAP.copy()
  if fileIdEntity['shareddrivename'] or fileIdEntity['shareddriveadminquery'] or fileIdEntity['shareddrivefilequery'] or fileIdEntity['shareddrive']:
    aclRolesMap['owner'] = 'organizer'
  addTitle = None
  roles = set()
  oneItemPerRow = pmselect = showTitles = False
  includePermissionsForView = None
  fieldsList = []
  OBY = OrderBy(DRIVEFILE_ORDERBY_CHOICE_MAP)
  PM = PermissionMatch()
  fileNameTitle = 'title' if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES] else 'name'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'role', 'roles'}:
      roles |= getACLRoles(aclRolesMap)
    elif myarg == 'oneitemperrow':
      oneItemPerRow = True
    elif myarg == 'orderby':
      OBY.GetChoice()
    elif myarg in {'showtitles', 'addtitle'}:
      if myarg == 'showtitles':
        showTitles = True
        addTitle = None
      else:
        addTitle = getString(Cmd.OB_STRING)
        showTitles = False
      if csvPF:
        csvPF.AddTitles(fileNameTitle)
        csvPF.SetSortAllTitles()
    elif getDriveFilePermissionsFields(myarg, fieldsList):
      pass
    elif myarg in ADMIN_ACCESS_OPTIONS:
      useDomainAdminAccess = True
    elif myarg == 'pmselect':
      pmselect = True
    elif myarg == 'pmfilter': # Ignore, this is the default behavior
      pass
    elif PM.ProcessArgument(myarg):
      pass
    elif myarg == 'includepermissionsforview':
      includePermissionsForView = getChoice(DRIVEFILE_PERMISSIONS_FOR_VIEW_CHOICES)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  _checkFileIdEntityDomainAccess(fileIdEntity, useDomainAdminAccess)
  if fieldsList:
    if roles:
      fieldsList.append('role')
  fields = getItemFieldsFromFieldsList('permissions', fieldsList, True)
  printKeys, timeObjects = _getDriveFileACLPrintKeysTimeObjects()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity,
                                                  entityType=[Ent.DRIVE_FILE_OR_FOLDER, None][csvPF is not None or FJQC.formatJSON],
                                                  orderBy=OBY.orderBy, useDomainAdminAccess=useDomainAdminAccess)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for fileId in fileIdEntity['list']:
      j += 1
      fileName = fileId
      entityType = Ent.DRIVE_FILE_OR_FOLDER_ID
      if showTitles:
        fileName, entityType, _ = _getDriveFileNameFromId(drive, fileId, not (csvPF or FJQC.formatJSON), useDomainAdminAccess)
      elif addTitle:
        fileName = f'{addTitle} ({fileId})'
        entityType = Ent.DRIVE_FILE_OR_FOLDER
      try:
        permissions = callGAPIpages(drive.permissions(), 'list', 'permissions',
                                    throwReasons=GAPI.DRIVE3_GET_ACL_REASONS+[GAPI.NOT_FOUND],
                                    retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                    useDomainAdminAccess=useDomainAdminAccess,
                                    includePermissionsForView=includePermissionsForView,
                                    fileId=fileId, fields=fields, supportsAllDrives=True)
        for permission in permissions:
          permission.pop('teamDrivePermissionDetails', None)
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError,
              GAPI.insufficientAdministratorPrivileges, GAPI.insufficientFilePermissions,
              GAPI.unknownError, GAPI.invalid) as e:
        entityActionFailedWarning([Ent.USER, user, entityType, fileName], str(e), j, jcount)
        continue
      except GAPI.notFound as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE, fileName], str(e), j, jcount)
        continue
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
      if pmselect:
        if not PM.CheckPermissionMatches(permissions):
          continue
      else:
        permissions = PM.GetMatchingPermissions(permissions)
      if roles:
        matchingPermissions = []
        for permission in permissions:
          if roles and permission['role'] not in roles:
            continue
          matchingPermissions.append(permission)
        permissions = matchingPermissions
      if not csvPF:
        if not FJQC.formatJSON:
          _showDriveFilePermissions(entityType, fileName, permissions, printKeys, timeObjects, j, jcount)
        else:
          if oneItemPerRow:
            for permission in permissions:
              _showDriveFilePermissionJSON(user, fileId, fileName, None, permission, timeObjects)
          else:
            _showDriveFilePermissionsJSON(user, fileId, fileName, None, permissions, timeObjects)
      else:
        baserow = {'Owner': user, 'id': fileId}
        if showTitles or addTitle:
          baserow[fileNameTitle] = fileName
        if oneItemPerRow:
          for permission in permissions:
            _mapDrivePermissionNames(permission)
            pdetails = permission.pop('permissionDetails', [])
            if not pdetails:
              _printPermissionRow(baserow, permission)
            else:
              for pdetail in pdetails:
                permission['permissionDetails'] = pdetail
                _printPermissionRow(baserow, permission)
        else:
          row = baserow.copy()
          for permission in permissions:
            _mapDrivePermissionNames(permission)
          flattenJSON({'permissions': permissions}, flattened=row, timeObjects=timeObjects)
          if not FJQC.formatJSON:
            csvPF.WriteRowTitles(row)
          elif csvPF.CheckRowTitles(row):
            baserow['JSON'] = json.dumps(cleanJSON({'permissions': permissions}, timeObjects=timeObjects),
                                         ensure_ascii=False, sort_keys=True)
            csvPF.WriteRowNoFilter(baserow)
    Ind.Decrement()
  if csvPF:
    if not oneItemPerRow:
      csvPF.SetIndexedTitles(['permissions'])
    csvPF.writeCSVfile('Drive File ACLs')

def doPrintShowDriveFileACLs():
  printShowDriveFileACLs([_getAdminEmail()], True)

DRIVELABELS_PROJECTION_CHOICE_MAP = {'basic': 'LABEL_VIEW_BASIC', 'full': 'LABEL_VIEW_FULL'}
DRIVELABELS_PERMISSION_ROLE_MAP = {
  'applier': 'APPLIER',
  'editor': 'EDITOR',
  'organizer': 'ORGANIZER',
  'organiser': 'ORGANIZER',
  'reader': 'READER',
  }
DRIVELABELS_TIME_OBJECTS = {'createTime', 'publishTime', 'disableTime', 'revisionCreateTime'}

def _getDisplayDriveLabelsParameters(myarg, parameters):
  if myarg in DRIVELABELS_PROJECTION_CHOICE_MAP:
    parameters['view'] = DRIVELABELS_PROJECTION_CHOICE_MAP[myarg]
  elif myarg == 'language':
    parameters['languageCode'] = getLanguageCode(BCP47_LANGUAGE_CODES_MAP)
  elif myarg in ADMIN_ACCESS_OPTIONS:
    parameters['useAdminAccess'] = True
  elif myarg == 'publishedonly':
    parameters['publishedOnly'] = getBoolean()
  elif myarg == 'minimumrole':
    parameters['minimumRole'] = getChoice(DRIVELABELS_PERMISSION_ROLE_MAP, mapChoice=True)
  else:
    return False
  return True

def normalizeDriveLabelID(driveLabelID):
  atLoc = driveLabelID.find('@')
  if atLoc != -1:
    driveLabelID = driveLabelID[:atLoc]
  if driveLabelID.startswith('labels/'):
    return driveLabelID[7:]
  return driveLabelID

def normalizeDriveLabelName(driveLabelName):
  if driveLabelName.startswith('labels/'):
    return driveLabelName
  return f'labels/{driveLabelName}'

def validateDriveLabelName(name, kvList, j, jcount, permName=False):
  name = normalizeDriveLabelName(name)
# Label name
  if not permName:
    mg = re.match(r'^(labels/[^/]+)$', name)
    if not mg:
      entityActionNotPerformedWarning(kvList, 'Expected labels/<String>', j, jcount)
      return None
    return name
# Label permission name
  mg = re.match(r'^(labels/[^/]+)/permissions/(?:audiences|groups|people)/.+$', name)
  if not mg:
    entityActionNotPerformedWarning(kvList, 'Expected labels/<String>/permissions/(audiences|groups|people)/<String>', j, jcount)
    return (None, None)
  return (name, mg.group(1))

def _showDriveLabel(label, j, jcount, FJQC):
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(label, timeObjects=DRIVELABELS_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.CLASSIFICATION_LABEL_NAME, f'{label["name"]}'], j, jcount)
  Ind.Increment()
  showJSON(None, label, timeObjects=DRIVELABELS_TIME_OBJECTS, dictObjectsKey={'fields': 'id', 'choices': 'id'})
  Ind.Decrement()

# gam [<UserTypeEntity>] info classificationlabels <ClassificationLabelNameEntity>
#	[[basic|full] [languagecode <BCP47LanguageCode>]
#	[formatjson] [asadmin]
def infoDriveLabels(users, useAdminAccess=False):
  driveLabelNameEntity = getUserObjectEntity(Cmd.OB_CLASSIFICATION_LABEL_NAME, Ent.CLASSIFICATION_LABEL, shlexSplit=True)
  FJQC = FormatJSONQuoteChar()
  parameters = {'useAdminAccess': useAdminAccess}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if _getDisplayDriveLabelsParameters(myarg, parameters):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  api = API.DRIVELABELS_ADMIN if parameters['useAdminAccess'] else API.DRIVELABELS_USER
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, labelNames, jcount = _validateUserGetObjectList(user, i, count, driveLabelNameEntity,
                                                                 api=api, showAction=not FJQC.formatJSON)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for name in labelNames:
      j += 1
      kvList = [Ent.USER, user, Ent.CLASSIFICATION_LABEL_NAME, name]
      name = validateDriveLabelName(name, kvList, j, jcount, False)
      if name is None:
        continue
      try:
        label = callGAPI(drive.labels(), 'get',
                         bailOnInternalError=True,
                         throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED,
                                                                     GAPI.INVALID_ARGUMENT, GAPI.INTERNAL_ERROR],
                         name=name, **parameters)
        _showDriveLabel(label, j, jcount, FJQC)
      except GAPI.notFound as e:
        entityActionFailedWarning(kvList, str(e), j, jcount)
      except (GAPI.permissionDenied, GAPI.invalidArgument, GAPI.internalError) as e:
        entityActionFailedWarning(kvList, str(e), j, jcount)
        break
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()

def doInfoDriveLabels():
  infoDriveLabels([_getAdminEmail()], True)

# gam [<UserTypeEntity>] print classificationlabels> [todrive <ToDriveAttribute>*]
#	[basic|full] [languagecode <BCP47LanguageCode>]
#	[publishedonly [<Boolean>]] [minimumrole applier|editor|organizer|reader]
#	[formatjson [quotechar <Character>]] [asadmin]
# gam [<UserTypeEntity>] show classificationlabels
#	[basic|full] [languagecode <BCP47LanguageCode>]
#	[publishedonly [<Boolean>]] [minimumrole applier|editor|organizer|reader]
#	[formatjson] [asadmin]
def printShowDriveLabels(users, useAdminAccess=False):
  csvPF = CSVPrintFile(['User', 'name', 'description', 'id'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  parameters = {'useAdminAccess': useAdminAccess}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif _getDisplayDriveLabelsParameters(myarg, parameters):
      pass
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if csvPF and FJQC.formatJSON:
    csvPF.SetJSONTitles(['User', 'name', 'JSON'])
  api = API.DRIVELABELS_ADMIN if parameters['useAdminAccess'] else API.DRIVELABELS_USER
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = buildGAPIServiceObject(api, user, i, count)
    if not drive:
      continue
    if csvPF:
      printGettingAllEntityItemsForWhom(Ent.CLASSIFICATION_LABEL, user, i, count)
      pageMessage = getPageMessageForWhom()
    else:
      pageMessage = None
    try:
      labels = callGAPIpages(drive.labels(), 'list', 'labels',
                             pageMessage=pageMessage,
                             throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.PERMISSION_DENIED],
                             **parameters, fields='nextPageToken,labels', pageSize=200)
      if not csvPF:
        jcount = len(labels)
        if not FJQC.formatJSON:
          entityPerformActionNumItems([Ent.USER, user], jcount, Ent.CLASSIFICATION_LABEL, i, count)
        Ind.Increment()
        j = 0
        for label in labels:
          j += 1
          _showDriveLabel(label, j, jcount, FJQC)
        Ind.Decrement()
      else:
        for label in labels:
          row = flattenJSON(label, flattened={'User': user}, timeObjects=DRIVELABELS_TIME_OBJECTS)
          if not FJQC.formatJSON:
            csvPF.WriteRowTitles(row)
          elif csvPF.CheckRowTitles(row):
            row = {'User': user, 'name': label['name']}
            row['JSON'] = json.dumps(cleanJSON(label, timeObjects=DRIVELABELS_TIME_OBJECTS),
                                     ensure_ascii=False, sort_keys=True)
            csvPF.WriteRowNoFilter(row)
    except GAPI.permissionDenied as e:
      entityActionFailedWarning([Ent.USER, user, Ent.CLASSIFICATION_LABEL, None], str(e), i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
  if csvPF:
    csvPF.writeCSVfile('Classification Labels')

def doPrintShowDriveLabels():
  printShowDriveLabels([_getAdminEmail()], True)

def _showDriveLabelPermission(labelperm, j, jcount, FJQC):
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(labelperm), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.CLASSIFICATION_LABEL_PERMISSION_NAME, f'{labelperm["name"]}'], j, jcount)
  Ind.Increment()
  showJSON(None, labelperm)
  Ind.Decrement()

# gam [<UserTypeEntity>] create classificationlabelpermission <ClassificationLabelNameEntity>
#	(user <UserItem>) | (group <GroupItem) | (audience <String>)
#	role applier|editor|organizer|reader
#	[nodetails|formatjson] [asadmin]
def createDriveLabelPermissions(users, useAdminAccess=False):
  driveLabelNameEntity = getUserObjectEntity(Cmd.OB_CLASSIFICATION_LABEL_NAME, Ent.CLASSIFICATION_LABEL_PERMISSION, shlexSplit=True)
  FJQC = FormatJSONQuoteChar()
  parameters = {'useAdminAccess': useAdminAccess}
  body = {}
  showDetails = True
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in ADMIN_ACCESS_OPTIONS:
      parameters['useAdminAccess'] = True
    elif myarg == 'role':
      body['role'] = getChoice(DRIVELABELS_PERMISSION_ROLE_MAP, mapChoice=True)
    elif myarg in {'user', 'group'}:
      email = getEmailAddress(returnUIDprefix='id:')
      body['email'], status = convertUIDtoEmailAddressWithType(email, emailTypes=[myarg])
      if status == 'unknown':
        Cmd.Backup()
        usageErrorExit(Msg.ENTITY_DOES_NOT_EXIST.format(email))
    elif myarg == 'audience':
      audience = getString(Cmd.OB_STRING)
      if not audience.startswith('audiences/'):
        audience = 'audiences/'+audience
      body['audience'] = audience
    elif myarg == 'nodetails':
      showDetails = False
    else:
      FJQC.GetFormatJSON(myarg)
  if 'role' not in body:
    missingArgumentExit(f'role {"|".join(DRIVELABELS_PERMISSION_ROLE_MAP.keys())}')
  if 'email' not in body and 'audience' not in body:
    missingArgumentExit('(user <UserItem>) | (group <GroupItem) | (audience <String>)')
  api = API.DRIVELABELS_ADMIN if parameters['useAdminAccess'] else API.DRIVELABELS_USER
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, labelNames, jcount = _validateUserGetObjectList(user, i, count, driveLabelNameEntity,
                                                                 api=api, showAction=not FJQC.formatJSON)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for name in labelNames:
      j += 1
      kvList = [Ent.USER, user, Ent.CLASSIFICATION_LABEL_NAME, name, Ent.CLASSIFICATION_LABEL_PERMISSION, None]
      name = validateDriveLabelName(name, kvList, j, jcount, False)
      if name is None:
        continue
      try:
        labelperm = callGAPI(drive.labels().permissions(), 'create',
                             bailOnInternalError=True,
                             throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.PERMISSION_DENIED, GAPI.NOT_FOUND,
                                                                         GAPI.INVALID, GAPI.INTERNAL_ERROR],
                             parent=name, body=body, **parameters)
        kvList = [Ent.USER, user, Ent.CLASSIFICATION_LABEL_PERMISSION, labelperm['name']]
        if not FJQC.formatJSON:
          entityActionPerformed(kvList, j, jcount)
        if showDetails:
          Ind.Increment()
          _showDriveLabelPermission(labelperm, j, jcount, FJQC)
          Ind.Decrement()
      except (GAPI.permissionDenied, GAPI.notFound, GAPI.invalid, GAPI.internalError)  as e:
        entityActionFailedWarning(kvList, str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()

def doCreateDriveLabelPermissions():
  createDriveLabelPermissions([_getAdminEmail()], True)

# gam [<UserTypeEntity>] delete classificationlabelpermission <ClassificationLabelNameEntity>
#	(user <UserItem>) | (group <GroupItem) | (audience <String>)
#	[asadmin]
# gam [<UserTypeEntity>] remove classificationlabelpermission <ClassificationLabelPermissionNameEntity>
#	[asadmin]
def deleteDriveLabelPermissions(users, useAdminAccess=False):
  doDelete = Act.Get() == Act.DELETE
  if doDelete:
    driveLabelNameEntity = getUserObjectEntity(Cmd.OB_CLASSIFICATION_LABEL_NAME, Ent.CLASSIFICATION_LABEL, shlexSplit=True)
  else:
    driveLabelNameEntity = getUserObjectEntity(Cmd.OB_CLASSIFICATION_LABEL_PERMISSION_NAME, Ent.CLASSIFICATION_LABEL_PERMISSION, shlexSplit=True)
  parameters = {'useAdminAccess': useAdminAccess, 'requests': [None]}
  labelperm = ''
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in ADMIN_ACCESS_OPTIONS:
      parameters['useAdminAccess'] = True
    elif doDelete and myarg in {'user', 'group'}:
      labelperm = ['people/', 'groups/'][myarg == 'group']+convertEmailAddressToUID(getEmailAddress(), cd=None, emailType=myarg, savedLocation=None)
    elif doDelete and myarg == 'audience':
      audience = getString(Cmd.OB_STRING)
      if not audience.startswith('audiences/'):
        audience = 'audiences/'+audience
      labelperm = audience
    else:
      unknownArgumentExit()
  if doDelete and not labelperm:
    missingArgumentExit('(user <UserItem>) | (group <GroupItem) | (audience <String>)')
  api = API.DRIVELABELS_ADMIN if parameters['useAdminAccess'] else API.DRIVELABELS_USER
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, labelPermNames, jcount = _validateUserGetObjectList(user, i, count, driveLabelNameEntity,
                                                                     api=api, showAction=True)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for name in labelPermNames:
      j += 1
      kvList = [Ent.USER, user, Ent.CLASSIFICATION_LABEL_PERMISSION_NAME, name]
      if doDelete:
        parent = validateDriveLabelName(name, kvList, j, jcount, False)
        if parent is None:
          continue
        name = parent+'/permissions/'+labelperm
      else:
        name, parent = validateDriveLabelName(name, kvList, j, jcount, True)
        if name is None:
          continue
      kvList[-1] = name
      parameters['requests'][0] = {'name': name}
      try:
        callGAPI(drive.labels().permissions(), 'batchDelete',
                 throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.PERMISSION_DENIED, GAPI.INVALID, GAPI.NOT_FOUND],
                 parent=parent, body=parameters)
        entityActionPerformed(kvList, j, jcount)
      except (GAPI.permissionDenied, GAPI.invalid, GAPI.notFound) as e:
        entityActionFailedWarning(kvList, str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()

def doDeleteDriveLabelPermissions():
  deleteDriveLabelPermissions([_getAdminEmail()], True)

# gam [<UserTypeEntity>] print classificationlabelpermissions <ClassificationLabelNameEntity> [todrive <ToDriveAttribute>*]
#	[formatjson [quotechar <Character>]] [asadmin]
# gam [<UserTypeEntity>] show classificationlabelpermissions <ClassificationLabelNameEntity>
#	[formatjson] [asadmin]
def printShowDriveLabelPermissions(users, useAdminAccess=False):
  csvPF = CSVPrintFile(['User', 'name', 'email', 'role', 'person', 'group', 'audience'], 'sortall') if Act.csvFormat() else None
  driveLabelNameEntity = getUserObjectEntity(Cmd.OB_CLASSIFICATION_LABEL_NAME, Ent.CLASSIFICATION_LABEL_PERMISSION, shlexSplit=True)
  FJQC = FormatJSONQuoteChar(csvPF)
  parameters = {'useAdminAccess': useAdminAccess}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in ADMIN_ACCESS_OPTIONS:
      parameters['useAdminAccess'] = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if csvPF and FJQC.formatJSON:
    csvPF.SetJSONTitles(['User', 'name', 'JSON'])
  api = API.DRIVELABELS_ADMIN if parameters['useAdminAccess'] else API.DRIVELABELS_USER
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, labelNames, jcount = _validateUserGetObjectList(user, i, count, driveLabelNameEntity,
                                                                 api=api, showAction=FJQC is None or not FJQC.formatJSON)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for name in labelNames:
      j += 1
      kvList = [Ent.USER, user, Ent.CLASSIFICATION_LABEL_NAME, name, Ent.CLASSIFICATION_LABEL_PERMISSION, None]
      name = validateDriveLabelName(name, kvList, j, jcount, False)
      if name is None:
        continue
      kvList = [Ent.USER, user, Ent.CLASSIFICATION_LABEL_NAME, name]
      if csvPF:
        printGettingAllEntityItemsForWhom(Ent.CLASSIFICATION_LABEL_PERMISSION, name, j, jcount)
        pageMessage = getPageMessageForWhom()
      else:
        pageMessage = None
      try:
        labelperms = callGAPIpages(drive.labels().permissions(), 'list', 'labelPermissions',
                                   pageMessage=pageMessage,
                                   throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.PERMISSION_DENIED, GAPI.NOT_FOUND],
                                   parent=name, **parameters, fields='nextPageToken,labelPermissions', pageSize=200)
        if not csvPF:
          jcount = len(labelperms)
          if not FJQC.formatJSON:
            entityPerformActionNumItems(kvList, jcount, Ent.CLASSIFICATION_LABEL_PERMISSION, i, count)
          Ind.Increment()
          j = 0
          for labelperm in labelperms:
            j += 1
            _showDriveLabelPermission(labelperm, j, jcount, FJQC)
          Ind.Decrement()
        else:
          for labelperm in labelperms:
            row = flattenJSON(labelperm, flattened={'User': user})
            if not FJQC.formatJSON:
              csvPF.WriteRowTitles(row)
            elif csvPF.CheckRowTitles(row):
              row = {'User': user, 'name': labelperm['name']}
              row['JSON'] = json.dumps(cleanJSON(labelperm),
                                       ensure_ascii=False, sort_keys=True)
              csvPF.WriteRowNoFilter(row)
      except (GAPI.permissionDenied, GAPI.notFound) as e:
        entityActionFailedWarning(kvList, str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('Classification Label Permissions')

def doPrintShowDriveLabelPermissions():
  printShowDriveLabelPermissions([_getAdminEmail()], True)

DRIVELABEL_FIELD_TYPE_MAP = {
  'text': 'setTextValues',
  'selection': 'setSelectionValues',
  'integer': 'setIntegerValues',
  'date': 'setDateValues',
  'user': 'setUserValues',
  }

# gam <UserTypeEntity> process filedrivelabels <DriveFileEntity>
#	(addlabel <ClassificationLabelIDList>)*
#	(deletelabel <ClassificationLabelIDList>)*
#	(addlabelfield <ClassificationLabelID> <ClassificationLabelFieldID>
#	    (text <String>)|selection <ClassificationLabelSelectionIDList>)|
#	    (integer <Number>)|(date <Date>)|(user <EmailAddressList>))*
#	(deletelabelfield <ClassificationLabelID> <ClassificationLabelFieldID>)*
#	[nodetails]
def processFileDriveLabels(users):
  fileIdEntity = getDriveFileEntity()
  actionList = {'addlabel': {'action': Act.CREATE, 'list': []},
                'deletelabel': {'action': Act.DELETE, 'list': []},
                'addlabelfield': {'action': Act.CREATE, 'list': []},
                'deletelabelfield': {'action': Act.DELETE, 'list': []},
               }
  showDetails = True
  kcount = 0
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'addlabel':
      labelIds = getEntityList(Cmd.OB_CLASSIFICATION_LABEL_ID, shlexSplit=True)
      for labelId in labelIds:
        actionList[myarg]['list'].append({'labelModifications': [{'labelId': normalizeDriveLabelID(labelId)}]})
        kcount += 1
    elif myarg == 'deletelabel':
      labelIds = getEntityList(Cmd.OB_CLASSIFICATION_LABEL_ID, shlexSplit=True)
      for labelId in labelIds:
        actionList[myarg]['list'].append({'labelModifications': [{'labelId': normalizeDriveLabelID(labelId), 'removeLabel': True}]})
        kcount += 1
    elif myarg == 'addlabelfield':
      labelId = normalizeDriveLabelID(getString(Cmd.OB_CLASSIFICATION_LABEL_ID))
      fieldId = getString(Cmd.OB_CLASSIFICATION_LABEL_FIELD_ID)
      fieldType = getChoice(DRIVELABEL_FIELD_TYPE_MAP, mapChoice=True)
      if fieldType == 'setTextValues':
        valueList = [getString(Cmd.OB_STRING, minLen=0)]
      elif fieldType == 'setSelectionValues':
        valueList = convertEntityToList(getString(Cmd.OB_CLASSIFICATION_LABEL_SELECTION_ID_LIST, minLen=0), shlexSplit=True)
      elif fieldType == 'setIntegerValues':
        valueList = [getInteger()]
      elif fieldType == 'setDateValues':
        valueList = [getYYYYMMDD()]
      else: #elif fieldType == 'setUserValues':
        valueList = convertEntityToList(getString(Cmd.OB_EMAIL_ADDRESS_LIST, minLen=0))
      actionList[myarg]['list'].append({'labelModifications': [{'labelId': labelId,
                                                                'fieldModifications': [{'fieldId': fieldId, fieldType: valueList}]}]})
      kcount += 1
    elif myarg == 'deletelabelfield':
      labelId = normalizeDriveLabelID(getString(Cmd.OB_CLASSIFICATION_LABEL_ID))
      fieldId = getString(Cmd.OB_CLASSIFICATION_LABEL_FIELD_ID)
      actionList[myarg]['list'].append({'labelModifications': [{'labelId': labelId,
                                                                'fieldModifications': [{'fieldId': fieldId, 'unsetValues': True}]}]})
      kcount += 1
    elif myarg == 'nodetails':
      showDetails = False
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity, entityType=None)
    if jcount == 0:
      continue
    Act.Set(Act.PROCESS)
    entityPerformActionSubItemModifierNumItems([Ent.USER, user], Ent.CLASSIFICATION_LABEL, Act.MODIFIER_FOR, jcount, Ent.DRIVE_FILE_OR_FOLDER)
    Ind.Increment()
    j = 0
    userError = False
    for fileId in fileIdEntity['list']:
      j += 1
      k = 0
      kvList = [Ent.USER, user, Ent.DRIVE_FILE_OR_FOLDER_ID, fileId]
      Act.Set(Act.PROCESS)
      entityPerformActionNumItems(kvList, kcount, Ent.CLASSIFICATION_LABEL)
      Ind.Increment()
      for operation in ['deletelabelfield', 'deletelabel', 'addlabel', 'addlabelfield']:
        Act.Set(actionList[operation]['action'])
        for action in actionList[operation]['list']:
          k += 1
          xkvList = kvList.copy()
          xkvList.extend([Ent.CLASSIFICATION_LABEL_ID, action['labelModifications'][0]['labelId']])
          if 'fieldModifications' in action['labelModifications'][0]:
            xkvList.extend([Ent.CLASSIFICATION_LABEL_FIELD_ID, action['labelModifications'][0]['fieldModifications'][0]['fieldId']])
          try:
            label = callGAPI(drive.files(), 'modifyLabels',
                             throwReasons=GAPI.DRIVE3_MODIFY_LABEL_THROW_REASONS,
                             fileId=fileId, body=action)
            entityActionPerformed(xkvList, k, kcount)
            if showDetails:
              Ind.Increment()
              showJSON(None, label, timeObjects=DRIVELABELS_TIME_OBJECTS, dictObjectsKey={'fields': 'id', 'modifiedLabels': 'id'})
              Ind.Decrement()
          except GAPI.fileNotFound as e:
            entityActionFailedWarning(kvList, str(e), j, jcount)
            break
          except (GAPI.notFound, GAPI.forbidden, GAPI.internalError,
                  GAPI.fileNeverWritable, GAPI.applyLabelForbidden,
                  GAPI.insufficientFilePermissions, GAPI.unknownError, GAPI.invalidInput, GAPI.badRequest,
                  GAPI.labelMutationUnknownField, GAPI.labelMutationIllegalSelection, GAPI.labelMutationForbidden,
                  GAPI.labelMultipleValuesForSingularField) as e:
            entityActionFailedWarning(xkvList, str(e), k, kcount)
          except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
            userDriveServiceNotEnabledWarning(user, str(e), i, count)
            userError = True
            break
      Ind.Decrement()
      if userError:
        break
    Ind.Decrement()

# gam print ownership <DriveFileID>|(drivefilename <DriveFileName>) [todrive <ToDriveAttribute>*]
#	(addcsvdata <FieldName> <String>)*
#	[formatjson [quotechar <Character>]]
# gam show ownership <DriveFileID>|(drivefilename <DriveFileName>)
#	[formatjson]
def doPrintShowOwnership():
  rep = buildGAPIObject(API.REPORTS)
  customerId = GC.Values[GC.CUSTOMER_ID]
  if customerId == GC.MY_CUSTOMER:
    customerId = None
  fileNameTitle = 'title' if not GC.Values[GC.DRIVE_V3_NATIVE_NAMES] else 'name'
  csvPF = CSVPrintFile('Owner') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  addCSVData = {}
  showComplete = False
  entityType = Ent.DRIVE_FILE_OR_FOLDER_ID
  myarg = getString(Cmd.OB_DRIVE_FILE_ID, checkBlank=True)
  mycmd = myarg.lower().replace('_', '').replace('-', '')
  if mycmd == 'id':
    fileId = getString(Cmd.OB_DRIVE_FILE_ID, checkBlank=True)
  elif mycmd == 'drivefilename':
    entityType = Ent.DRIVE_FILE_OR_FOLDER
    fileId = getString(Cmd.OB_DRIVE_FILE_NAME, checkBlank=True)
  elif mycmd.find(':') != -1:
    kw, fileId = myarg.split(':', 1)
    kw = kw.lower().replace('_', '').replace('-', '')
    if fileId.isspace():
      Cmd.Backup()
      blankArgumentExit(Cmd.OB_DRIVE_FILE_ID)
    if kw == 'id':
      pass
    elif kw == 'drivefilename':
      entityType = Ent.DRIVE_FILE_OR_FOLDER
    else:
      Cmd.Backup()
      invalidArgumentExit(Cmd.OB_DRIVE_FILE_ID)
  else:
    fileId = myarg
  if not fileId:
    Cmd.Backup()
    invalidArgumentExit(Cmd.OB_DRIVE_FILE_ID)
  if entityType == Ent.DRIVE_FILE_OR_FOLDER_ID:
    filters = f'doc_id=={fileId}'
  else:
    filters = f'doc_title=={fileId}'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif csvPF and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if csvPF and not FJQC.formatJSON:
    csvPF.AddTitles(['id', fileNameTitle, 'type', 'ownerIsSharedDrive', 'driveId', 'event'])
    if addCSVData:
      csvPF.AddTitles(sorted(addCSVData.keys()))
    csvPF.SetSortAllTitles()
  foundIds = {}
  try:
    feed = callGAPIpages(rep.activities(), 'list', 'items',
                         throwReasons=[GAPI.BAD_REQUEST, GAPI.INVALID, GAPI.AUTH_ERROR],
                         applicationName='drive', userKey='all', customerId=customerId,
                         filters=filters, fields='nextPageToken,items(events(name,parameters))')
  except GAPI.badRequest:
    systemErrorExit(BAD_REQUEST_RC, Msg.BAD_REQUEST)
  except GAPI.invalid as e:
    systemErrorExit(GOOGLE_API_ERROR_RC, str(e))
  except GAPI.authError:
    accessErrorExit(None)
  for activity in feed:
    events = activity.pop('events')
    for event in events:
      fileInfo = {'event': event['name']}
      for item in event.get('parameters', []):
        if item['name'] == 'primary_event':
          if not item['boolValue']:
            break
        elif item['name'] == 'doc_id':
          if item['value'] in foundIds:
            break
          fileInfo['id'] = item['value']
        elif event['name'] == 'change_owner' and item['name'] == 'new_owner':
          fileInfo['Owner'] = item['value']
        elif event['name'] != 'change_owner' and item['name'] == 'owner':
          fileInfo['Owner'] = item['value']
        elif item['name'] == 'doc_title':
          fileInfo[fileNameTitle] = item['value']
        elif item['name'] == 'doc_type':
          fileInfo['type'] = item['value']
        elif item['name'] == 'owner_is_shared_drive':
          fileInfo['ownerIsSharedDrive'] = item['boolValue']
        elif item['name'] == 'shared_drive_id':
          fileInfo['driveId'] = item['value']
      else:
        if 'Owner' in fileInfo and 'id' in fileInfo:
          foundIds[fileInfo['id']] = True
          if not csvPF:
            if not FJQC.formatJSON:
              printEntityKVList([Ent.OWNER, fileInfo['Owner']],
                                ['id', fileInfo['id'], fileNameTitle, fileInfo.get(fileNameTitle, ''),
                                 'type', fileInfo.get('type', ''),
                                 'ownerIsSharedDrive', fileInfo.get('ownerIsSharedDrive', False),
                                 'driveId', fileInfo.get('driveId', ''),
                                 'event', fileInfo['event']])
            else:
              printLine(json.dumps(cleanJSON(fileInfo), ensure_ascii=False, sort_keys=True))
          else:
            if addCSVData:
              fileInfo.update(addCSVData)
            row = flattenJSON(fileInfo)
            if not FJQC.formatJSON:
              csvPF.WriteRowTitles(row)
            elif csvPF.CheckRowTitles(row):
              csvPF.WriteRowNoFilter({'JSON': json.dumps(cleanJSON(fileInfo), ensure_ascii=False, sort_keys=True)})
          if entityType == Ent.DRIVE_FILE_OR_FOLDER_ID:
            showComplete = True
            break
      if showComplete:
        break
    if showComplete:
      break
  if not foundIds:
    entityActionFailedWarning([entityType, fileId], Msg.NOT_FOUND)
  if csvPF:
    csvPF.writeCSVfile('Drive File Ownership')

def _getSharedDriveTheme(myarg, body):
  if myarg in {'theme', 'themeid'}:
    body.pop('backgroundImageFile', None)
    body.pop('colorRgb', None)
    body['themeId'] = getString(Cmd.OB_STRING, checkBlank=True)
  elif myarg == 'customtheme':
    body.pop('themeId', None)
    body['backgroundImageFile'] = {
      'id': getString(Cmd.OB_DRIVE_FILE_ID, checkBlank=True),
      'xCoordinate': getFloat(minVal=0.0, maxVal=1.0),
      'yCoordinate': getFloat(minVal=0.0, maxVal=1.0),
      'width': getFloat(minVal=0.0, maxVal=1.0)
      }
  elif myarg in {'color', 'colour'}:
    body.pop('themeId', None)
    body['colorRgb'] = getColor()
  else:
    return False
  return True

SHAREDDRIVE_RESTRICTIONS_MAP = {
  'adminmanagedrestrictions': 'adminManagedRestrictions',
  'allowcontentmanagerstosharefolders': 'sharingFoldersRequiresOrganizerPermission',
  'copyrequireswriterpermission': 'copyRequiresWriterPermission',
  'domainusersonly': 'domainUsersOnly',
  'drivemembersonly': 'driveMembersOnly',
  'sharingfoldersrequiresorganizerpermission': 'sharingFoldersRequiresOrganizerPermission',
  'teammembersonly': 'driveMembersOnly',
  }

def _getSharedDriveRestrictions(myarg, body):
  def _setRestriction(restriction):
    body.setdefault('restrictions', {})
    if restriction != 'allowcontentmanagerstosharefolders':
      body['restrictions'][SHAREDDRIVE_RESTRICTIONS_MAP[restriction]] = getBoolean()
    else:
      body['restrictions'][SHAREDDRIVE_RESTRICTIONS_MAP[restriction]] = not getBoolean()

  if myarg.startswith('restrictions.'):
    _, subField = myarg.split('.', 1)
    if subField in SHAREDDRIVE_RESTRICTIONS_MAP:
      _setRestriction(subField)
      return True
    invalidChoiceExit(subField, SHAREDDRIVE_RESTRICTIONS_MAP, True)
  if myarg in SHAREDDRIVE_RESTRICTIONS_MAP:
    _setRestriction(myarg)
    return True
  return False

def _moveSharedDriveToOU(orgUnit, orgUnitId, driveId, user, i, count, ci, returnIdOnly):
  action = Act.Get()
  name = f'orgUnits/-/memberships/shared_drive;{driveId}'
  if ci is None:
    ci = buildGAPIObject(API.CLOUDIDENTITY_ORGUNITS_BETA)
  cibody = {'customer': _getCustomersCustomerIdWithC(),
            'destinationOrgUnit': f'orgUnits/{orgUnitId[3:]}'}
  try:
    callGAPI(ci.orgUnits().memberships(), 'move',
             throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN,
                                                         GAPI.INVALID_ARGUMENT, GAPI.ABORTED],
             name=name, body=cibody)
    if not returnIdOnly:
      Act.Set(Act.MOVE)
      entityModifierNewValueActionPerformed([Ent.USER, user, Ent.SHAREDDRIVE, driveId], Act.MODIFIER_TO,
                                            f'{Ent.Singular(Ent.ORGANIZATIONAL_UNIT)}: {orgUnit}', i, count)
  except (GAPI.notFound, GAPI.forbidden, GAPI.aborted, GAPI.badRequest, GAPI.internalError,
          GAPI.noManageTeamDriveAdministratorPrivilege, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, driveId], str(e), i, count)
  except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
    userDriveServiceNotEnabledWarning(user, str(e), i, count)
  Act.Set(action)
  return ci

# gam <UserTypeEntity> create shareddrive <Name> [asadmin]
#	[(theme|themeid <String>) | ([customtheme <DriveFileID> <Float> <Float> <Float>] [color <ColorValue>])]
#	(<SharedDriveRestrictionsFieldName> <Boolean>)*
#	[hide|hidden <Boolean>] [ou|org|orgunit <OrgUnitItem>]
#	[errorretries <Integer>] [updateinitialdelay <Integer>] [updateretrydelay <Integer>]
#	[movetoorgunitdelay <Integer>]
#	[(csv [todrive <ToDriveAttribute>*] (addcsvdata <FieldName> <String>)*) | returnidonly]
def createSharedDrive(users, useDomainAdminAccess=False):
  def waitingForCreationToComplete(sleep_time):
    writeStderr(Ind.Spaces()+Msg.WAITING_FOR_ITEM_CREATION_TO_COMPLETE_SLEEPING.format(Ent.Singular(Ent.SHAREDDRIVE), sleep_time))
    time.sleep(sleep_time)

  requestId = str(uuid.uuid4())
  body = {'name': getString(Cmd.OB_NAME, checkBlank=True)}
  updateBody = {}
  csvPF = None
  addCSVData = {}
  hide = returnIdOnly = False
  orgUnit = orgUnitId = ci = None
  errorRetries = 5
  updateInitialDelay = 10
  updateRetryDelay = 10
  moveToOrgUnitDelay = 20
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if _getSharedDriveTheme(myarg, body):
      pass
    elif _getSharedDriveRestrictions(myarg, updateBody):
      pass
    elif myarg in {'hide', 'hidden'}:
      hide = getBoolean()
    elif myarg in {'ou', 'org', 'orgunit'}:
      orgUnit, orgUnitId = getOrgUnitId()
    elif myarg == 'returnidonly':
      returnIdOnly = True
    elif myarg == 'csv':
      csvPF = CSVPrintFile()
    elif csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif csvPF and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg in ADMIN_ACCESS_OPTIONS:
      useDomainAdminAccess = True
    elif myarg == 'errorretries':
      errorRetries = getInteger(minVal=0, maxVal=10)
    elif myarg == 'updateinitialdelay':
      updateInitialDelay = getInteger(minVal=0, maxVal=60)
    elif myarg == 'updateretrydelay':
      updateRetryDelay = getInteger(minVal=0, maxVal=60)
    elif myarg == 'movetoorgunitdelay':
      moveToOrgUnitDelay = getInteger(minVal=0, maxVal=60)
    else:
      unknownArgumentExit()
  if csvPF:
    csvPF.SetTitles(['User', 'name', 'id'])
    if addCSVData:
      csvPF.AddTitles(sorted(addCSVData.keys()))
  for field in ['backgroundImageFile', 'colorRgb']:
    if field in body:
      updateBody[field] = body.pop(field)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
    if not drive:
      continue
    doUpdate = False
    Act.Set(Act.CREATE)
    retry = 0
    while True:
      try:
        shareddrive = callGAPI(drive.drives(), 'create',
                               bailOnTransientError=True,
                               throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.TRANSIENT_ERROR, GAPI.TEAMDRIVE_ALREADY_EXISTS,
                                                                           GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_FILE_PERMISSIONS,
                                                                           GAPI.DUPLICATE, GAPI.BAD_REQUEST, GAPI.USER_CANNOT_CREATE_TEAMDRIVES],
                               requestId=requestId, body=body, fields='id')
        driveId = shareddrive['id']
        if returnIdOnly:
          writeStdout(f'{driveId}\n')
        elif not csvPF:
          entityActionPerformed([Ent.USER, user, Ent.SHAREDDRIVE_NAME, body['name'], Ent.SHAREDDRIVE_ID, driveId], i, count)
        else:
          row = {'User': user, 'name': body['name'], 'id': driveId}
          if addCSVData:
            row.update(addCSVData)
          csvPF.WriteRow(row)
        doUpdate = True
        break
      except (GAPI.transientError, GAPI.teamDriveAlreadyExists) as e:
        retry += 1
        if retry > errorRetries:
          entityActionFailedWarning([Ent.USER, user, Ent.REQUEST_ID, requestId], str(e), i, count)
          break
        requestId = str(uuid.uuid4())
      except GAPI.duplicate:
        entityActionFailedWarning([Ent.USER, user, Ent.REQUEST_ID, requestId], Msg.DUPLICATE, i, count)
        break
      except (GAPI.insufficientPermissions, GAPI.insufficientFilePermissions, GAPI.badRequest, GAPI.userCannotCreateTeamDrives) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.REQUEST_ID, requestId], str(e), i, count)
        break
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    if not (doUpdate or updateBody or hide or orgUnit):
      continue
    waitingForCreationToComplete(updateInitialDelay)
    created = False
    retry = 0
    while not created:
      try:
        callGAPI(drive.drives(), 'get',
                 throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND],
                 useDomainAdminAccess=useDomainAdminAccess,
                 driveId=driveId, fields='id')
        created = True
        break
      except GAPI.notFound as e:
        retry += 1
        if retry > errorRetries:
          entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, driveId], str(e), i, count)
          break
        waitingForCreationToComplete(updateRetryDelay)
    if not created:
      continue
    try:
      if updateBody:
        Act.Set(Act.UPDATE)
        try:
          callGAPI(drive.drives(), 'update',
                   bailOnInternalError=True,
                   throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN,
                                                               GAPI.NO_MANAGE_TEAMDRIVE_ADMINISTRATOR_PRIVILEGE,
                                                               GAPI.BAD_REQUEST, GAPI.INTERNAL_ERROR, GAPI.PERMISSION_DENIED,
                                                               GAPI.FILE_NOT_FOUND],
                   useDomainAdminAccess=useDomainAdminAccess, driveId=driveId, body=updateBody)
          if not returnIdOnly and not csvPF:
            entityActionPerformed([Ent.USER, user, Ent.SHAREDDRIVE_ID, driveId], i, count)
        except GAPI.fileNotFound as e:
          entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, driveId,
                                     Ent.DRIVE_FILE, body.get('backgroundImageFile', {}).get('id', UNKNOWN)],
                                    str(e), i, count)
      if hide:
        Act.Set(Act.HIDE)
        callGAPI(drive.drives(), 'hide',
                 throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                 driveId=driveId)
        if not returnIdOnly and not csvPF:
          entityActionPerformed([Ent.USER, user, Ent.SHAREDDRIVE_ID, driveId], i, count)
      if orgUnit:
        waitingForCreationToComplete(moveToOrgUnitDelay)
        ci = _moveSharedDriveToOU(orgUnit, orgUnitId, driveId, user, i, count, ci, returnIdOnly or csvPF)
    except (GAPI.notFound, GAPI.forbidden, GAPI.badRequest, GAPI.noManageTeamDriveAdministratorPrivilege) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, driveId], str(e), i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
  if csvPF:
    csvPF.writeCSVfile('SharedDrives')

# gam create shareddrive <Name>
#	[(theme|themeid <String>) | ([customtheme <DriveFileID> <Float> <Float> <Float>] [color <ColorValue>])]
#	(<SharedDriveRestrictionsFieldName> <Boolean>)*
#	[hide|hidden <Boolean>]
#	[errorretries <Integer>] [updateinitialdelay <Integer>] [updateretrydelay <Integer>]
#	[movetoorgunitdelay <Integer>]
#	[(csv [todrive <ToDriveAttribute>*] (addcsvdata <FieldName> <String>)*) | returnidonly]
def doCreateSharedDrive():
  createSharedDrive([_getAdminEmail()], True)

# gam <UserTypeEntity> update shareddrive <SharedDriveEntity> [asadmin] [name <Name>]
#	[(theme|themeid <String>) | ([customtheme <DriveFileID> <Float> <Float> <Float>] [color <ColorValue>])]
#	(<SharedDriveRestrictionsFieldName> <Boolean>)*
#	[hide|hidden <Boolean>] [ou|org|orgunit <OrgUnitItem>]
def updateSharedDrive(users, useDomainAdminAccess=False):
  fileIdEntity = getSharedDriveEntity()
  body = {}
  hide = None
  orgUnit = orgUnitId = ci = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'name':
      body['name'] = getString(Cmd.OB_NAME, checkBlank=True)
    elif myarg in {'ou', 'org', 'orgunit'}:
      orgUnit, orgUnitId = getOrgUnitId()
    elif _getSharedDriveTheme(myarg, body):
      pass
    elif _getSharedDriveRestrictions(myarg, body):
      pass
    elif myarg in {'hide', 'hidden'}:
      hide = getBoolean()
    elif myarg in ADMIN_ACCESS_OPTIONS:
      useDomainAdminAccess = True
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = _validateUserSharedDrive(user, i, count, fileIdEntity, useDomainAdminAccess=useDomainAdminAccess)
    if not drive:
      continue
    try:
      driveId = fileIdEntity['shareddrive']['driveId']
      if body:
        result = callGAPI(drive.drives(), 'update',
                          bailOnInternalError=True,
                          throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.BAD_REQUEST,
                                                                      GAPI.NO_MANAGE_TEAMDRIVE_ADMINISTRATOR_PRIVILEGE,
                                                                      GAPI.INTERNAL_ERROR, GAPI.FILE_NOT_FOUND],
                          useDomainAdminAccess=useDomainAdminAccess, driveId=driveId, body=body, fields='name')
        entityActionPerformed([Ent.USER, user, Ent.SHAREDDRIVE_NAME, result['name'], Ent.SHAREDDRIVE_ID, driveId], i, count)
      if hide is not None:
        if hide:
          Act.Set(Act.HIDE)
          function = 'hide'
        else:
          Act.Set(Act.UNHIDE)
          function = 'unhide'
        callGAPI(drive.drives(), function,
                 throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN],
                 driveId=driveId)
        entityActionPerformed([Ent.USER, user, Ent.SHAREDDRIVE_ID, driveId], i, count)
      if orgUnit:
        ci = _moveSharedDriveToOU(orgUnit, orgUnitId, driveId, user, i, count, ci, False)
    except (GAPI.notFound, GAPI.forbidden, GAPI.badRequest, GAPI.internalError,
            GAPI.noManageTeamDriveAdministratorPrivilege) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, driveId], str(e), i, count)
    except GAPI.fileNotFound as e:
      entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, driveId,
                                 Ent.DRIVE_FILE, body.get('backgroundImageFile', {}).get('id', UNKNOWN)],
                                str(e), i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
    Act.Set(Act.UPDATE)

def doUpdateSharedDrive():
  updateSharedDrive([_getAdminEmail()], True)

# gam <UserTypeEntity> delete shareddrive <SharedDriveEntity>
#	[asadmin [allowitemdeletion]
def deleteSharedDrive(users):
  fileIdEntity = getSharedDriveEntity()
  allowItemDeletion = useDomainAdminAccess = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'nukefromorbit', 'allowitemdeletion'}:
      allowItemDeletion = useDomainAdminAccess = True
    elif myarg in ADMIN_ACCESS_OPTIONS:
      useDomainAdminAccess = True
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = _validateUserSharedDrive(user, i, count, fileIdEntity)
    if not drive:
      continue
    try:
      driveId = fileIdEntity['shareddrive']['driveId']
      callGAPI(drive.drives(), 'delete',
               throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN,
                                                           GAPI.CANNOT_DELETE_RESOURCE_WITH_CHILDREN, GAPI.INSUFFICIENT_FILE_PERMISSIONS,
                                                           GAPI.NO_MANAGE_TEAMDRIVE_ADMINISTRATOR_PRIVILEGE],
               driveId=driveId, allowItemDeletion=allowItemDeletion, useDomainAdminAccess=useDomainAdminAccess)
      entityActionPerformed([Ent.USER, user, Ent.SHAREDDRIVE_ID, driveId], i, count)
    except (GAPI.notFound, GAPI.forbidden,
            GAPI.cannotDeleteResourceWithChildren, GAPI.insufficientFilePermissions,
            GAPI.noManageTeamDriveAdministratorPrivilege) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, driveId], str(e), i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)

# gam delete shareddrive <SharedDriveEntity> [allowitemdeletion]
def doDeleteSharedDrive():
  deleteSharedDrive([_getAdminEmail()])

# gam <UserTypeEntity> hide/unhide shareddrive <SharedDriveEntity>
def hideUnhideSharedDrive(users):
  fileIdEntity = getSharedDriveEntity()
  checkForExtraneousArguments()
  function = 'hide' if Act.Get() == Act.HIDE else 'unhide'
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = _validateUserSharedDrive(user, i, count, fileIdEntity)
    if not drive:
      continue
    try:
      driveId = fileIdEntity['shareddrive']['driveId']
      callGAPI(drive.drives(), function,
               throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.FORBIDDEN],
               driveId=driveId)
      entityActionPerformed([Ent.USER, user, Ent.SHAREDDRIVE_ID, driveId], i, count)
    except (GAPI.notFound, GAPI.forbidden) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, driveId], str(e), i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)

# gam hide/unhide shareddrive <SharedDriveEntity>
def doHideUnhideSharedDrive():
  hideUnhideSharedDrive([_getAdminEmail()])

SHAREDDRIVE_FIELDS_CHOICE_MAP = {
  'backgroundimagefile': 'backgroundImageFile',
  'backgroundimagelink': 'backgroundImageLink',
  'capabilities': 'capabilities',
  'colorrgb': 'colorRgb',
  'createddate': 'createdTime',
  'createdtime': 'createdTime',
  'id': 'id',
  'hidden': 'hidden',
  'name': 'name',
  'restrictions': 'restrictions',
  'themeid': 'themeId',
  }
SHAREDDRIVE_LIST_FIELDS_CHOICE_MAP = {
  'org': 'orgUnitId',
  'orgunit': 'orgUnitId',
  'orgunitid': 'orgUnitId',
  'ou': 'orgUnitId',
  }
SHAREDDRIVE_TIME_OBJECTS = {'createdTime'}
SHAREDDRIVE_ROLES_CAPABILITIES_MAP = {
  'commenter': {'canComment': True, 'canEdit': False},
  'reader': {'canComment': False, 'canEdit': False},
  'writer': {'canEdit': True, 'canTrashChildren': False},
  'fileOrganizer': {'canTrashChildren': True, 'canManageMembers': False},
  'organizer': {'canManageMembers': True},
  }
SHAREDDRIVE_API_GUI_ROLES_MAP = {
  'commenter': 'Commenter',
  'fileOrganizer': 'Content manager',
  'organizer': 'Manager',
  'reader': 'Viewer',
  'writer': 'Contributor',
  'unknown': 'Unknown'
  }

def _getSharedDriveRole(shareddrive):
  if 'capabilities' not in shareddrive:
    return None
  for role, capabilities in iter(SHAREDDRIVE_ROLES_CAPABILITIES_MAP.items()):
    match = True
    for capability in capabilities:
      if capabilities[capability] != shareddrive['capabilities'].get(capability, ''):
        match = False
        break
    if match:
      break
  else:
    role = 'unknown'
  return role

def _showSharedDrive(user, shareddrive, j, jcount, FJQC):
  def _showCapabilitiesRestrictions(field):
    if field in shareddrive:
      printKeyValueList([field, ''])
      Ind.Increment()
      for capability in sorted(shareddrive[field]):
        printKeyValueList([capability, shareddrive[field][capability]])
      Ind.Decrement()

  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(shareddrive, timeObjects=SHAREDDRIVE_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.USER, user, Ent.SHAREDDRIVE, f'{shareddrive["name"]} ({shareddrive["id"]})'], j, jcount)
  Ind.Increment()
  printEntity([Ent.SHAREDDRIVE_ID, shareddrive['id']])
  printEntity([Ent.SHAREDDRIVE_NAME, shareddrive['name']])
  if 'hidden' in shareddrive:
    printKeyValueList(['hidden', shareddrive['hidden']])
  if 'createdTime' in shareddrive:
    printKeyValueList(['createdTime', formatLocalTime(shareddrive['createdTime'])])
  for setting in ['backgroundImageLink', 'colorRgb', 'themeId', 'orgUnit', 'orgUnitId']:
    if setting in shareddrive:
      printKeyValueList([setting, shareddrive[setting]])
  if 'role' in shareddrive:
    printKeyValueList(['role', shareddrive['role']])
  _showCapabilitiesRestrictions('capabilities')
  _showCapabilitiesRestrictions('restrictions')
  Ind.Decrement()

# gam <UserTypeEntity> info shareddrive <SharedDriveEntity>
#	[asadmin]
#	[fields <SharedDriveFieldNameList>]
#	[guiroles [<Boolean>]] [formatjson]
def infoSharedDrive(users, useDomainAdminAccess=False):
  fileIdEntity = getSharedDriveEntity()
  fieldsList = []
  FJQC = FormatJSONQuoteChar()
  guiRoles = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in ADMIN_ACCESS_OPTIONS:
      useDomainAdminAccess = True
    elif getFieldsList(myarg, SHAREDDRIVE_FIELDS_CHOICE_MAP, fieldsList, initialField=['id', 'name']):
      pass
    elif myarg == 'guiroles':
      guiRoles = getBoolean()
    else:
      FJQC.GetFormatJSON(myarg)
  fields = getFieldsFromFieldsList(fieldsList) if fieldsList else '*'
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = _validateUserSharedDrive(user, i, count, fileIdEntity, useDomainAdminAccess=useDomainAdminAccess)
    if not drive:
      continue
    try:
      driveId = fileIdEntity['shareddrive']['driveId']
      shareddrive = callGAPI(drive.drives(), 'get',
                             throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND],
                             useDomainAdminAccess=useDomainAdminAccess,
                             driveId=driveId, fields=fields)
      role = _getSharedDriveRole(shareddrive)
      if role:
        shareddrive['role'] = role if not guiRoles else SHAREDDRIVE_API_GUI_ROLES_MAP[role]
      _showSharedDrive(user, shareddrive, i, count, FJQC)
    except GAPI.notFound as e:
      entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, driveId], str(e), i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)

def doInfoSharedDrive():
  infoSharedDrive([_getAdminEmail()], True)

SHAREDDRIVE_ACL_ROLES_MAP = {
  'commenter': 'commenter',
  'contentmanager': 'fileOrganizer',
  'contributor': 'writer',
  'editor': 'writer',
  'fileorganizer': 'fileOrganizer',
  'fileorganiser': 'fileOrganizer',
  'manager': 'organizer',
  'organizer': 'organizer',
  'organiser': 'organizer',
  'owner': 'organizer',
  'read': 'reader',
  'reader': 'reader',
  'viewer': 'reader',
  'writer': 'writer',
  }

# gam <UserTypeEntity> print shareddrives [todrive <ToDriveAttribute>*]
#	[asadmin [shareddriveadminquery|query <QuerySharedDrive>]]
#	[matchname <REMatchPattern>] [orgunit|org|ou <OrgUnitPath>]
#	(role|roles <SharedDriveACLRoleList>)*
#	[fields <SharedDriveFieldNameList>] [noorgunits [<Boolean>]]
#	[guiroles [<Boolean>]] [formatjson [quotechar <Character>]]
# 	[showitemcountonly]
# gam <UserTypeEntity> show shareddrives
#	[asadmin [shareddriveadminquery|query <QuerySharedDrive>]]
#	[matchname <REMatchPattrn>] [orgunit|org|ou <OrgUnitPath>]
#	(role|roles <SharedDriveACLRoleLIst>)*
#	[fields <SharedDriveFieldNameList>] [noorgunits [<Boolean>]]
#	[guiroles [<Boolean>]] [formatjson]
# 	[showitemcountonly]
def printShowSharedDrives(users, useDomainAdminAccess=False):
  def stripNonShowFields(shareddrive):
    if orgUnitIdToPathMap:
      td_ouid = shareddrive.get('orgUnitId')
      if td_ouid:
        shareddrive['orgUnit'] = orgUnitIdToPathMap.get(f'id:{td_ouid}', UNKNOWN)
    if not showFields:
      return shareddrive
    sshareddrive = {}
    for field in showFields:
      if field in shareddrive:
        sshareddrive[field] = shareddrive[field]
    return sshareddrive

  csvPF = CSVPrintFile(['User', 'id', 'name'], ['User', 'id', 'name', 'role']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  roles = set()
  cd = orgUnitId = query = matchPattern = None
  showFields = set()
  fieldsList = []
  SHAREDDRIVE_FIELDS_CHOICE_MAP.update(SHAREDDRIVE_LIST_FIELDS_CHOICE_MAP)
  showOrgUnitPaths = True
  orgUnitIdToPathMap = None
  guiRoles = showItemCountOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'teamdriveadminquery', 'shareddriveadminquery', 'query'}:
      queryLocation = Cmd.Location()
      query = getString(Cmd.OB_QUERY, minLen=0) or None
      if query:
        query = mapQueryRelativeTimes(query, ['createdTime'])
    elif myarg == 'matchname':
      matchPattern = getREPattern(re.IGNORECASE)
    elif myarg in {'ou', 'org', 'orgunit'}:
      orgLocation = Cmd.Location()
      if cd is None:
        cd = buildGAPIObject(API.DIRECTORY)
      _, orgUnitId = getOrgUnitId(cd)
      orgUnitId = orgUnitId[3:]
    elif myarg in {'role', 'roles'}:
      roles |= getACLRoles(SHAREDDRIVE_ACL_ROLES_MAP)
    elif myarg == 'checkgroups':
      pass
    elif myarg in ADMIN_ACCESS_OPTIONS:
      useDomainAdminAccess = True
    elif getFieldsList(myarg, SHAREDDRIVE_FIELDS_CHOICE_MAP, fieldsList, initialField=['id', 'name']):
      pass
    elif myarg == 'noorgunits':
      showOrgUnitPaths = not getBoolean()
    elif myarg == 'guiroles':
      guiRoles = getBoolean()
    elif myarg == 'showitemcountonly':
      showItemCountOnly = True
      showOrgUnitPaths = False
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if query and not useDomainAdminAccess:
    Cmd.SetLocation(queryLocation-1)
    usageErrorExit(Msg.ONLY_ADMINISTRATORS_CAN_PERFORM_SHARED_DRIVE_QUERIES)
  if orgUnitId is not None and not useDomainAdminAccess:
    Cmd.SetLocation(orgLocation-1)
    usageErrorExit(Msg.ONLY_ADMINISTRATORS_CAN_SPECIFY_SHARED_DRIVE_ORGUNIT)
  if fieldsList:
    showFields = set(fieldsList)
  if csvPF and not useDomainAdminAccess:
    if fieldsList:
      showFields.add('role')
    csvPF.AddTitle('role')
    if FJQC.formatJSON:
      csvPF.AddJSONTitles(['role'])
      csvPF.MoveJSONTitlesToEnd(['JSON'])
  if showOrgUnitPaths and useDomainAdminAccess and ((not showFields) or ('orgUnitId' in showFields)):
    orgUnitIdToPathMap = getOrgUnitIdToPathMap(cd)
    if showFields:
      showFields.add('orgUnit')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
    if not drive:
      continue
    if useDomainAdminAccess:
      printGettingAllAccountEntities(Ent.SHAREDDRIVE, query)
      pageMessage = getPageMessage()
    else:
      printGettingAllEntityItemsForWhom(Ent.SHAREDDRIVE, user, i, count, query)
      pageMessage = getPageMessageForWhom()
    try:
      feed = callGAPIpages(drive.drives(), 'list', 'drives',
                           pageMessage=pageMessage,
                           throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID,
                                                                       GAPI.QUERY_REQUIRES_ADMIN_CREDENTIALS,
                                                                       GAPI.NO_LIST_TEAMDRIVES_ADMINISTRATOR_PRIVILEGE,
                                                                       GAPI.FILE_NOT_FOUND],
                           q=query, useDomainAdminAccess=useDomainAdminAccess,
                           fields='*', pageSize=100)
    except (GAPI.invalidQuery, GAPI.invalid, GAPI.queryRequiresAdminCredentials,
            GAPI.noListTeamDrivesAdministratorPrivilege, GAPI.fileNotFound) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE, None], str(e), i, count)
      continue
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
      continue
    matchedFeed = []
    if not useDomainAdminAccess:
      for shareddrive in feed:
        if matchPattern is not None and matchPattern.match(shareddrive['name']) is None:
          continue
        role = _getSharedDriveRole(shareddrive)
        if not roles or role in roles:
          shareddrive['role'] = role if not guiRoles else SHAREDDRIVE_API_GUI_ROLES_MAP[role]
          matchedFeed.append(shareddrive)
    elif matchPattern is not None or orgUnitId is not None:
      for shareddrive in feed:
        if ((matchPattern is not None and matchPattern.match(shareddrive['name']) is None) or
            (orgUnitId is not None and orgUnitId != shareddrive.get('orgUnitId'))):
          continue
        matchedFeed.append(shareddrive)
    else:
      matchedFeed = feed
    jcount = len(matchedFeed)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
    if showItemCountOnly:
      writeStdout(f'{jcount}\n')
      return
    if not csvPF:
      if not FJQC.formatJSON:
        entityPerformActionNumItems([Ent.USER, user], jcount, Ent.SHAREDDRIVE, i, count)
      Ind.Increment()
      j = 0
      for shareddrive in matchedFeed:
        j += 1
        shareddrive = stripNonShowFields(shareddrive)
        _showSharedDrive(user, shareddrive, j, jcount, FJQC)
      Ind.Decrement()
    else:
      for shareddrive in matchedFeed:
        shareddrive = stripNonShowFields(shareddrive)
        if FJQC.formatJSON:
          row = {'User': user, 'id': shareddrive['id'], 'name': shareddrive['name']}
          if not useDomainAdminAccess:
            row['role'] = shareddrive['role'] if not guiRoles else SHAREDDRIVE_API_GUI_ROLES_MAP[shareddrive['role']]
          row['JSON'] = json.dumps(cleanJSON(shareddrive, timeObjects=SHAREDDRIVE_TIME_OBJECTS), ensure_ascii=False, sort_keys=True)
          csvPF.WriteRow(row)
        else:
          csvPF.WriteRowTitles(flattenJSON(shareddrive, flattened={'User': user}, timeObjects=SHAREDDRIVE_TIME_OBJECTS))
  if csvPF:
    csvPF.writeCSVfile('SharedDrives')

def doPrintShowSharedDrives():
  printShowSharedDrives([_getAdminEmail()], True)

# gam print oushareddrives [todrive <ToDriveAttribute>*]
#	[ou|org|orgunit <OrgUnitPath>]
#	[formatjson [quotechar <Character>]]
# 	[showitemcountonly]
# gam show oushareddrives
#	[ou|org|orgunit <OrgUnitPath>]
#	[formatjson]
# 	[showitemcountonly]
def doPrintShowOrgunitSharedDrives():
  def _getOrgUnitSharedDriveInfo(shareddrive):
    shareddrive['driveId'] = shareddrive['name'].rsplit(';')[1]
    shareddrive['driveName'] = _getSharedDriveNameFromId(drive, shareddrive['driveId'], True)
    shareddrive['orgUnitPath'] = orgUnitPath

  def _showOrgUnitSharedDrive(shareddrive, j, jcount, FJQC):
    if FJQC.formatJSON:
      printLine(json.dumps(cleanJSON(shareddrive), ensure_ascii=False, sort_keys=True))
      return
    printEntity([Ent.NAME, f'{shareddrive["name"]}'], j, jcount)
    Ind.Increment()
    printEntity([Ent.TYPE, shareddrive['type']])
    printEntity([Ent.MEMBER, shareddrive['member']])
    printEntity([Ent.MEMBER_URI, shareddrive['memberUri']])
    printEntity([Ent.SHAREDDRIVE_ID, shareddrive['driveId']])
    printEntity([Ent.SHAREDDRIVE_NAME, shareddrive['driveName']])
    printEntity([Ent.ORGANIZATIONAL_UNIT, shareddrive['orgUnitPath']])
    Ind.Decrement()

  ci = buildGAPIObject(API.CLOUDIDENTITY_ORGUNITS_BETA)
  cd = buildGAPIObject(API.DIRECTORY)
  _, drive = buildGAPIServiceObject(API.DRIVE3, _getAdminEmail())
  if drive is None:
    return
  csvPF = CSVPrintFile(['name', 'type', 'member', 'memberUri', 'driveId', 'driveName', 'orgUnitPath']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  orgUnitPath = '/'
  showItemCountOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'ou', 'org', 'orgunit'}:
      orgUnitPath = getString(Cmd.OB_ORGUNIT_ITEM)
    elif myarg == 'showitemcountonly':
      showItemCountOnly = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if csvPF and FJQC.formatJSON:
    csvPF.SetJSONTitles(['name', 'JSON'])
  orgUnitPath, orgUnitId = getOrgUnitId(cd, orgUnitPath)
  printGettingAllEntityItemsForWhom(Ent.SHAREDDRIVE, orgUnitPath, entityType=Ent.ORGANIZATIONAL_UNIT)
  sds = callGAPIpages(ci.orgUnits().memberships(), 'list', 'orgMemberships',
                      pageMessage=getPageMessageForWhom(),
                      parent=f'orgUnits/{orgUnitId[3:]}',
                      customer=_getCustomersCustomerIdWithC(),
                      filter="type == 'shared_drive'")
  jcount = len(sds)
  if jcount == 0:
    setSysExitRC(NO_ENTITIES_FOUND_RC)
  if showItemCountOnly:
    writeStdout(f'{jcount}\n')
    return
  if not csvPF:
    if not FJQC.formatJSON:
      entityPerformActionNumItems([Ent.ORGANIZATIONAL_UNIT, orgUnitPath], jcount, Ent.SHAREDDRIVE)
    Ind.Increment()
    j = 0
    for shareddrive in sds:
      j += 1
      _getOrgUnitSharedDriveInfo(shareddrive)
      _showOrgUnitSharedDrive(shareddrive, j, jcount, FJQC)
    Ind.Decrement()
  else:
    for shareddrive in sds:
      _getOrgUnitSharedDriveInfo(shareddrive)
      if FJQC.formatJSON:
        row = {'name': shareddrive['name']}
        row['JSON'] = json.dumps(cleanJSON(shareddrive), ensure_ascii=False, sort_keys=True)
        csvPF.WriteRow(row)
      else:
        csvPF.WriteRowTitles(flattenJSON(shareddrive))
  if csvPF:
    csvPF.writeCSVfile('OrgUnit {orgUnitPath} SharedDrives')

# gam [<UserTypeEntity>] copy shareddriveacls <SharedDriveEntity> to <SharedDriveEntity>
# gam [<UserTypeEntity>] sync shareddriveacls <SharedDriveEntity> with <SharedDriveEntity>
#	[asadmin]
#	[showpermissionsmessages [<Boolean>]]
#	[excludepermissionsfromdomains|includepermissionsfromdomains <DomainNameList>]
#	(mappermissionsdomain <DomainName> <DomainName>)*
def copySyncSharedDriveACLs(users, useDomainAdminAccess=False):
  copyMoveOptions = initCopyMoveOptions(True)
  srcFileIdEntity = getSharedDriveEntity()
  checkArgumentPresent(['to', 'with'], True)
  tgtFileIdEntity = getSharedDriveEntity()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if getCopyMoveOptions(myarg, copyMoveOptions):
      pass
    elif myarg in ADMIN_ACCESS_OPTIONS:
      useDomainAdminAccess = True
    else:
      unknownArgumentExit()
  copyMoveOptions['useDomainAdminAccess'] = useDomainAdminAccess
  copyMoveOptions['copyTopFolderNonInheritedPermissions'] =\
    COPY_NONINHERITED_PERMISSIONS_ALWAYS if Act.Get() == Act.COPY else COPY_NONINHERITED_PERMISSIONS_SYNC_ALL_FOLDERS
  copyMoveOptions['copyMergeWithParentFolderPermissions'] = True
  copyMoveOptions['copyTopFolderInheritedPermissions'] = False
  copyMoveOptions['destParentType'] = DEST_PARENT_SHAREDDRIVE_ROOT
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = _validateUserSharedDrive(user, i, count, srcFileIdEntity)
    if not drive:
      continue
    if not srcFileIdEntity.get('shareddrivename'):
      srcFileIdEntity['shareddrivename'] = _getSharedDriveNameFromId(drive, srcFileIdEntity['shareddrive']['driveId'])
    if tgtFileIdEntity.get('shareddrivename'):
      if not _convertSharedDriveNameToId(drive, user, i, count, tgtFileIdEntity, useDomainAdminAccess):
        continue
      tgtFileIdEntity['shareddrive']['corpora'] = 'drive'
    else:
      tgtFileIdEntity['shareddrivename'] = _getSharedDriveNameFromId(drive, tgtFileIdEntity['shareddrive']['driveId'])
    statistics = _initStatistics()
    copyMoveOptions['sourceDriveId'] = srcFileIdEntity['shareddrive']['driveId']
    copyMoveOptions['destDriveId'] = tgtFileIdEntity['shareddrive']['driveId']
    entityPerformActionModifierItemValueList([Ent.USER, user, Ent.SHAREDDRIVE, srcFileIdEntity['shareddrivename']],
                                             f"{Ent.Plural(Ent.PERMISSION)} {Act.MODIFIER_TO}",
                                             [Ent.SHAREDDRIVE, tgtFileIdEntity['shareddrivename']], i, count)
    _copyPermissions(drive, user, i, count, 0, 0,
                     Ent.SHAREDDRIVE, srcFileIdEntity['shareddrive']['driveId'], srcFileIdEntity['shareddrivename'],
                     tgtFileIdEntity['shareddrive']['driveId'], tgtFileIdEntity['shareddrivename'],
                     statistics, STAT_FOLDER_PERMISSIONS_FAILED,
                     copyMoveOptions, True,
                     'copyTopFolderInheritedPermissions',
                     'copyTopFolderNonInheritedPermissions',
                     False)

def doCopySyncSharedDriveACLs():
  copySyncSharedDriveACLs([_getAdminEmail()], True)

SHOW_NO_PERMISSIONS_DRIVES_CHOICE_MAP = {
  'true': 1,
  'false': 0,
  'only': -1,
  }

# gam [<UserTypeEntity>] print shareddriveacls [todrive <ToDriveAttribute>*]
#	[asadmin] [shareddriveadminquery|query <QuerySharedDrive>]
#	[matchname <REMatchPattern>] [orgunit|org|ou <OrgUnitPath>]
#	[user|group <EmailAddress> [checkgroups]] (role|roles <SharedDriveACLRoleList>)*
#	<PermissionMatch>* [<PermissionMatchAction>] [pmselect]
#	[oneitemperrow] [maxitems <Integer>]
#	[shownopermissionsdrives false|true|only]
#	[<DrivePermissionsFieldName>*|(fields <DrivePermissionsFieldNameList>)]
#	(addcsvdata <FieldName> <String>)*
#	[formatjson [quotechar <Character>]]
# gam [<UserTypeEntity>] show shareddriveacls
#	[asadmin] [shareddriveadminquery|query <QuerySharedDrive>]
#	[matchname <REMatchPattern>] [orgunit|org|ou <OrgUnitPath>]
#	[user|group <EmailAddress> [checkgroups]] (role|roles <SharedDriveACLRoleList>)*
#	<PermissionMatch>* [<PermissionMatchAction>] [pmselect]
#	[oneitemperrow] [maxitems <Integer>]
#	[shownopermissionsdrives false|true|only]
#	[<DrivePermissionsFieldName>*|(fields <DrivePermissionsFieldNameList>)]
#	[formatjson]
def printShowSharedDriveACLs(users, useDomainAdminAccess=False):
  def _printPermissionRow(baserow, permission):
    row = baserow.copy()
    flattenJSON({'permission': permission}, flattened=row, timeObjects=timeObjects)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      row = baserow.copy()
      row['JSON'] = json.dumps(cleanJSON({'permission': permission}, timeObjects=timeObjects),
                               ensure_ascii=False, sort_keys=True)
      csvPF.WriteRowNoFilter(row)

  csvPF = CSVPrintFile(['User', 'id', 'name', 'createdTime'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  roles = set()
  checkGroups = oneItemPerRow = pmselect = False
  showNoPermissionsDrives = SHOW_NO_PERMISSIONS_DRIVES_CHOICE_MAP['false']
  fieldsList = []
  cd = emailAddress = orgUnitId = query = matchPattern = permtype = None
  PM = PermissionMatch()
  maxItems = 0
  addCSVData = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'teamdriveadminquery', 'shareddriveadminquery', 'query'}:
      queryLocation = Cmd.Location()
      query = getString(Cmd.OB_QUERY, minLen=0) or None
      if query:
        query = mapQueryRelativeTimes(query, ['createdTime'])
    elif myarg == 'matchname':
      matchPattern = getREPattern(re.IGNORECASE)
    elif myarg in {'ou', 'org', 'orgunit'}:
      orgLocation = Cmd.Location()
      if cd is None:
        cd = buildGAPIObject(API.DIRECTORY)
      _, orgUnitId = getOrgUnitId(cd)
      orgUnitId = orgUnitId[3:]
    elif myarg in {'user', 'group'}:
      permtype = myarg
      emailAddress = getEmailAddress(noUid=True)
    elif myarg in {'role', 'roles'}:
      roles |= getACLRoles(SHAREDDRIVE_ACL_ROLES_MAP)
    elif myarg == 'checkgroups':
      checkGroups = True
    elif myarg == 'oneitemperrow':
      oneItemPerRow = True
    elif myarg == 'maxitems':
      maxItems = getInteger(minVal=0)
    elif getDriveFilePermissionsFields(myarg, fieldsList):
      pass
    elif myarg in ADMIN_ACCESS_OPTIONS:
      useDomainAdminAccess = True
    elif PM.ProcessArgument(myarg):
      pass
    elif myarg == 'pmselect':
      pmselect = True
    elif myarg == 'shownopermissionsdrives':
      showNoPermissionsDrives = getChoice(SHOW_NO_PERMISSIONS_DRIVES_CHOICE_MAP, mapChoice=True)
    elif csvPF and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if query and not useDomainAdminAccess:
    Cmd.SetLocation(queryLocation-1)
    usageErrorExit(Msg.ONLY_ADMINISTRATORS_CAN_PERFORM_SHARED_DRIVE_QUERIES)
  if orgUnitId is not None and not useDomainAdminAccess:
    Cmd.SetLocation(orgLocation-1)
    usageErrorExit(Msg.ONLY_ADMINISTRATORS_CAN_SPECIFY_SHARED_DRIVE_ORGUNIT)
  if fieldsList:
    if permtype is not None:
      fieldsList.extend(['type', 'emailAddress'])
    if roles:
      fieldsList.append('role')
  fields = getItemFieldsFromFieldsList('permissions', fieldsList, True)
  printKeys, timeObjects = _getDriveFileACLPrintKeysTimeObjects()
  if checkGroups:
    if emailAddress:
      cd = buildGAPIObject(API.DIRECTORY)
      try:
        groups = callGAPIpages(cd.groups(), 'list', 'groups',
                               throwReasons=GAPI.GROUP_LIST_USERKEY_THROW_REASONS,
                               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                               userKey=emailAddress, orderBy='email', fields='nextPageToken,groups(email)')
      except (GAPI.invalidMember, GAPI.invalidInput):
        badRequestWarning(Ent.GROUP, Ent.MEMBER, emailAddress)
        return
      except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.forbidden, GAPI.badRequest):
        accessErrorExit(cd)
      groupsSet = {group['email'] for group in groups}
    else:
      checkGroups = False
  if csvPF and addCSVData:
    csvPF.AddTitles(sorted(addCSVData.keys()))
    if FJQC.formatJSON:
      csvPF.AddJSONTitles(sorted(addCSVData.keys()))
      csvPF.MoveJSONTitlesToEnd(['JSON'])
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
    if not drive:
      continue
    feed = None
    if permtype == 'user':
      _, userdrive = buildGAPIServiceObject(API.DRIVE3, emailAddress, displayError=False)
      if userdrive is not None:
        try:
          feed = callGAPIpages(userdrive.drives(), 'list', 'drives',
                               throwReasons=GAPI.DRIVE_USER_THROW_REASONS,
                               fields='nextPageToken,drives(id,name,createdTime,orgUnitId)', pageSize=100)
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy):
          pass
    if feed is None:
      if useDomainAdminAccess:
        printGettingAllAccountEntities(Ent.SHAREDDRIVE, query)
        pageMessage = getPageMessage()
      else:
        printGettingAllEntityItemsForWhom(Ent.SHAREDDRIVE, user, i, count, query)
        pageMessage = getPageMessageForWhom()
      try:
        feed = callGAPIpages(drive.drives(), 'list', 'drives',
                             pageMessage=pageMessage,
                             throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID,
                                                                         GAPI.QUERY_REQUIRES_ADMIN_CREDENTIALS,
                                                                         GAPI.NO_LIST_TEAMDRIVES_ADMINISTRATOR_PRIVILEGE],
                             q=query, useDomainAdminAccess=useDomainAdminAccess,
                             fields='nextPageToken,drives(id,name,createdTime,orgUnitId)', pageSize=100)
      except (GAPI.invalidQuery, GAPI.invalid, GAPI.queryRequiresAdminCredentials, GAPI.noListTeamDrivesAdministratorPrivilege) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE, None], str(e), i, count)
        continue
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        continue
    matchFeed = []
    jcount = len(feed)
    j = 0
    for shareddrive in feed:
      j += 1
      if ((matchPattern is not None and matchPattern.match(shareddrive['name']) is None) or
          (orgUnitId is not None and orgUnitId != shareddrive.get('orgUnitId'))):
        continue
      printGettingAllEntityItemsForWhom(Ent.PERMISSION, shareddrive['name'], j, jcount)
      shareddrive['createdTime'] = formatLocalTime(shareddrive['createdTime'])
      shareddrive['permissions'] = []
      try:
        permissions = callGAPIpages(drive.permissions(), 'list', 'permissions',
                                    pageMessage=getPageMessageForWhom(),
                                    throwReasons=GAPI.DRIVE3_GET_ACL_REASONS,
                                    retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                    useDomainAdminAccess=useDomainAdminAccess,
                                    fileId=shareddrive['id'], fields=fields, supportsAllDrives=True)
        if not permissions:
          if showNoPermissionsDrives == 0: # no permissions and showNoPermissionDrives False - ignore
            continue
          matchFeed.append(shareddrive) # no permissions and showNoPermissionDrives Only/True - keep
          continue
        if showNoPermissionsDrives < 0: # permissions and showNoPermissionDrives Only/True - ignore
          continue
        if pmselect:
          if not PM.CheckPermissionMatches(permissions):
            continue
        else:
          permissions = PM.GetMatchingPermissions(permissions)
        for permission in permissions:
          if roles and permission['role'] not in roles:
            continue
          permission.pop('teamDrivePermissionDetails', None)
          if permtype is None:
            shareddrive['permissions'].append(permission)
          elif permission['type'] == permtype and permission['emailAddress'] == emailAddress:
            shareddrive['permissions'].append(permission)
          elif checkGroups and permission['emailAddress'] in groupsSet:
            shareddrive['permissions'].append(permission)
        if shareddrive['permissions']:
          numItems = len(shareddrive['permissions'])
          if numItems > maxItems > 0:
            shareddrive['permissions'] = shareddrive['permissions'][0:maxItems]
          matchFeed.append(shareddrive)
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError,
              GAPI.insufficientAdministratorPrivileges, GAPI.insufficientFilePermissions,
              GAPI.unknownError, GAPI.invalid):
        pass
    jcount = len(matchFeed)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
    if not csvPF:
      if not FJQC.formatJSON:
        entityPerformActionNumItems([Ent.USER, user], jcount, Ent.SHAREDDRIVE, i, count)
      Ind.Increment()
      j = 0
      for shareddrive in matchFeed:
        j += 1
        if not FJQC.formatJSON:
          _showDriveFilePermissions(Ent.SHAREDDRIVE, f'{shareddrive["name"]} ({shareddrive["id"]}) - {shareddrive["createdTime"]}',
                                    shareddrive['permissions'], printKeys, timeObjects, j, jcount)
        else:
          if oneItemPerRow and shareddrive['permissions']:
            for permission in shareddrive['permissions']:
              _showDriveFilePermissionJSON(user, shareddrive['id'], shareddrive['name'], shareddrive['createdTime'], permission, timeObjects)
          else:
            _showDriveFilePermissionsJSON(user, shareddrive['id'], shareddrive['name'], shareddrive['createdTime'], shareddrive['permissions'], timeObjects)
      Ind.Decrement()
    elif matchFeed:
      if oneItemPerRow:
        for shareddrive in matchFeed:
          baserow = {'User': user, 'id': shareddrive['id'], 'name': shareddrive['name'], 'createdTime': shareddrive['createdTime']}
          if addCSVData:
            baserow.update(addCSVData)
          if shareddrive['permissions']:
            for permission in shareddrive['permissions']:
              _mapDrivePermissionNames(permission)
              pdetails = permission.pop('permissionDetails', [])
              if not pdetails:
                _printPermissionRow(baserow, permission)
              else:
                for pdetail in pdetails:
                  permission['permissionDetails'] = pdetail
                  _printPermissionRow(baserow, permission)
          else:
            if not FJQC.formatJSON:
              csvPF.WriteRowTitles(baserow)
            elif csvPF.CheckRowTitles(baserow):
              baserow['JSON'] = json.dumps({})
              csvPF.WriteRowNoFilter(baserow)
      else:
        for shareddrive in matchFeed:
          baserow = {'User': user, 'id': shareddrive['id'], 'name': shareddrive['name'], 'createdTime': shareddrive['createdTime']}
          if addCSVData:
            baserow.update(addCSVData)
          row = baserow.copy()
          if shareddrive['permissions']:
            for permission in shareddrive['permissions']:
              _mapDrivePermissionNames(permission)
            flattenJSON({'permissions': shareddrive['permissions']}, flattened=row, timeObjects=timeObjects)
            if not FJQC.formatJSON:
              csvPF.WriteRowTitles(row)
            elif csvPF.CheckRowTitles(row):
              baserow['JSON'] = json.dumps(cleanJSON({'permissions': shareddrive['permissions']}, timeObjects=timeObjects),
                                           ensure_ascii=False, sort_keys=True)
              csvPF.WriteRowNoFilter(baserow)
          else:
            if not FJQC.formatJSON:
              csvPF.WriteRowTitles(baserow)
            elif csvPF.CheckRowTitles(baserow):
              baserow['JSON'] = json.dumps({})
              csvPF.WriteRowNoFilter(baserow)
  if csvPF:
    if not oneItemPerRow:
      csvPF.SetIndexedTitles(['permissions'])
    csvPF.writeCSVfile('SharedDrive ACLs')

def doPrintShowSharedDriveACLs():
  printShowSharedDriveACLs([_getAdminEmail()], True)

PRINT_ORGANIZER_TYPES = {'group', 'user'}

# gam [<UserTypeEntity>] print shareddriveorganizers [todrive <ToDriveAttribute>*]
#	[adminaccess|asadmin]
#	[(shareddriveadminquery|query <QuerySharedDrive>) |
#	 (shareddrives|teamdrives (<SharedDriveIDList>|(select <FileSelector>|<CSVFileSelector>)))]
#	[matchname <REMatchPattern>] [orgunit|org|ou <OrgUnitPath>]
#	[domainlist <DomainList>]
#	[includetypes user|group]
#	[oneorganizer [<Boolean>]]
#	[shownorganizerdrives false|true|only]
#	[includefileorganizers [<Boolean>]]
#	[delimiter <Character>]
def printSharedDriveOrganizers(users, useDomainAdminAccess=False):
  csvPF = CSVPrintFile(['id', 'name', 'organizers', 'createdTime'], 'sortall')
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  roles = set(['organizer'])
  includeTypes = set()
  showNoOrganizerDrives = SHOW_NO_PERMISSIONS_DRIVES_CHOICE_MAP['false']
  fieldsList = ['role', 'type', 'emailAddress']
  cd = entityList = orgUnitId = query = matchPattern = None
  domainList = []
  oneOrganizer = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    elif myarg in {'shareddrive', 'shareddrives', 'teamdrive', 'teamdrives'}:
      sharedDriveArg = myarg
      itemList = getString(Cmd.OB_SHAREDDRIVE_ID_LIST)
      if itemList != 'select':
        entityList =  itemList.replace(',', ' ').split()
      else:
        entityList = getEntityList(Cmd.OB_SHAREDDRIVE_ID_LIST)
    elif myarg in {'teamdriveadminquery', 'shareddriveadminquery', 'query'}:
      queryArg = myarg
      queryLocation = Cmd.Location()
      query = getString(Cmd.OB_QUERY, minLen=0) or None
      if query:
        query = mapQueryRelativeTimes(query, ['createdTime'])
    elif myarg == 'matchname':
      matchPattern = getREPattern(re.IGNORECASE)
    elif myarg in {'ou', 'org', 'orgunit'}:
      orgLocation = Cmd.Location()
      if cd is None:
        cd = buildGAPIObject(API.DIRECTORY)
      orgUnitPath, orgUnitId = getOrgUnitId(cd)
      orgUnitId = orgUnitId[3:]
      orgUnitInfo = {'orgUnit': orgUnitPath, 'orgUnitId': orgUnitId}
    elif myarg in ADMIN_ACCESS_OPTIONS:
      useDomainAdminAccess = True
    elif myarg == 'domainlist':
      domainList = set(getString(Cmd.OB_DOMAIN_NAME_LIST).replace(',', ' ').lower().split())
    elif myarg == 'includetypes':
      for itype in getString(Cmd.OB_ORGANIZER_TYPE_LIST).lower().replace(',', ' ').split():
        if itype in PRINT_ORGANIZER_TYPES:
          includeTypes.add(itype)
        else:
          invalidChoiceExit(itype, PRINT_ORGANIZER_TYPES, True)
    elif myarg == 'oneorganizer':
      oneOrganizer = getBoolean()
    elif myarg == 'shownoorganizerdrives':
      showNoOrganizerDrives = getChoice(SHOW_NO_PERMISSIONS_DRIVES_CHOICE_MAP, defaultChoice=1, mapChoice=True)
    elif myarg in {'includefileorganizers', 'includecontentmanagers'}:
      if getBoolean():
        roles.add('fileOrganizer')
    else:
      unknownArgumentExit()
  if query:
    if not useDomainAdminAccess:
      Cmd.SetLocation(queryLocation-1)
      usageErrorExit(Msg.ONLY_ADMINISTRATORS_CAN_PERFORM_SHARED_DRIVE_QUERIES)
    if entityList:
      Cmd.SetLocation(queryLocation-1)
      usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format(queryArg, sharedDriveArg))
  if orgUnitId is not None:
    if not useDomainAdminAccess:
      Cmd.SetLocation(orgLocation-1)
      usageErrorExit(Msg.ONLY_ADMINISTRATORS_CAN_SPECIFY_SHARED_DRIVE_ORGUNIT)
    csvPF.AddTitles(['orgUnit', 'orgUnitId'])
  if not includeTypes:
    includeTypes = PRINT_ORGANIZER_TYPES
  fields = getItemFieldsFromFieldsList('permissions', fieldsList, True)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
    if not drive:
      continue
    if entityList is None:
      if useDomainAdminAccess:
        printGettingAllAccountEntities(Ent.SHAREDDRIVE, query)
        pageMessage = getPageMessage()
      else:
        printGettingAllEntityItemsForWhom(Ent.SHAREDDRIVE, user, i, count, query)
        pageMessage = getPageMessageForWhom()
      try:
        feed = callGAPIpages(drive.drives(), 'list', 'drives',
                             pageMessage=pageMessage,
                             throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.INVALID_QUERY, GAPI.INVALID,
                                                                         GAPI.QUERY_REQUIRES_ADMIN_CREDENTIALS,
                                                                         GAPI.NO_LIST_TEAMDRIVES_ADMINISTRATOR_PRIVILEGE,
                                                                         GAPI.FILE_NOT_FOUND],
                             q=query, useDomainAdminAccess=useDomainAdminAccess,
                             fields='nextPageToken,drives(id,name,createdTime,orgUnitId)', pageSize=100)
      except (GAPI.invalidQuery, GAPI.invalid, GAPI.queryRequiresAdminCredentials,
              GAPI.noListTeamDrivesAdministratorPrivilege, GAPI.fileNotFound) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SHAREDDRIVE, None], str(e), i, count)
        continue
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        continue
    else:
      feed = []
      jcount = len(entityList)
      j = 0
      for driveId in entityList:
        j +=1
        try:
          feed.append(callGAPI(drive.drives(), 'get',
                               throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.NOT_FOUND],
                               useDomainAdminAccess=useDomainAdminAccess,
                               driveId=driveId, fields='id,name,createdTime,orgUnitId'))
        except (GAPI.fileNotFound, GAPI.notFound) as e:
          entityActionNotPerformedWarning([Ent.USER, user, Ent.SHAREDDRIVE_ID, driveId], str(e), j, jcount)
          continue
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          userDriveServiceNotEnabledWarning(user, str(e), i, count)
          break
    matchFeed = []
    jcount = len(feed)
    j = 0
    for shareddrive in feed:
      j += 1
      if ((matchPattern is not None and matchPattern.match(shareddrive['name']) is None) or
          (orgUnitId is not None and orgUnitId != shareddrive.get('orgUnitId'))):
        continue
      printGettingAllEntityItemsForWhom(Ent.PERMISSION, shareddrive['name'], j, jcount)
      shareddrive['createdTime'] = formatLocalTime(shareddrive['createdTime'])
      shareddrive['organizers'] = []
      try:
        permissions = callGAPIpages(drive.permissions(), 'list', 'permissions',
                                    pageMessage=getPageMessageForWhom(),
                                    throwReasons=GAPI.DRIVE3_GET_ACL_REASONS,
                                    retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                    useDomainAdminAccess=useDomainAdminAccess,
                                    fileId=shareddrive['id'], fields=fields, supportsAllDrives=True)
        for permission in permissions:
          if permission['type'] in includeTypes and permission['role'] in roles:
            if domainList:
              _, domain = permission['emailAddress'].lower().split('@', 1)
              if domain not in domainList:
                continue
            shareddrive['organizers'].append(permission['emailAddress'])
            if oneOrganizer:
              break
        if not shareddrive['organizers']:
          if showNoOrganizerDrives == 0: # no organizers and showNoOrganizerDrives False - ignore
            continue
          matchFeed.append(shareddrive) # no organizers and showNoOrganizerDrives Only/True - keep
          continue
        if showNoOrganizerDrives < 0: # organizers and showNoOrganizerDrives Only/True - ignore
          continue
        matchFeed.append(shareddrive)
      except (GAPI.fileNotFound, GAPI.forbidden, GAPI.internalError,
              GAPI.insufficientAdministratorPrivileges, GAPI.insufficientFilePermissions,
              GAPI.unknownError, GAPI.invalid):
        pass
    if len(matchFeed) == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
    for shareddrive in matchFeed:
      row = {'id': shareddrive['id'], 'name': shareddrive['name'],
             'organizers': delimiter.join(shareddrive['organizers']),
             'createdTime': shareddrive['createdTime']}
      if orgUnitId:
        row.update(orgUnitInfo)
      csvPF.WriteRowTitles(row)
  if csvPF:
    csvPF.writeCSVfile('SharedDrive Organizers')

def doPrintSharedDriveOrganizers():
  printSharedDriveOrganizers([_getAdminEmail()], True)

LOOKERSTUDIO_ASSETTYPE_CHOICE_MAP = {
  'report': ['REPORT'],
  'datasource': ['DATA_SOURCE'],
  'all': ['REPORT', 'DATA_SOURCE'],
  }

def initLookerStudioAssetSelectionParameters():
  return ({'owner': None, 'title': None, 'includeTrashed': False}, {'assetTypes': ['REPORT']})

def getLookerStudioAssetSelectionParameters(myarg, parameters, assetTypes):
  if myarg in {'assettype', 'assettypes'}:
    assetTypes['assetTypes'] = getChoice(LOOKERSTUDIO_ASSETTYPE_CHOICE_MAP, mapChoice=True)
  elif myarg == 'title':
    parameters['title'] = getString(Cmd.OB_STRING)
  elif myarg == 'owner':
    parameters['owner'] = getEmailAddress(noUid=True)
  elif myarg == 'includetrashed':
    parameters['includeTrashed'] = True
  else:
    return False
  return True

def _validateUserGetLookerStudioAssetIds(user, i, count, entity):
  if entity:
    if entity['dict']:
      entityList = [{'name': item, 'title': item} for item in entity['dict'][user]]
    else:
      entityList = [{'name': item, 'title': item} for item in entity['list']]
  else:
    entityList = []
  user, ds = buildGAPIServiceObject(API.LOOKERSTUDIO, user, i, count)
  if not ds:
    return (user, None, None, 0)
  return (user, ds, entityList, len(entityList))

def _getLookerStudioAssetByID(ds, user, i, count, assetId):
  printGettingAllEntityItemsForWhom(Ent.LOOKERSTUDIO_ASSET, user, i, count)
  try:
    return callGAPI(ds.assets(), 'get',
                    throwReasons=GAPI.LOOKERSTUDIO_THROW_REASONS,
                    name=f'assets/{assetId}')
  except (GAPI.invalidArgument, GAPI.badRequest, GAPI.notFound, GAPI.permissionDenied, GAPI.internalError) as e:
    entityActionFailedWarning([Ent.USER, user], str(e), i, count)
  except GAPI.serviceNotAvailable:
    userLookerStudioServiceNotEnabledWarning(user, i, count)
  return None

def _getLookerStudioAssets(ds, user, i, count, parameters, assetTypes, fields, orderBy=None):
  assets = []
  for assetType in assetTypes['assetTypes']:
    entityType = Ent.LOOKERSTUDIO_ASSET_REPORT if assetType == 'REPORT' else Ent.LOOKERSTUDIO_ASSET_DATASOURCE
    printGettingAllEntityItemsForWhom(entityType, user, i, count)
    parameters['assetTypes'] = assetType
    try:
      assets.extend(callGAPIpages(ds.assets(), 'search', 'assets',
                                  pageMessage=getPageMessage(),
                                  throwReasons=GAPI.LOOKERSTUDIO_THROW_REASONS,
                                  **parameters, orderBy=orderBy, fields=fields))
    except (GAPI.invalidArgument, GAPI.badRequest, GAPI.notFound, GAPI.permissionDenied, GAPI.internalError) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
      return (None, 0)
    except GAPI.serviceNotAvailable:
      userLookerStudioServiceNotEnabledWarning(user, i, count)
      return (None, 0)
  return (assets, len(assets))

LOOKERSTUDIO_ASSETS_ORDERBY_CHOICE_MAP = {
  'title': 'title'
  }
LOOKERSTUDIO_ASSETS_TIME_OBJECTS = {'updateTime', 'updateByMeTime', 'createTime', 'lastViewByMeTime'}

# gam <UserTypeEntity> print lookerstudioassets [todrive <ToDriveAttribute>*]
#	[([assettype report|datasource|all] [title <String>]
#	  [owner <Emailddress>] [includetrashed]
#	  [orderby title [ascending|descending]]) |
#	 (assetids <LookerStudioAssetIDEntity>)]
#	[stripcrsfromtitle]
#	[formatjson [quotechar <Character>]]
# gam <UserTypeEntity> show lookerstudioassets
#	[([assettype report|datasource|all] [title <String>]
#	  [owner <Emailddress>] [includetrashed]
#	  [orderby title [ascending|descending]]) |
#	 (assetids <LookerStudioAssetIDEntity>)]
#	[stripcrsfromtitle]
#	[formatjson]
def printShowLookerStudioAssets(users):
  def _printAsset(asset, user):
    if stripCRsFromTitle:
      asset['title'] = _stripControlCharsFromName(asset['title'])
    row = flattenJSON(asset, flattened={'User': user})
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'User': user, 'title': asset['title'],
                              'JSON': json.dumps(cleanJSON(asset, timeObjects=LOOKERSTUDIO_ASSETS_TIME_OBJECTS), ensure_ascii=False, sort_keys=True)})

  def _showAsset(asset):
    if stripCRsFromTitle:
      asset['title'] = _stripControlCharsFromName(asset['title'])
    if FJQC.formatJSON:
      printLine(json.dumps(cleanJSON(asset, timeObjects=LOOKERSTUDIO_ASSETS_TIME_OBJECTS), ensure_ascii=False, sort_keys=False))
      return
    printEntity([Ent.LOOKERSTUDIO_ASSET, asset['title']], j, jcount)
    Ind.Increment()
    showJSON(None, asset, timeObjects=LOOKERSTUDIO_ASSETS_TIME_OBJECTS)
    Ind.Decrement()

  csvPF = CSVPrintFile(['User', 'title']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  OBY = OrderBy(LOOKERSTUDIO_ASSETS_ORDERBY_CHOICE_MAP, ascendingKeyword='ascending', descendingKeyword='')
  parameters, assetTypes = initLookerStudioAssetSelectionParameters()
  assetIdEntity = None
  stripCRsFromTitle = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif getLookerStudioAssetSelectionParameters(myarg, parameters, assetTypes):
      pass
    elif myarg in {'assetid', 'assetids'}:
      assetIdEntity = getUserObjectEntity(Cmd.OB_USER_ENTITY, Ent.LOOKERSTUDIO_ASSETID)
    elif myarg == 'stripcrsfromtitle':
      stripCRsFromTitle = True
    elif myarg == 'orderby':
      OBY.GetChoice()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, ds, assets, jcount = _validateUserGetLookerStudioAssetIds(user, i, count, assetIdEntity)
    if not ds:
      continue
    if assetIdEntity is None:
      assets, jcount = _getLookerStudioAssets(ds, user, i, count, parameters, assetTypes, 'nextPageToken,assets', OBY.orderBy)
      if assets is None:
        continue
    if not csvPF:
      if not FJQC.formatJSON:
        entityPerformActionNumItems([Ent.USER, user], jcount, Ent.LOOKERSTUDIO_ASSET, i, count)
      Ind.Increment()
      j = 0
      for asset in assets:
        j += 1
        if assetIdEntity:
          asset = _getLookerStudioAssetByID(ds, user, i, count, asset['name'])
        if asset:
          _showAsset(asset)
      Ind.Decrement()
    else:
      for asset in assets:
        if assetIdEntity:
          asset = _getLookerStudioAssetByID(ds, user, i, count, asset['name'])
        if asset:
          _printAsset(asset, user)
  if csvPF:
    csvPF.writeCSVfile('Looker Studio Assets')

def _showLookerStudioPermissions(user, asset, permissions, j, jcount, FJQC):
  if FJQC is not None and FJQC.formatJSON:
    permissions['User'] = user
    permissions['assetId'] = asset['name']
    printLine(json.dumps(cleanJSON(permissions), ensure_ascii=False, sort_keys=False))
    return
  permissions = permissions['permissions']
  if permissions:
    printEntity([Ent.LOOKERSTUDIO_ASSET, asset['title'], Ent.LOOKERSTUDIO_PERMISSION, ''], j, jcount)
  for role in ['OWNER', 'EDITOR', 'VIEWER']:
    members = permissions.get(role, {}).get('members', [])
    if members:
      lrole = role.lower()
      Ind.Increment()
      for member in members:
        printKeyValueList([lrole, member])
      Ind.Decrement()

LOOKERSTUDIO_VIEW_PERMISSION_ROLE_CHOICE_MAP = {
  'editor': 'EDITOR',
  'owner': 'OWNER',
  'viewer': 'VIEWER',
  }

LOOKERSTUDIO_ADD_UPDATE_PERMISSION_ROLE_CHOICE_MAP = {
  'editor': 'EDITOR',
  'viewer': 'VIEWER',
  }

LOOKERSTUDIO_DELETE_PERMISSION_ROLE_CHOICE_MAP = {
  'any': None,
  'editor': None,
  'owner': None,
  'viewer': None,
  }

LOOKERSTUDIO_PERMISSION_MODIFIER_MAP = {
  Act.ADD: Act.MODIFIER_TO,
  Act.DELETE: Act.MODIFIER_FROM,
  Act.UPDATE: Act.MODIFIER_FOR
  }

# gam <UserTypeEntity> add lookerstudiopermissions
#	[([assettype report|datasource|all] [title <String>]
#	  [owner <Emailddress>] [includetrashed]
#	  [orderby title [ascending|descending]]) |
#	 (assetids <LookerStudioAssetIDEntity>)]
#	(role editor|viewer <LookerStudioPermissionEntity>)+
#	[nodetails]
# gam <UserTypeEntity> delete lookerstudiopermissions
#	([[assettype report|datasource|all] [title <String>]
#	  [owner <Emailddress>] [includetrashed]
#	  [orderby title [ascending|descending]]) |
#	 (assetids <LookerStudioAssetIDEntity>)]
#	(role any <LookerStudioPermissionEntity>)+
#	[nodetails]
# gam <UserTypeEntity> update lookerstudiopermissions
#	[([assettype report|datasource|all] [title <String>]
#	  [owner <Emailddress>] [includetrashed]
#	  [orderby title [ascending|descending]]) |
#	 (assetids <LookerStudioAssetIDEntity>)]
#	(role editor|viewer <LookerStudioPermissionEntity>)+
#	[nodetails]
def processLookerStudioPermissions(users):
  action = Act.Get()
  if action == Act.CREATE:
    action = Act.ADD
  modifier = LOOKERSTUDIO_PERMISSION_MODIFIER_MAP[action]
  parameters, assetTypes = initLookerStudioAssetSelectionParameters()
  permissions = {}
  assetIdEntity = None
  showDetails = True
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if getLookerStudioAssetSelectionParameters(myarg, parameters, assetTypes):
      pass
    elif myarg in {'assetid', 'assetids'}:
      assetIdEntity = getUserObjectEntity(Cmd.OB_USER_ENTITY, Ent.LOOKERSTUDIO_ASSETID)
    elif myarg == 'role':
      permissions.setdefault('permissions', {})
      if action in {Act.ADD, Act.UPDATE}:
        role = getChoice(LOOKERSTUDIO_ADD_UPDATE_PERMISSION_ROLE_CHOICE_MAP, mapChoice=True)
      else:
        role = getChoice(LOOKERSTUDIO_DELETE_PERMISSION_ROLE_CHOICE_MAP, mapChoice=True)
      permissions['permissions'].setdefault(role, {'members': []})
      permissions['permissions'][role]['members'].extend(getEntityList(Cmd.OB_LOOKERSTUDIO_PERMISSION_ENTITY))
    elif myarg == 'nodetails':
      showDetails = False
    else:
      unknownArgumentExit()
  if not permissions:
    if action in {Act.ADD, Act.UPDATE}:
      missingArgumentExit('role editor|owner|viewer members')
    else:
      missingArgumentExit('members')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, ds, assets, jcount = _validateUserGetLookerStudioAssetIds(user, i, count, assetIdEntity)
    if not ds:
      continue
    if assetIdEntity is None:
      assets, jcount = _getLookerStudioAssets(ds, user, i, count, parameters, assetTypes, 'nextPageToken,assets(name,title)', None)
      if assets is None:
        continue
    entityPerformActionSubItemModifierNumItems([Ent.USER, user], Ent.LOOKERSTUDIO_PERMISSION, modifier, jcount, Ent.LOOKERSTUDIO_ASSET, i, count)
    j = 0
    for asset in assets:
      j += 1
      for role in permissions['permissions']:
        try:
          body = {'name': asset['name'], 'members': permissions['permissions'][role]['members']}
          if action in {Act.DELETE, Act.UPDATE}:
            results = callGAPI(ds.assets().permissions(), 'revokeAllPermissions',
                               throwReasons=GAPI.LOOKERSTUDIO_THROW_REASONS,
                               name=asset['name'], body=body)
          if action in {Act.ADD, Act.UPDATE}:
            body['role'] = role
            results = callGAPI(ds.assets().permissions(), 'addMembers',
                               throwReasons=GAPI.LOOKERSTUDIO_THROW_REASONS,
                               name=asset['name'], body=body)
          entityActionPerformed([Ent.USER, user, Ent.LOOKERSTUDIO_ASSET, asset['title'], Ent.LOOKERSTUDIO_PERMISSION, ''], j, jcount)
          if showDetails:
            _showLookerStudioPermissions(user, asset, results, j, jcount, None)
        except (GAPI.invalidArgument, GAPI.badRequest, GAPI.notFound, GAPI.permissionDenied, GAPI.internalError) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.LOOKERSTUDIO_ASSET, asset['title']], str(e), j, jcount)
          continue
        except GAPI.serviceNotAvailable:
          userLookerStudioServiceNotEnabledWarning(user, i, count)
          break

# gam <UserTypeEntity> print lookerstudiopermissions [todrive <ToDriveAttribute>*]
#	[([assettype report|datasource|all] [title <String>]
#	  [owner <Emailddress>] [includetrashed]
#	  [orderby title [ascending|descending]]) |
#	 (assetids <LookerStudioAssetIDEntity>)]
#	[role editor|owner|viewer]
#	[formatjson [quotechar <Character>]]
# gam <UserTypeEntity> show lookerstudiopermissions
#	[([assettype report|datasource|all] [title <String>]
#	  [owner <Emailddress>] [includetrashed]
#	  [orderby title [ascending|descending]]) |
#	 (assetids <LookerStudioAssetIDEntity>)[
#	[role editor|owner|viewer]
#	[formatjson]
def printShowLookerStudioPermissions(users):
  def _printLookerStudioPermissions(user, asset, permissions):
    row = flattenJSON(permissions, flattened={'User': user, 'assetId': asset['name']},
                      simpleLists=['members'], delimiter=delimiter)
    if not FJQC.formatJSON:
      csvPF.WriteRowTitles(row)
    elif csvPF.CheckRowTitles(row):
      csvPF.WriteRowNoFilter({'User': user, 'assetId': asset['name'],
                              'JSON': json.dumps(cleanJSON(permissions), ensure_ascii=False, sort_keys=True)})

  csvPF = CSVPrintFile(['User', 'assetId']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  parameters, assetTypes = initLookerStudioAssetSelectionParameters()
  assetIdEntity = None
  role = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif getLookerStudioAssetSelectionParameters(myarg, parameters, assetTypes):
      pass
    elif myarg in {'assetid', 'assetids'}:
      assetIdEntity = getUserObjectEntity(Cmd.OB_USER_ENTITY, Ent.LOOKERSTUDIO_ASSETID)
    elif myarg == 'role':
      role = getChoice(LOOKERSTUDIO_VIEW_PERMISSION_ROLE_CHOICE_MAP, mapChoice=True)
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, ds, assets, jcount = _validateUserGetLookerStudioAssetIds(user, i, count, assetIdEntity)
    if not ds:
      continue
    if assetIdEntity is None:
      assets, jcount = _getLookerStudioAssets(ds, user, i, count, parameters, assetTypes, 'nextPageToken,assets(name,title)', None)
      if assets is None:
        continue
    if not csvPF and not FJQC.formatJSON:
      entityPerformActionNumItems([Ent.USER, user], jcount, Ent.LOOKERSTUDIO_ASSET, i, count)
    j = 0
    for asset in assets:
      j += 1
      try:
        permissions = callGAPI(ds.assets(), 'getPermissions',
                               throwReasons=GAPI.LOOKERSTUDIO_THROW_REASONS,
                               name=asset['name'], role=role)
      except (GAPI.invalidArgument, GAPI.badRequest, GAPI.notFound, GAPI.permissionDenied, GAPI.internalError) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.LOOKERSTUDIO_ASSET, asset['title']], str(e), j, jcount)
        continue
      except GAPI.serviceNotAvailable:
        userLookerStudioServiceNotEnabledWarning(user, i, count)
        break
      if not csvPF:
        Ind.Increment()
        _showLookerStudioPermissions(user, asset, permissions, j, jcount, FJQC)
        Ind.Decrement()
      else:
        _printLookerStudioPermissions(user, asset, permissions)
  if csvPF:
    csvPF.writeCSVfile('Looker Studio Permissions')

def _validateSubkeyRoleGetGroups(user, role, origUser, userGroupLists, i, count):
  roleLower = role.lower()
  if roleLower in GROUP_ROLES_MAP:
    return (GROUP_ROLES_MAP[roleLower], userGroupLists[origUser][role])
  entityActionNotPerformedWarning([Ent.USER, user, Ent.ROLE, role], Msg.INVALID_ROLE.format(','.join(sorted(GROUP_ROLES_MAP))), i, count)
  return (None, None)

def _addUserToGroups(cd, user, addGroupsSet, addGroups, i, count):
  jcount = len(addGroupsSet)
  entityPerformActionModifierNumItems([Ent.USER, user], Act.MODIFIER_TO, jcount, Ent.GROUP, i, count)
  Ind.Increment()
  j = 0
  for group in sorted(addGroupsSet):
    j += 1
    role = addGroups[group]['role']
    body = {'email': user, 'role': role}
    if addGroups[group]['delivery_settings'] != DELIVERY_SETTINGS_UNDEFINED:
      body['delivery_settings'] = addGroups[group]['delivery_settings']
    try:
      callGAPI(cd.members(), 'insert',
               throwReasons=GAPI.MEMBERS_THROW_REASONS+[GAPI.DUPLICATE, GAPI.MEMBER_NOT_FOUND, GAPI.RESOURCE_NOT_FOUND,
                                                        GAPI.INVALID_MEMBER, GAPI.CYCLIC_MEMBERSHIPS_NOT_ALLOWED,
                                                        GAPI.CONDITION_NOT_MET, GAPI.CONFLICT],
               retryReasons=GAPI.MEMBERS_RETRY_REASONS,
               groupKey=group, body=body, fields='')
      entityActionPerformed([Ent.GROUP, group, role, user], j, jcount)
    except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden):
      entityUnknownWarning(Ent.GROUP, group, j, jcount)
    except (GAPI.duplicate, GAPI.cyclicMembershipsNotAllowed, GAPI.conditionNotMet, GAPI.serviceNotAvailable) as e:
      entityActionFailedWarning([Ent.GROUP, group, role, user], str(e), j, jcount)
    except GAPI.conflict:
      entityActionPerformedMessage([Ent.GROUP, group, role, user], Msg.ACTION_MAY_BE_DELAYED, j, jcount)
    except (GAPI.memberNotFound, GAPI.resourceNotFound, GAPI.invalidMember) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
  Ind.Decrement()

# gam <UserTypeEntity> add group|groups
#	([<GroupRole>] [[delivery] <DeliverySetting>] <GroupEntity>)+
def addUserToGroups(users):
  cd = buildGAPIObject(API.DIRECTORY)
  baseRole = getChoice(GROUP_ROLES_MAP, defaultChoice=Ent.ROLE_MEMBER, mapChoice=True)
  baseDeliverySettings = getDeliverySettings()
  groupKeys = getEntityList(Cmd.OB_GROUP_ENTITY)
  subkeyRoleField = GM.Globals[GM.CSV_SUBKEY_FIELD]
  if not isinstance(groupKeys, dict):
    userGroupLists = None
    addGroups = {}
    for group in groupKeys:
      addGroups[normalizeEmailAddressOrUID(group)] = {'role': baseRole, 'delivery_settings': baseDeliverySettings}
    while Cmd.ArgumentsRemaining():
      role = getChoice(GROUP_ROLES_MAP, defaultChoice=Ent.ROLE_MEMBER, mapChoice=True)
      deliverySettings = getDeliverySettings()
      for group in getEntityList(Cmd.OB_GROUP_ENTITY):
        addGroups[normalizeEmailAddressOrUID(group)] = {'role': role, 'delivery_settings': deliverySettings}
  else:
    userGroupLists = groupKeys
    checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user = normalizeEmailAddressOrUID(user)
    if userGroupLists:
      roleList = [baseRole]
      if not subkeyRoleField:
        groupKeys = userGroupLists[origUser]
      else:
        roleList = userGroupLists[origUser]
      addGroups = {}
      for role in roleList:
        if subkeyRoleField:
          role, groupKeys = _validateSubkeyRoleGetGroups(user, role, origUser, userGroupLists, i, count)
          if role is None:
            continue
        for group in groupKeys:
          addGroups[normalizeEmailAddressOrUID(group)] = {'role': role, 'delivery_settings': DELIVERY_SETTINGS_UNDEFINED}
    _addUserToGroups(cd, user, set(addGroups), addGroups, i, count)

def _deleteUserFromGroups(cd, user, deleteGroupsSet, deleteGroups, i, count):
  jcount = len(deleteGroupsSet)
  entityPerformActionModifierNumItems([Ent.USER, user], Act.MODIFIER_FROM, jcount, Ent.GROUP, i, count)
  Ind.Increment()
  j = 0
  for group in sorted(deleteGroupsSet):
    j += 1
    role = deleteGroups[group]['role']
    try:
      callGAPI(cd.members(), 'delete',
               throwReasons=GAPI.MEMBERS_THROW_REASONS+[GAPI.MEMBER_NOT_FOUND, GAPI.INVALID_MEMBER,
                                                        GAPI.CONDITION_NOT_MET, GAPI.CONFLICT],
               retryReasons=GAPI.MEMBERS_RETRY_REASONS,
               groupKey=group, memberKey=user)
      entityActionPerformed([Ent.GROUP, group, role, user], j, jcount)
    except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden):
      entityUnknownWarning(Ent.GROUP, group, j, jcount)
    except (GAPI.memberNotFound, GAPI.invalidMember, GAPI.conditionNotMet, GAPI.serviceNotAvailable) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.GROUP, group], str(e), j, jcount)
    except GAPI.conflict:
      entityActionPerformedMessage([Ent.GROUP, group, role, user], Msg.ACTION_MAY_BE_DELAYED, j, jcount)
  Ind.Decrement()

def _getUserGroupOptionalDomainCustomerId():
  if checkArgumentPresent('domain'):
    return {'domain': getString(Cmd.OB_DOMAIN_NAME).lower()}
  if checkArgumentPresent('customerid'):
    return {'customer': getString(Cmd.OB_CUSTOMER_ID)}
  return {}

def _setUserGroupArgs(user, kwargs):
  if 'customer' in kwargs:
    if "'" not in user:
      kwargs['query'] = f'memberKey={user}'
    else:
      quser = user.replace("'", "\\'")
      kwargs['query'] = f'memberKey={quser}'
  else:
    kwargs['userKey'] = user

def checkUserGroupMatchPattern(groupEmail, matchPattern):
  if not matchPattern['not']:
    if not matchPattern['pattern'].match(groupEmail):
      return False
  else:
    if matchPattern['pattern'].match(groupEmail):
      return False
  return True

# gam <UserTypeEntity> delete group|groups
#	[(domain <DomainName>)|(customerid <CustomerID>)|
#	 (emailmatchpattern [not] <REMatchPattern>)|<GroupEntity>]
def deleteUserFromGroups(users):
  cd = buildGAPIObject(API.DIRECTORY)
  groupKeys = None
  matchPattern = {}
  kwargs = _getUserGroupOptionalDomainCustomerId()
  if not kwargs:
    kwargs = {'customer': GC.Values[GC.CUSTOMER_ID]}
    deleteGroups = {}
    if Cmd.ArgumentsRemaining():
      if not checkArgumentPresent('emailmatchpattern'):
        groupKeys = getEntityList(Cmd.OB_GROUP_ENTITY)
        userGroupLists = groupKeys if isinstance(groupKeys, dict) else None
        for group in groupKeys:
          deleteGroups[normalizeEmailAddressOrUID(group)] = {'role': Ent.MEMBER}
      else:
        matchPattern = {'not': checkArgumentPresent('not'), 'pattern': getREPattern(re.IGNORECASE)}
      checkForExtraneousArguments()
  else:
    checkForExtraneousArguments()
  role = Ent.MEMBER
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user = normalizeEmailAddressOrUID(user)
    if groupKeys is None:
      _setUserGroupArgs(user, kwargs)
      try:
        result = callGAPIpages(cd.groups(), 'list', 'groups',
                               throwReasons=GAPI.GROUP_LIST_USERKEY_THROW_REASONS,
                               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                               orderBy='email', fields='nextPageToken,groups(email)', **kwargs)
      except (GAPI.invalidMember, GAPI.invalidInput):
        badRequestWarning(Ent.GROUP, Ent.MEMBER, user)
        continue
      except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.forbidden, GAPI.badRequest):
        accessErrorExit(cd)
      deleteGroups = {}
      for group in result:
        if not matchPattern or checkUserGroupMatchPattern(group['email'], matchPattern):
          deleteGroups[group['email']] = {'role': role}
    elif userGroupLists:
      userGroupKeys = userGroupLists[origUser]
      deleteGroups = {}
      for group in userGroupKeys:
        deleteGroups[normalizeEmailAddressOrUID(group)] = {'role': role}
    _deleteUserFromGroups(cd, user, set(deleteGroups), deleteGroups, i, count)

def _updateUserGroups(cd, user, updateGroupsSet, updateGroups, i, count):
  jcount = len(updateGroupsSet)
  entityPerformActionNumItems([Ent.USER, user], jcount, Ent.GROUP, i, count)
  Ind.Increment()
  j = 0
  for group in sorted(updateGroupsSet):
    j += 1
    role = updateGroups[group]['role']
    body = {'email': user, 'role': role}
    if updateGroups[group]['delivery_settings'] != DELIVERY_SETTINGS_UNDEFINED:
      body['delivery_settings'] = updateGroups[group]['delivery_settings']
    try:
      callGAPI(cd.members(), 'patch',
               throwReasons=GAPI.MEMBERS_THROW_REASONS+[GAPI.MEMBER_NOT_FOUND, GAPI.INVALID_MEMBER, GAPI.CONDITION_NOT_MET],
               retryReasons=GAPI.MEMBERS_RETRY_REASONS,
               groupKey=group, memberKey=user, body=body, fields='')
      entityActionPerformed([Ent.GROUP, group, role, user], j, jcount)
    except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden):
      entityUnknownWarning(Ent.GROUP, group, j, jcount)
    except (GAPI.memberNotFound, GAPI.invalidMember, GAPI.conditionNotMet, GAPI.serviceNotAvailable) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.GROUP, group], str(e), j, jcount)
  Ind.Decrement()

# gam <UserTypeEntity> update group|groups
#	[(domain <DomainName>)|(customerid <CustomerID>)]) [<GroupRole>] [[delivery] <DeliverySetting>]
#	([<GroupRole>] [[delivery] <DeliverySetting>] [<GroupEntity>])*
def updateUserGroups(users):
  cd = buildGAPIObject(API.DIRECTORY)
  groupKeys = None
  kwargs = _getUserGroupOptionalDomainCustomerId()
  baseRole = getChoice(GROUP_ROLES_MAP, defaultChoice=Ent.ROLE_MEMBER, mapChoice=True)
  baseDeliverySettings = getDeliverySettings()
  if not kwargs:
    kwargs = {'customer': GC.Values[GC.CUSTOMER_ID]}
    if Cmd.ArgumentsRemaining():
      groupKeys = getEntityList(Cmd.OB_GROUP_ENTITY)
      subkeyRoleField = GM.Globals[GM.CSV_SUBKEY_FIELD]
      if not isinstance(groupKeys, dict):
        userGroupLists = None
        updateGroups = {}
        for group in groupKeys:
          updateGroups[normalizeEmailAddressOrUID(group)] = {'role': baseRole, 'delivery_settings': baseDeliverySettings}
        while Cmd.ArgumentsRemaining():
          role = getChoice(GROUP_ROLES_MAP, defaultChoice=Ent.ROLE_MEMBER, mapChoice=True)
          deliverySettings = getDeliverySettings()
          for group in getEntityList(Cmd.OB_GROUP_ENTITY):
            updateGroups[normalizeEmailAddressOrUID(group)] = {'role': role, 'delivery_settings': deliverySettings}
      else:
        userGroupLists = groupKeys
        checkForExtraneousArguments()
  else:
    checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user = normalizeEmailAddressOrUID(user)
    if groupKeys is None:
      _setUserGroupArgs(user, kwargs)
      try:
        result = callGAPIpages(cd.groups(), 'list', 'groups',
                               throwReasons=GAPI.GROUP_LIST_USERKEY_THROW_REASONS,
                               retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                               orderBy='email', fields='nextPageToken,groups(email)', **kwargs)
      except (GAPI.invalidMember, GAPI.invalidInput):
        badRequestWarning(Ent.GROUP, Ent.MEMBER, user)
        continue
      except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.forbidden, GAPI.badRequest):
        accessErrorExit(cd)
      updateGroups = {}
      for group in result:
        updateGroups[group['email']] = {'role': baseRole, 'delivery_settings': baseDeliverySettings}
    elif userGroupLists:
      roleList = [baseRole]
      if not subkeyRoleField:
        groupKeys = userGroupLists[origUser]
      else:
        roleList = userGroupLists[origUser]
      updateGroups = {}
      for role in roleList:
        if subkeyRoleField:
          role, groupKeys = _validateSubkeyRoleGetGroups(user, role, origUser, userGroupLists, i, count)
          if role is None:
            continue
        for group in groupKeys:
          updateGroups[normalizeEmailAddressOrUID(group)] = {'role': role, 'delivery_settings': DELIVERY_SETTINGS_UNDEFINED}
    _updateUserGroups(cd, user, set(updateGroups), updateGroups, i, count)

# gam <UserTypeEntity> sync group|groups
#	[(domain <DomainName>)|(customerid <CustomerID>)]
#	[<GroupRole>] [[delivery] <DeliverySetting>] (<GroupEntity>)*
def syncUserWithGroups(users):
  cd = buildGAPIObject(API.DIRECTORY)
  kwargs = _getUserGroupOptionalDomainCustomerId()
  if not kwargs:
    kwargs = {'customer': GC.Values[GC.CUSTOMER_ID]}
  baseRole = getChoice(GROUP_ROLES_MAP, defaultChoice=Ent.ROLE_MEMBER, mapChoice=True)
  baseDeliverySettings = getDeliverySettings()
  groupKeys = getEntityList(Cmd.OB_GROUP_ENTITY)
  subkeyRoleField = GM.Globals[GM.CSV_SUBKEY_FIELD]
  if not isinstance(groupKeys, dict):
    userGroupLists = None
    syncGroups = {}
    for group in groupKeys:
      syncGroups[normalizeEmailAddressOrUID(group)] = {'role': baseRole, 'delivery_settings': baseDeliverySettings}
    while Cmd.ArgumentsRemaining():
      role = getChoice(GROUP_ROLES_MAP, defaultChoice=Ent.ROLE_MEMBER, mapChoice=True)
      deliverySettings = getDeliverySettings()
      for group in getEntityList(Cmd.OB_GROUP_ENTITY):
        syncGroups[normalizeEmailAddressOrUID(group)] = {'role': role, 'delivery_settings': deliverySettings}
  else:
    userGroupLists = groupKeys
    checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user = normalizeEmailAddressOrUID(user)
    if userGroupLists:
      roleList = [baseRole]
      if not subkeyRoleField:
        groupKeys = userGroupLists[origUser]
      else:
        roleList = userGroupLists[origUser]
      syncGroups = {}
      for role in roleList:
        if subkeyRoleField:
          role, groupKeys = _validateSubkeyRoleGetGroups(user, role, origUser, userGroupLists, i, count)
          if role is None:
            continue
        for group in groupKeys:
          syncGroups[normalizeEmailAddressOrUID(group)] = {'role': role, 'delivery_settings': DELIVERY_SETTINGS_UNDEFINED}
    currGroups = {}
    _setUserGroupArgs(user, kwargs)
    try:
      entityList = callGAPIpages(cd.groups(), 'list', 'groups',
                                 throwReasons=GAPI.GROUP_LIST_USERKEY_THROW_REASONS,
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 orderBy='email', fields='nextPageToken,groups(email)', **kwargs)
    except (GAPI.invalidMember, GAPI.invalidInput):
      badRequestWarning(Ent.GROUP, Ent.MEMBER, user)
      continue
    except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.forbidden, GAPI.badRequest):
      accessErrorExit(cd)
    for groupEntity in entityList:
      groupEmail = groupEntity['email']
      try:
        result = callGAPI(cd.members(), 'get',
                          throwReasons=GAPI.MEMBERS_THROW_REASONS+[GAPI.MEMBER_NOT_FOUND, GAPI.INVALID_MEMBER, GAPI.CONDITION_NOT_MET],
                          retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                          groupKey=groupEmail, memberKey=user, fields='role,delivery_settings')
        currGroups[groupEmail] = {'role': result.get('role', Ent.MEMBER),
                                  'delivery_settings': result.get('delivery_settings', DELIVERY_SETTINGS_UNDEFINED)}
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden):
        entityUnknownWarning(Ent.GROUP, groupEmail, i, count)
      except (GAPI.memberNotFound, GAPI.invalidMember, GAPI.conditionNotMet, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.GROUP, groupEmail], str(e), i, count)
    currGroupsSet = set(currGroups)
    syncGroupsSet = set(syncGroups)
    removeGroupsSet = currGroupsSet-syncGroupsSet
    addGroupsSet = syncGroupsSet-currGroupsSet
    updateGroupsSet = set()
    for group in currGroupsSet.intersection(syncGroupsSet):
      if (syncGroups[group]['role'] != currGroups[group]['role'] or
          (syncGroups[group]['delivery_settings'] != currGroups[group]['delivery_settings'] and
           syncGroups[group]['delivery_settings'] != DELIVERY_SETTINGS_UNDEFINED)):
        updateGroupsSet.add(group)
    if removeGroupsSet or addGroupsSet or updateGroupsSet:
      if removeGroupsSet:
        Act.Set(Act.REMOVE)
        _deleteUserFromGroups(cd, user, removeGroupsSet, currGroups, i, count)
      if addGroupsSet:
        Act.Set(Act.ADD)
        _addUserToGroups(cd, user, addGroupsSet, syncGroups, i, count)
      if updateGroupsSet:
        Act.Set(Act.UPDATE)
        _updateUserGroups(cd, user, updateGroupsSet, syncGroups, i, count)
    else:
      printEntityKVList([Ent.USER, user], [Msg.NO_CHANGES], i, count)

def _getUserGroupDomainCustomerId(myarg, kwargs):
  if myarg == 'domain':
    kwargs['domain'] = getString(Cmd.OB_DOMAIN_NAME).lower()
    kwargs.pop('customer', None)
  elif myarg == 'customerid':
    kwargs['customer'] = getString(Cmd.OB_CUSTOMER_ID)
    kwargs.pop('domain', None)
  else:
    return False
  return True

# gam <UserTypeEntity> check group|groups
#	[roles <GroupRoleList>] [includederivedmembership] [csv] <GroupEntity>
def checkUserInGroups(users):
  def _setCheckError():
    sysRC['sysRC'] = CHECK_USER_GROUPS_ERROR_RC

  def _checkMember(result):
    role = result.get('role', Ent.MEMBER)
    if role in rolesSet:
      if not csvPF:
        printEntity([Ent.USER, user, Ent.GROUP, groupEmail, Ent.ROLE, role], j, jcount)
      else:
        csvPF.WriteRow({'user': user, 'group': groupEmail, 'role': role})
    else:
      if not csvPF:
        entityActionFailedWarning([Ent.USER, user, Ent.GROUP, groupEmail, Ent.ROLE, role], Msg.ROLE_NOT_IN_SET.format(rolesSet), j, jcount)
      else:
        csvPF.WriteRow({'user': user, 'group': groupEmail, 'role': notMemberOrRole})
      _setCheckError()

  cd = buildGAPIObject(API.DIRECTORY)
  csvPF = None
  groupKeys = []
  checkGroupsSet = set()
  rolesSet = set()
  includeDerivedMembership = False
  sysRC = {'sysRC' : 0}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'role', 'roles'}:
      for role in getString(Cmd.OB_GROUP_ROLE_LIST).lower().replace(',', ' ').split():
        if role in GROUP_ROLES_MAP:
          rolesSet.add(GROUP_ROLES_MAP[role])
        else:
          invalidChoiceExit(role, GROUP_ROLES_MAP, True)
    elif myarg == 'includederivedmembership':
      includeDerivedMembership = True
    elif myarg == 'csv':
      csvPF = CSVPrintFile(['user', 'group', 'role'])
    else:
      Cmd.Backup()
      groupKeys = getEntityList(Cmd.OB_GROUP_ENTITY)
  if not rolesSet:
    rolesSet = ALL_GROUP_ROLES
  notMemberOrRole = Msg.NOT_AN_ENTITY.format('|'.join(rolesSet))
  userGroupLists = groupKeys if isinstance(groupKeys, dict) else None
  for group in groupKeys:
    checkGroupsSet.add(normalizeEmailAddressOrUID(group))
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user = normalizeEmailAddressOrUID(user)
    if userGroupLists:
      userGroupKeys = userGroupLists[origUser]
      checkGroupsSet = set()
      for group in userGroupKeys:
        checkGroupsSet.add(normalizeEmailAddressOrUID(group))
    jcount = len(checkGroupsSet)
    entityPerformActionModifierNumItems([Ent.USER, user], Act.MODIFIER_IN, jcount, Ent.GROUP, i, count)
    Ind.Increment()
    j = 0
    for groupEmail in sorted(checkGroupsSet):
      j += 1
      if not includeDerivedMembership:
        try:
          result = callGAPI(cd.members(), 'get',
                            throwReasons=GAPI.MEMBERS_THROW_REASONS+[GAPI.MEMBER_NOT_FOUND, GAPI.INVALID_MEMBER, GAPI.CONDITION_NOT_MET],
                            retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                            groupKey=groupEmail, memberKey=user, fields='role')
          _checkMember(result)
        except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden):
          entityUnknownWarning(Ent.GROUP, groupEmail, j, jcount)
          _setCheckError()
        except GAPI.memberNotFound:
          if not csvPF:
            entityActionFailedWarning([Ent.USER, user, Ent.GROUP, groupEmail], Msg.NOT_A_MEMBER, j, jcount)
          else:
            csvPF.WriteRow({'user': user, 'group': groupEmail, 'role': notMemberOrRole})
          _setCheckError()
        except (GAPI.invalidMember, GAPI.conditionNotMet, GAPI.serviceNotAvailable) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.GROUP, groupEmail], str(e), j, jcount)
          _setCheckError()
      else:
        try:
          result = callGAPIpages(cd.members(), 'list', 'members',
                                 throwReasons=GAPI.MEMBERS_THROW_REASONS, retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                                 includeDerivedMembership=includeDerivedMembership,
                                 groupKey=groupEmail, fields='nextPageToken,members(email,role,type)', maxResults=GC.Values[GC.MEMBER_MAX_RESULTS])
          for member in result:
            if member['type'] != Ent.TYPE_USER or member['email'].lower() != user:
              continue
            _checkMember(member)
            break
          else:
            if not csvPF:
              entityActionFailedWarning([Ent.USER, user, Ent.GROUP, groupEmail], Msg.NOT_A_MEMBER, j, jcount)
            else:
              csvPF.WriteRow({'user': user, 'group': groupEmail, 'role': notMemberOrRole})
            _setCheckError()
        except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden):
          entityUnknownWarning(Ent.GROUP, groupEmail, j, jcount)
          _setCheckError()
        except (GAPI.invalidMember, GAPI.conditionNotMet, GAPI.serviceNotAvailable) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.GROUP, groupEmail], str(e), j, jcount)
          _setCheckError()
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('User Check Groups')
  setSysExitRC(sysRC['sysRC'])

# gam <UserTypeEntity> print groups [todrive <ToDriveAttribute>*]
#	[(domain <DomainName>)|(customerid <CustomerID>)]
#	[roles <GroupRoleList>] [countsonly|totalonly|nodetails]
# gam <UserTypeEntity> show groups
#	[(domain <DomainName>)|(customerid <CustomerID>)]
#	[roles <GroupRoleList>] [countsonly|totalonly|nodetails]
def printShowUserGroups(users):
  cd = buildGAPIObject(API.DIRECTORY)
  kwargs = {'customer': GC.Values[GC.CUSTOMER_ID]}
  csvPF = CSVPrintFile(['User', 'Group', 'Role', 'Status', 'Delivery'], 'sortall') if Act.csvFormat() else None
  rolesSet = set()
  countsOnly = noDetails = totalOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif _getUserGroupDomainCustomerId(myarg, kwargs):
      pass
    elif myarg in {'role', 'roles'}:
      for role in getString(Cmd.OB_GROUP_ROLE_LIST).lower().replace(',', ' ').split():
        if role in GROUP_ROLES_MAP:
          rolesSet.add(GROUP_ROLES_MAP[role])
        else:
          invalidChoiceExit(role, GROUP_ROLES_MAP, True)
    elif myarg == 'countsonly':
      countsOnly = True
    elif myarg == 'totalonly':
      countsOnly = totalOnly = True
    elif myarg == 'nodetails':
      noDetails = True
    else:
      unknownArgumentExit()
  if not rolesSet:
    rolesSet = ALL_GROUP_ROLES
  allRoles = rolesSet == ALL_GROUP_ROLES
  if noDetails:
    if csvPF:
      titles = ['User', 'Group']
      csvPF.SetTitles(titles)
      csvPF.SetSortTitles([])
  elif countsOnly:
    zeroCounts = {'User': None, 'Total': 0}
    if not totalOnly:
      for role in [Ent.ROLE_MEMBER, Ent.ROLE_MANAGER, Ent.ROLE_OWNER]:
        if role in rolesSet:
          zeroCounts[role] = 0
    if csvPF:
      titles = ['User', 'Total']
      if not totalOnly:
        for role in [Ent.ROLE_MEMBER, Ent.ROLE_MANAGER, Ent.ROLE_OWNER]:
          if role in rolesSet:
            titles.append(role)
      csvPF.SetTitles(titles)
      csvPF.SetSortTitles([])
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    if csvPF:
      printGettingAllEntityItemsForWhom(Ent.GROUP, user, i, count)
      pageMessage = getPageMessageForWhom()
    else:
      pageMessage = None
    _setUserGroupArgs(user, kwargs)
    try:
      entityList = callGAPIpages(cd.groups(), 'list', 'groups',
                                 pageMessage=pageMessage,
                                 throwReasons=GAPI.GROUP_LIST_USERKEY_THROW_REASONS,
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 orderBy='email', fields='nextPageToken,groups(email)', **kwargs)
    except (GAPI.invalidMember, GAPI.invalidInput):
      badRequestWarning(Ent.GROUP, Ent.MEMBER, user)
      continue
    except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.forbidden, GAPI.badRequest):
      if kwargs.get('domain'):
        badRequestWarning(Ent.GROUP, Ent.DOMAIN, kwargs['domain'])
        return
      accessErrorExit(cd)
    jcount = len(entityList)
    if totalOnly:
      if not csvPF:
        printEntityKVList([Ent.USER, user], ['Total', jcount])
      else:
        csvPF.WriteRow({'User': user, 'Total': jcount})
      continue
    if not csvPF:
      if allRoles:
        entityPerformActionNumItems([Ent.USER, user], jcount, Ent.GROUP, i, count)
      else:
        entityPerformActionModifierNumItems([Ent.USER, user], Msg.MAXIMUM_OF, jcount, Ent.GROUP, i, count)
    if noDetails:
      Ind.Increment()
      j = 0
      for groupEntity in entityList:
        j += 1
        groupEmail = groupEntity['email']
        if not csvPF:
          printEntity([Ent.GROUP, groupEmail], j, jcount)
        else:
          csvPF.WriteRow({'User': user, 'Group': groupEmail})
      Ind.Decrement()
      continue
    if countsOnly:
      userCounts = zeroCounts.copy()
      userCounts['User'] = user
    Ind.Increment()
    j = 0
    for groupEntity in entityList:
      j += 1
      groupEmail = groupEntity['email']
      try:
        result = callGAPI(cd.members(), 'get',
                          throwReasons=GAPI.MEMBERS_THROW_REASONS+[GAPI.MEMBER_NOT_FOUND, GAPI.INVALID_MEMBER, GAPI.CONDITION_NOT_MET],
                          retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                          groupKey=groupEmail, memberKey=user, fields='role,status,delivery_settings')
        role = result.get('role', Ent.MEMBER)
        status = result.get('status', 'UNKNOWN')
        delivery_settings = result.get('delivery_settings', '')
        if role in rolesSet:
          if countsOnly:
            userCounts[role] += 1
          elif not csvPF:
            printEntity([Ent.GROUP, groupEmail, Ent.ROLE, role, Ent.STATUS, status, Ent.DELIVERY, delivery_settings], j, jcount)
          else:
            csvPF.WriteRow({'User': user, 'Group': groupEmail, 'Role': role, 'Status': status, 'Delivery': delivery_settings})
      except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden):
        entityUnknownWarning(Ent.GROUP, groupEmail, j, jcount)
      except (GAPI.memberNotFound, GAPI.invalidMember, GAPI.conditionNotMet, GAPI.serviceNotAvailable) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.GROUP, groupEmail], str(e), j, jcount)
    if countsOnly:
      for role in [Ent.ROLE_MEMBER, Ent.ROLE_MANAGER, Ent.ROLE_OWNER]:
        if role in rolesSet:
          userCounts['Total'] += userCounts[role]
      if not csvPF:
        kvList = ['Total', userCounts['Total']]
        for role in [Ent.ROLE_MEMBER, Ent.ROLE_MANAGER, Ent.ROLE_OWNER]:
          if role in rolesSet:
            kvList.extend([role, userCounts[role]])
        printEntityKVList([Ent.USER, user], kvList)
      else:
        csvPF.WriteRow(userCounts)
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('User Groups')

# gam <UserTypeEntity> print grouptree [todrive <ToDriveAttribute>*]
#	[(domain <DomainName>)|(customerid <CustomerID>)]
#	[roles <GroupRoleList>]
#	[showparentsaslist [<Boolean>]] [delimiter <Character>]
#	[formatjson [quotechar <Character>]]
# gam <UserTypeEntity> show grouptree
#	[(domain <DomainName>)|(customerid <CustomerID>)]
#	[roles <GroupRoleList>]
#	[formatjson]
def printShowGroupTree(users):
  cd = buildGAPIObject(API.DIRECTORY)
  kwargs = {'customer': GC.Values[GC.CUSTOMER_ID]}
  csvPF = CSVPrintFile(['User', 'Group', 'Name']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  showParentsAsList = False
  rolesSet = set()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif _getUserGroupDomainCustomerId(myarg, kwargs):
      pass
    elif myarg in {'role', 'roles'}:
      for role in getString(Cmd.OB_GROUP_ROLE_LIST).lower().replace(',', ' ').split():
        if role in GROUP_ROLES_MAP:
          rolesSet.add(GROUP_ROLES_MAP[role])
        else:
          invalidChoiceExit(role, GROUP_ROLES_MAP, True)
    elif csvPF and myarg == 'delimiter':
      delimiter = getCharacter()
    elif csvPF and myarg == 'showparentsaslist':
      showParentsAsList = getBoolean()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  if csvPF:
    if rolesSet:
      csvPF.AddTitles('Role')
    if not FJQC.formatJSON:
      if not showParentsAsList:
        csvPF.SetIndexedTitles(['parents'])
      else:
        csvPF.AddTitles(['ParentsCount', 'Parents', 'ParentsName'])
    else:
      if rolesSet:
        csvPF.AddJSONTitles('Role')
      csvPF.AddJSONTitles('JSON')
  allRoles = rolesSet == ALL_GROUP_ROLES
  groupParents = {}
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    _setUserGroupArgs(user, kwargs)
    try:
      groups = callGAPIpages(cd.groups(), 'list', 'groups',
                             throwReasons=GAPI.GROUP_LIST_USERKEY_THROW_REASONS,
                             retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                             orderBy='email', fields='nextPageToken,groups(email,name)', **kwargs)
    except (GAPI.invalidMember, GAPI.invalidInput):
      entityUnknownWarning(Ent.USER, user, i, count)
      continue
    j = 0
    jcount = len(groups)
    if not csvPF and not FJQC.formatJSON:
      if allRoles:
        entityPerformActionNumItems([Ent.USER, user], jcount, Ent.GROUP_TREE, i, count)
      else:
        entityPerformActionModifierNumItems([Ent.USER, user], Msg.MAXIMUM_OF, jcount, Ent.GROUP_TREE, i, count)
    Ind.Increment()
    for group in groups:
      j += 1
      groupEmail = group['email']
      if groupEmail not in groupParents:
        getGroupParents(cd, groupParents, groupEmail, group['name'], kwargs)
      if rolesSet:
        try:
          result = callGAPI(cd.members(), 'get',
                            throwReasons=GAPI.MEMBERS_THROW_REASONS+[GAPI.MEMBER_NOT_FOUND, GAPI.INVALID_MEMBER, GAPI.CONDITION_NOT_MET],
                            retryReasons=GAPI.MEMBERS_RETRY_REASONS,
                            groupKey=groupEmail, memberKey=user, fields='role')
          role = result.get('role', Ent.MEMBER)
          if role not in rolesSet:
            continue
        except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.invalid, GAPI.forbidden):
          entityUnknownWarning(Ent.GROUP, groupEmail, j, jcount)
          continue
        except (GAPI.memberNotFound, GAPI.invalidMember, GAPI.conditionNotMet, GAPI.serviceNotAvailable) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.GROUP, groupEmail], str(e), j, jcount)
          continue
      else:
        role = None
      if not FJQC.formatJSON:
        if not csvPF:
          showGroupParents(groupParents, groupEmail, role, j, jcount)
        else:
          row = {'User': user, 'Group': groupEmail, 'Name': group['name'], 'parents': []}
          if role is not None:
            row['Role'] = role
          printGroupParents(groupParents, groupEmail, row, csvPF, delimiter, showParentsAsList)
      else:
        groupInfo = {'email': groupEmail, 'name': group['name'], 'parents': []}
        if role is not None:
          groupInfo['role'] = role
        addJsonGroupParents(groupParents, groupInfo, groupEmail)
        if not csvPF:
          printLine(json.dumps(cleanJSON(groupInfo), ensure_ascii=False, sort_keys=True))
        else:
          row = flattenJSON(groupInfo)
          if csvPF.CheckRowTitles(row):
            row = {'User': user, 'Group': groupEmail, 'Name': group['name']}
            if rolesSet:
              row['Role'] = role
            row['JSON'] = json.dumps(cleanJSON(groupInfo),
                                     ensure_ascii=False, sort_keys=True)
            csvPF.WriteRowNoFilter(row)
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('User Group Trees')

# gam <UserTypeEntity> print groupslist [todrive <ToDriveAttribute>*]
#	[(domain <DomainName>)|(customerid <CustomerID>)]
#	[delimiter <Character>] [quotechar <Character>]
def printUserGroupsList(users):
  cd = buildGAPIObject(API.DIRECTORY)
  kwargs = {'customer': GC.Values[GC.CUSTOMER_ID]}
  csvPF = CSVPrintFile(['User', 'Groups', 'GroupsList'])
  FJQC = FormatJSONQuoteChar(csvPF)
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif _getUserGroupDomainCustomerId(myarg, kwargs):
      pass
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    else:
      FJQC.GetQuoteChar(myarg)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    printGettingAllEntityItemsForWhom(Ent.GROUP, user, i, count)
    _setUserGroupArgs(user, kwargs)
    try:
      entityList = callGAPIpages(cd.groups(), 'list', 'groups',
                                 pageMessage=getPageMessageForWhom(),
                                 throwReasons=GAPI.GROUP_LIST_USERKEY_THROW_REASONS,
                                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                                 orderBy='email', fields='nextPageToken,groups(email)', **kwargs)
    except (GAPI.invalidMember, GAPI.invalidInput):
      badRequestWarning(Ent.GROUP, Ent.MEMBER, user)
      continue
    except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.forbidden, GAPI.badRequest):
      if kwargs.get('domain'):
        badRequestWarning(Ent.GROUP, Ent.DOMAIN, kwargs['domain'])
        return
      accessErrorExit(cd)
    csvPF.WriteRow({'User': user, 'Groups': len(entityList), 'GroupsList': delimiter.join([group['email'] for group in entityList])})
  csvPF.writeCSVfile('User GroupsList')

# License command utilities
LICENSE_PRODUCT_SKUIDS = 'productSkuIds'
LICENSE_PREVIEW_TITLES = ['user', 'productId', 'skuId', 'action', 'message']

def getLicenseParameters(operation):
  lic = buildGAPIObject(API.LICENSING)
  parameters = {LICENSE_PRODUCT_SKUIDS: [], 'csvPF': None, 'preview': False, 'syncOperation': 'addremove', 'syncACLsMode': None, 'archive': False}
  skuLocation = Cmd.Location()
  if operation != 'patch':
    parameters[LICENSE_PRODUCT_SKUIDS] = getGoogleSKUList(allowUnknownProduct=True)
  else:
    parameters[LICENSE_PRODUCT_SKUIDS] = [getGoogleSKU()]
  if checkArgumentPresent(['product', 'productid']):
    productId = getGoogleProduct()
    for productSku in parameters[LICENSE_PRODUCT_SKUIDS]:
      productSku = (productId, productSku[1])
  if operation == 'patch':
    checkArgumentPresent('from')
    productId, oldSkuId = getGoogleSKU()
    if not productId:
      invalidChoiceExit(oldSkuId, SKU.getSortedSKUList(), True)
    skuId = parameters[LICENSE_PRODUCT_SKUIDS][0][1]
    parameters[LICENSE_PRODUCT_SKUIDS] = [(productId, skuId, oldSkuId)]
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if operation == 'sync' and myarg in {'addonly', 'removeonly'}:
      parameters['syncOperation'] = myarg
    elif operation == 'sync' and myarg in {'allskus', 'onesku'}:
      parameters['syncACLsMode'] = myarg
    elif myarg == 'preview':
      parameters['preview'] = True
    elif myarg == 'actioncsv':
      titles = LICENSE_PREVIEW_TITLES
      if operation == 'patch':
        titles.insert(2, 'oldskuId')
      parameters['csvPF'] = CSVPrintFile(titles)
    elif operation == 'patch' and myarg == 'archive':
      if skuId not in SKU.ARCHIVABLE_SKUS:
        usageErrorExit(Msg.SKU_HAS_NO_MATCHING_ARCHIVED_USER_SKU.format(skuId))
      parameters['archive'] = True
    else:
      unknownArgumentExit()
  for productSku in parameters[LICENSE_PRODUCT_SKUIDS]:
    if not productSku[0]:
      Cmd.SetLocation(skuLocation)
      invalidChoiceExit(productSku[1], SKU.getSortedSKUList(), False)
  if operation == 'sync':
    if len(parameters[LICENSE_PRODUCT_SKUIDS]) > 1:
      if parameters['syncACLsMode'] is None:
        missingArgumentExit('allskus|onesku')
      if parameters['syncACLsMode'] == 'onesku':
        # With onesku, all SKU productIds must be the same
        baseProductId = parameters[LICENSE_PRODUCT_SKUIDS][0][0]
        baseSkuId = parameters[LICENSE_PRODUCT_SKUIDS][0][1]
        for i in range(1, len(parameters[LICENSE_PRODUCT_SKUIDS])):
          productId = parameters[LICENSE_PRODUCT_SKUIDS][i][0]
          if baseProductId != productId:
            skuId = parameters[LICENSE_PRODUCT_SKUIDS][i][1]
            Cmd.SetLocation(skuLocation)
            usageErrorExit(Msg.ALL_SKU_PRODUCTIDS_MUST_MATCH.format(f'{baseProductId}:{baseSkuId}', f'{productId}:{skuId}'))
    else:
      parameters['syncACLsMode'] = 'allskus'
  return (lic, parameters)

def _writeLicenseAction(productId, skuId, oldSkuId, parameters, user, action, message):
  actionName = Act.PerformedName(action) if message == Act.SUCCESS else Act.ToPerformName(action)
  if action != Act.UPDATE:
    parameters['csvPF'].WriteRow({'user': user,
                                  'productId': productId,
                                  'skuId': skuId,
                                  'action': actionName,
                                  'message': message})
  else:
    parameters['csvPF'].WriteRow({'user': user,
                                  'productId': productId,
                                  'oldskuId': oldSkuId,
                                  'skuId': skuId,
                                  'action': actionName,
                                  'message': message})

def _createLicenses(lic, productId, skuId, parameters, jcount, users, i, count, returnDoneSet=False):
  Act.Set([Act.ADD, Act.ADD_PREVIEW][parameters['preview']])
  if parameters['preview']:
    message = Act.PREVIEW
  noAvailableLicenses = False
  doneSet = set()
  if not returnDoneSet:
    entityPerformActionModifierNumItems([Ent.LICENSE, SKU.skuIdToDisplayName(skuId)], Msg.TO_LC, jcount, Ent.USER, i, count)
  else:
    entityPerformActionModifierNumItems([Ent.LICENSE, SKU.skuIdToDisplayName(skuId)], Msg.TO_MAXIMUM_OF, jcount, Ent.USER, i, count)
  Ind.Increment()
  j = 0
  for user in users:
    j += 1
    if returnDoneSet:
      origUser = user
      doneSet.add(user)
    user = normalizeEmailAddressOrUID(user)
    if not noAvailableLicenses:
      try:
        if not parameters['preview']:
          callGAPI(lic.licenseAssignments(), 'insert',
                   bailOnInternalError=True,
                   throwReasons=[GAPI.INTERNAL_ERROR, GAPI.DUPLICATE, GAPI.CONDITION_NOT_MET, GAPI.INVALID,
                                 GAPI.USER_NOT_FOUND, GAPI.FORBIDDEN, GAPI.BACKEND_ERROR, GAPI.SERVICE_NOT_AVAILABLE],
                   retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                   productId=productId, skuId=skuId, body={'userId': user}, fields='')
          message = Act.SUCCESS
        entityActionPerformed([Ent.USER, user, Ent.LICENSE, SKU.formatSKUIdDisplayName(skuId)], j, jcount)
      except (GAPI.internalError, GAPI.duplicate, GAPI.invalid,
              GAPI.forbidden, GAPI.serviceNotAvailable) as e:
        message = str(e)
        entityActionFailedWarning([Ent.USER, user, Ent.LICENSE, SKU.formatSKUIdDisplayName(skuId)], message, j, jcount)
      except (GAPI.conditionNotMet, GAPI.backendError) as e:
        message = str(e)
        entityActionFailedWarning([Ent.USER, user, Ent.LICENSE, SKU.formatSKUIdDisplayName(skuId)], message, j, jcount)
        if ("there aren't enough available licenses" in message.lower() or
            "backend error" in message.lower()):
          noAvailableLicenses = True
          if returnDoneSet:
            doneSet.remove(origUser)
            break
      except GAPI.userNotFound as e:
        message = str(e)
        entityUnknownWarning(Ent.USER, user, j, jcount)
    else:
      entityActionFailedWarning([Ent.USER, user, Ent.LICENSE, SKU.formatSKUIdDisplayName(skuId)], message, j, jcount)
    if parameters['csvPF']:
      _writeLicenseAction(productId, skuId, None, parameters, user, Act.ADD, message)
  Ind.Decrement()
  if returnDoneSet:
    return doneSet

# gam <UserTypeEntity> create license <SKUIDList> [product|productid <ProductID>] [preview] [actioncsv]
def createLicense(users):
  lic, parameters = getLicenseParameters('insert')
  _, jcount, users = getEntityArgument(users)
  count = len(parameters[LICENSE_PRODUCT_SKUIDS])
  i = 0
  for productSku in parameters[LICENSE_PRODUCT_SKUIDS]:
    i += 1
    _createLicenses(lic, productSku[0], productSku[1], parameters, jcount, users, i, count)
  if parameters['csvPF']:
    parameters['csvPF'].writeCSVfile('Create Licenses')

# gam <UserTypeEntity> update license <SKUID> [product|productid <ProductID>] [from] <SKUID>
#	[preview] [actioncsv] [archive]
def updateLicense(users):
  lic, parameters = getLicenseParameters('patch')
  j, jcount, users = getEntityArgument(users)
  Act.Set([Act.UPDATE, Act.UPDATE_PREVIEW][parameters['preview']])
  cd = None
  if parameters['preview']:
    message = Act.PREVIEW
  elif parameters['archive']:
    cd = buildGAPIObject(API.DIRECTORY)
  productId, skuId, oldSkuId = parameters[LICENSE_PRODUCT_SKUIDS][0]
  body = {'skuId': skuId}
  entityPerformActionModifierNumItems([Ent.LICENSE, SKU.skuIdToDisplayName(skuId)], Msg.FOR, jcount, Ent.USER)
  Ind.Increment()
  for user in users:
    j += 1
    user = normalizeEmailAddressOrUID(user)
    try:
      if not parameters['preview']:
        callGAPI(lic.licenseAssignments(), 'patch',
                 bailOnInternalError=True,
                 throwReasons=[GAPI.INTERNAL_ERROR, GAPI.NOT_FOUND, GAPI.CONDITION_NOT_MET, GAPI.INVALID,
                               GAPI.USER_NOT_FOUND, GAPI.FORBIDDEN, GAPI.BACKEND_ERROR, GAPI.SERVICE_NOT_AVAILABLE],
                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                 productId=productId, skuId=oldSkuId, userId=user, body=body, fields='')
        message = Act.SUCCESS
      entityModifierNewValueActionPerformed([Ent.USER, user, Ent.LICENSE, SKU.skuIdToDisplayName(skuId)],
                                            Act.MODIFIER_FROM, SKU.skuIdToDisplayName(oldSkuId), j, jcount)
    except (GAPI.internalError, GAPI.notFound, GAPI.conditionNotMet, GAPI.invalid,
            GAPI.forbidden, GAPI.backendError, GAPI.serviceNotAvailable) as e:
      message = str(e)
      entityActionFailedWarning([Ent.USER, user, Ent.LICENSE, SKU.formatSKUIdDisplayName(oldSkuId)], message, j, jcount)
    except GAPI.userNotFound as e:
      message = str(e)
      entityUnknownWarning(Ent.USER, user, j, jcount)
    if parameters['archive'] and message == Act.SUCCESS:
      Act.Set(Act.ARCHIVE)
      try:
        callGAPI(cd.users(), 'update',
                 throwReasons=[GAPI.USER_NOT_FOUND, GAPI.DOMAIN_NOT_FOUND,
                               GAPI.FORBIDDEN, GAPI.BAD_REQUEST,
                               GAPI.INSUFFICIENT_ARCHIVED_USER_LICENSES],
                 retryReasons=[GAPI.INSUFFICIENT_ARCHIVED_USER_LICENSES],
                 userKey=user, body={'archived': True})
        entityActionPerformed([Ent.USER, user], j, jcount)
      except GAPI.userNotFound:
        entityUnknownWarning(Ent.USER, user, j, jcount)
      except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest,
              GAPI.insufficientArchivedUserLicenses) as e:
        entityActionFailedWarning([Ent.USER, user], str(e), j, jcount)
      Act.Set(Act.UPDATE)
    if parameters['csvPF']:
      _writeLicenseAction(productId, skuId, oldSkuId, parameters, user, Act.UPDATE, message)
  Ind.Decrement()
  if parameters['csvPF']:
    parameters['csvPF'].writeCSVfile('Update Licenses')

def _deleteLicenses(lic, productId, skuId, parameters, jcount, users, i, count):
  Act.Set([Act.DELETE, Act.DELETE_PREVIEW][parameters['preview']])
  if parameters['preview']:
    message = Act.PREVIEW
  entityPerformActionModifierNumItems([Ent.LICENSE, SKU.skuIdToDisplayName(skuId)], Msg.FROM_LC, jcount, Ent.USER, i, count)
  Ind.Increment()
  j = 0
  for user in users:
    j += 1
    user = normalizeEmailAddressOrUID(user)
    try:
      if not parameters['preview']:
        callGAPI(lic.licenseAssignments(), 'delete',
                 throwReasons=[GAPI.INTERNAL_ERROR, GAPI.NOT_FOUND, GAPI.CONDITION_NOT_MET, GAPI.INVALID,
                               GAPI.USER_NOT_FOUND, GAPI.FORBIDDEN, GAPI.BACKEND_ERROR, GAPI.SERVICE_NOT_AVAILABLE],
                 retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                 productId=productId, skuId=skuId, userId=user)
        message = Act.SUCCESS
      entityActionPerformed([Ent.USER, user, Ent.LICENSE, SKU.formatSKUIdDisplayName(skuId)], j, jcount)
    except (GAPI.internalError, GAPI.notFound, GAPI.conditionNotMet, GAPI.invalid,
            GAPI.forbidden, GAPI.backendError, GAPI.serviceNotAvailable) as e:
      message = str(e)
      entityActionFailedWarning([Ent.USER, user, Ent.LICENSE, SKU.formatSKUIdDisplayName(skuId)], message, j, jcount)
    except GAPI.userNotFound as e:
      message = str(e)
      entityUnknownWarning(Ent.USER, user, j, jcount)
    if parameters['csvPF']:
      _writeLicenseAction(productId, skuId, None, parameters, user, Act.DELETE, message)
  Ind.Decrement()

# gam <UserTypeEntity> delete license <SKUIDList> [product|productid <ProductID>] [preview] [actioncsv]
def deleteLicense(users):
  lic, parameters = getLicenseParameters('delete')
  _, jcount, users = getEntityArgument(users)
  count = len(parameters[LICENSE_PRODUCT_SKUIDS])
  i = 0
  for productSku in parameters[LICENSE_PRODUCT_SKUIDS]:
    i += 1
    _deleteLicenses(lic, productSku[0], productSku[1], parameters, jcount, users, i, count)
  if parameters['csvPF']:
    parameters['csvPF'].writeCSVfile('Delete Licenses')

# gam <UserTypeEntity> sync license <SKUIDList> [product|productid <ProductID>]
#	[addonly|removeonly] [allskus|onesku] [preview] [actioncsv]
def syncLicense(users):
  lic, parameters = getLicenseParameters('sync')
  _, _, users = getEntityArgument(users)
  usersSet = set()
  currentLicenses = {}
  for user in users:
    usersSet.add(normalizeEmailAddressOrUID(user))
  for productSku in parameters[LICENSE_PRODUCT_SKUIDS]:
    skuId = productSku[1]
    currentLicenses[skuId] = set(getItemsToModify(Cmd.ENTITY_LICENSES, skuId))
  count = len(parameters[LICENSE_PRODUCT_SKUIDS])
  if parameters['syncACLsMode'] != 'onesku':
    i = 0
    for productSku in parameters[LICENSE_PRODUCT_SKUIDS]:
      i += 1
      if parameters['syncOperation'] != 'addonly':
        deleteSet = currentLicenses[skuId]-usersSet
        _deleteLicenses(lic, productSku[0], productSku[1], parameters, len(deleteSet), deleteSet, i, count)
      if parameters['syncOperation'] != 'removeonly':
        addSet = usersSet-currentLicenses[skuId]
        _createLicenses(lic, productSku[0], productSku[1], parameters, len(addSet), addSet, i, count)
  else: #parameters['syncACLsMode'] == 'onesku':
    if parameters['syncOperation'] != 'addonly':
      i = 0
      for productSku in parameters[LICENSE_PRODUCT_SKUIDS]:
        i += 1
        deleteSet = currentLicenses[productSku[1]]-usersSet
        _deleteLicenses(lic, productSku[0], productSku[1], parameters, len(deleteSet), deleteSet, i, count)
    if parameters['syncOperation'] != 'removeonly':
      addSet = usersSet.copy()
      for productSku in parameters[LICENSE_PRODUCT_SKUIDS]:
        addSet = addSet-currentLicenses[productSku[1]]
      i = 0
      for productSku in parameters[LICENSE_PRODUCT_SKUIDS]:
        if not addSet:
          break
        i += 1
        addSet -= _createLicenses(lic, productSku[0], productSku[1], parameters, len(addSet), addSet, i, count, returnDoneSet=True)
      if addSet:
        productId = productSku[0]
        skuIds = []
        for productSku in parameters[LICENSE_PRODUCT_SKUIDS]:
          skuIds.append(productSku[1])
        skuIdsList = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER].join(skuIds)
        message = Msg.NO_AVAILABLE_LICENSES
        for user in addSet:
          user = normalizeEmailAddressOrUID(user)
          entityActionFailedWarning([Ent.USER, user, Ent.LICENSE, skuIdsList], message)
          if parameters['csvPF']:
            _writeLicenseAction(productId, skuIdsList, None, parameters, user, Act.ADD, message)
  if parameters['csvPF']:
    parameters['csvPF'].writeCSVfile('Sync Licenses')

# gam <UserTypeEntity> update photo
#	([<FileNamePattern>] |
#	 ([drivedir|(sourcefolder <FilePath>)] [filename <FileNamePattern>]) |
#	 (gphoto <EmailAddress> <DriveFileIDEntity>|<DriveFileNameEntity>))
# #user# and #email" will be replaced with user email address #username# will be replaced by portion of email address in front of @
# in <FileNamePattern> and <DriveFileNameEntity>
def updatePhoto(users):
  cd = buildGAPIObject(API.DIRECTORY)
  baseFileIdEntity = drive = owner = None
  sourceFolder = os.getcwd()
  if Cmd.NumArgumentsRemaining() == 1:
    filenamePattern = getString(Cmd.OB_PHOTO_FILENAME_PATTERN)
  else:
    filenamePattern = '#email#.jpg'
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg == 'drivedir':
        sourceFolder = GC.Values[GC.DRIVE_DIR]
      elif myarg == 'sourcefolder':
        sourceFolder = os.path.expanduser(getString(Cmd.OB_FILE_PATH))
        if not os.path.isdir(sourceFolder):
          entityDoesNotExistExit(Ent.DIRECTORY, sourceFolder)
      elif myarg == 'filename':
        filenamePattern = getString(Cmd.OB_PHOTO_FILENAME_PATTERN)
      elif myarg == 'gphoto':
        owner, drive = buildGAPIServiceObject(API.DRIVE3, getEmailAddress())
        if not drive:
          return
        baseFileIdEntity = getDriveFileEntity(queryShortcutsOK=False)
      else:
        unknownArgumentExit()
  p = re.compile('^(ht|f)tps?://.*$')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, userName, _ = splitEmailAddressOrUID(user)
    filename = _substituteForUser(filenamePattern, user, userName)
    if baseFileIdEntity is not None:
      fileIdEntity = baseFileIdEntity.copy()
      if fileIdEntity['query'] is not None:
        fileIdEntity['query'] = _substituteForUser(fileIdEntity['query'], user, userName)
      _, _, jcount = _validateUserGetFileIDs(owner, 0, 0, fileIdEntity, drive=drive, entityType=None)
      if jcount == 0:
        entityItemValueListActionNotPerformedWarning([Ent.USER, user], [Ent.OWNER, owner],
                                                     Msg.NO_ENTITIES_FOUND.format(Ent.Singular(Ent.DRIVE_FILE)), i, count)
        continue
      if jcount > 1:
        entityItemValueListActionNotPerformedWarning([Ent.USER, user], [Ent.OWNER, owner],
                                                     Msg.MULTIPLE_ENTITIES_FOUND.format(Ent.Plural(Ent.DRIVE_FILE), jcount, ','.join(fileIdEntity['list'])), i, count)
        continue
      fb = TemporaryFile(mode='wb+')
      filename = fileIdEntity['list'][0]
      request = drive.files().get_media(fileId=filename)
      downloader = googleapiclient.http.MediaIoBaseDownload(fb, request)
      done = False
      while not done:
        _, done = downloader.next_chunk()
      fb.seek(0)
      image_data = fb.read()
      fb.close()
    elif p.match(filename):
      try:
        status, image_data = getHttpObj().request(filename, 'GET')
        if status['status'] != '200':
          entityActionFailedWarning([Ent.USER, user, Ent.PHOTO, filename], Msg.NOT_ALLOWED, i, count)
          continue
      except (httplib2.HttpLib2Error, google.auth.exceptions.TransportError, RuntimeError) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.PHOTO, filename], str(e), i, count)
        continue
    else:
      if sourceFolder is not None:
        filename = os.path.join(sourceFolder, filename)
      try:
        with open(os.path.expanduser(filename), 'rb') as f:
          image_data = f.read()
      except (OSError, IOError) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.PHOTO, filename], str(e), i, count)
        continue
    body = {'photoData': base64.urlsafe_b64encode(image_data).decode(UTF8)}
    try:
      callGAPI(cd.users().photos(), 'update',
               throwReasons=[GAPI.USER_NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID_INPUT, GAPI.CONDITION_NOT_MET],
               userKey=user, body=body, fields='')
      entityActionPerformed([Ent.USER, user, Ent.PHOTO, filename], i, count)
    except (GAPI.invalidInput, GAPI.conditionNotMet) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.PHOTO, filename], str(e), i, count)
    except (GAPI.userNotFound, GAPI.forbidden):
      entityUnknownWarning(Ent.USER, user, i, count)

# gam <UserTypeEntity> delete photo
def deletePhoto(users):
  cd = buildGAPIObject(API.DIRECTORY)
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    try:
      callGAPI(cd.users().photos(), 'delete',
               bailOnInternalError=True,
               throwReasons=[GAPI.USER_NOT_FOUND, GAPI.FORBIDDEN, GAPI.PHOTO_NOT_FOUND, GAPI.INTERNAL_ERROR],
               userKey=user)
      entityActionPerformed([Ent.USER, user, Ent.PHOTO, ''], i, count)
    except (GAPI.photoNotFound, GAPI.internalError) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.PHOTO, ''], str(e), i, count)
    except (GAPI.userNotFound, GAPI.forbidden):
      entityUnknownWarning(Ent.USER, user, i, count)

def getPhoto(users, profileMode):
  cd = buildGAPIObject(API.DIRECTORY)
  targetFolder = os.getcwd()
  filenamePattern = '#email#.#ext#'
  noDefault = returnURLonly = False
  writeFileData = showPhotoData = True
  size = ''
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'drivedir':
      targetFolder = GC.Values[GC.DRIVE_DIR]
    elif myarg == 'targetfolder':
      targetFolder = os.path.expanduser(getString(Cmd.OB_FILE_PATH))
      if not os.path.isdir(targetFolder):
        os.makedirs(targetFolder)
    elif myarg == 'filename':
      filenamePattern = getString(Cmd.OB_PHOTO_FILENAME_PATTERN)
    elif myarg == 'nofile':
      writeFileData = False
    elif myarg == 'noshow':
      showPhotoData = False
    elif profileMode and myarg == 'returnurlonly':
      returnURLonly = True
    elif myarg == 'nodefault':
      noDefault = True
    elif profileMode and myarg == 'size':
      size = getInteger(minVal=50)
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    if profileMode:
      user, people = buildGAPIServiceObject(API.PEOPLE, user, i, count)
      if not people:
        continue
    else:
      user = normalizeEmailAddressOrUID(user)
    _, userName, _ = splitEmailAddressOrUID(user)
    filename = os.path.join(targetFolder, _substituteForUser(filenamePattern, user, userName))
    try:
      if not showPhotoData:
        entityPerformActionNumItems([Ent.USER, user], 1, Ent.PHOTO, i, count)
      if not profileMode:
        photo = callGAPI(cd.users().photos(), 'get',
                         throwReasons=[GAPI.USER_NOT_FOUND, GAPI.FORBIDDEN, GAPI.PHOTO_NOT_FOUND],
                         userKey=user)
        if showPhotoData:
          writeStdout(photo['photoData']+'\n')
        photo_data = base64.urlsafe_b64decode(photo['photoData'])
      else:
        result = callGAPI(people.people(), 'get',
                          throwReasons=[GAPI.NOT_FOUND],
                          retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
                          resourceName='people/me', personFields='photos')
        default = False
        url = None
        for photo in result.get('photos', []):
          if photo['metadata']['source']['type'] == 'PROFILE':
            default = photo.get('default', False)
            url = photo['url']
            break
        if not url:
          entityActionFailedWarning([Ent.USER, user, Ent.PHOTO, None], Msg.PROFILE_PHOTO_NOT_FOUND, i, count)
          continue
        if noDefault and default:
          entityActionFailedWarning([Ent.USER, user, Ent.PHOTO, None], Msg.PROFILE_PHOTO_IS_DEFAULT, i, count)
          continue
        if returnURLonly:
          writeStdout(f'{url}\n')
          continue
        if size:
          url = re.sub(r"=s\d+$", f"=s{size}", url)
        try:
          status, photo_data = getHttpObj().request(url, 'GET')
          if status['status'] != '200':
            entityActionFailedWarning([Ent.USER, user, Ent.PHOTO, filename], Msg.NOT_ALLOWED, i, count)
            continue
          if showPhotoData:
            writeStdout(base64.encodebytes(photo_data).decode(UTF8))
        except (httplib2.HttpLib2Error, google.auth.exceptions.TransportError, RuntimeError) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.PHOTO, filename], str(e), i, count)
          continue
      if writeFileData:
        if photo_data[:3] == b'\xff\xd8\xff':
          extension = 'jpg'
        elif photo_data[:8] == b'\x89\x50\x4e\x47\x0d\x0a\x1a\x0a':
          extension = 'png'
        elif photo_data[:6] == b'\x47\x49\x46\x38\x37\x61' or photo_data[:6] == b'\x47\x49\x46\x38\x39\x61':
          extension = 'gif'
        elif photo_data[:2] == b'\x42\x4d':
          extension= 'bmp'
        elif photo_data[:4] == b'\x49\x49\x2A\x00' or photo_data[:4] == b'\x4D\x4D\x00\x2A':
          extension= 'tif'
        else:
          extension = 'img'
        filenameExt = filename.replace('#ext#', extension)
        status, e = writeFileReturnError(filenameExt, photo_data, mode='wb')
        if status:
          if not showPhotoData:
            entityActionPerformed([Ent.USER, user, Ent.PHOTO, filenameExt], i, count)
        else:
          entityActionFailedWarning([Ent.USER, user, Ent.PHOTO, filenameExt], str(e), i, count)
    except (GAPI.notFound, GAPI.photoNotFound) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.PHOTO, None], str(e), i, count)
    except (GAPI.userNotFound, GAPI.forbidden):
      entityUnknownWarning(Ent.USER, user, i, count)

# gam <UserTypeEntity> get photo [drivedir|(targetfolder <FilePath>)] [filename <FileNamePattern>]]
#	[noshow] [nofile]
def getUserPhoto(users):
  getPhoto(users, False)

# gam <UserTypeEntity> get profilephoto [drivedir|(targetfolder <FilePath>)] [filename <FileNamePattern>]
#	[noshow] [nofile] [returnurlonly] [nodefault] [size <Integer>]
def getProfilePhoto(users):
  getPhoto(users, True)

PROFILE_SHARING_CHOICE_MAP = {
  'share': True,
  'shared': True,
  'unshare': False,
  'unshared': False,
  }

def _setShowProfile(users, function, **kwargs):
  cd = buildGAPIObject(API.DIRECTORY)
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    try:
      result = callGAPI(cd.users(), function,
                        throwReasons=[GAPI.USER_NOT_FOUND, GAPI.FORBIDDEN],
                        userKey=user, fields='includeInGlobalAddressList', **kwargs)
      printEntity([Ent.USER, user, Ent.PROFILE_SHARING_ENABLED, result.get('includeInGlobalAddressList', UNKNOWN)], i, count)
    except (GAPI.userNotFound, GAPI.forbidden):
      entityUnknownWarning(Ent.USER, user, i, count)

# gam <UserTypeEntity> profile share|shared|unshare|unshared
def setProfile(users):
  body = {'includeInGlobalAddressList': getChoice(PROFILE_SHARING_CHOICE_MAP, mapChoice=True)}
  _setShowProfile(users, 'update', body=body)

# gam <UserTypeEntity> show profile
def showProfile(users):
  _setShowProfile(users, 'get')

# gam <UserTypeEntity> create sheet
#	((json [charset <Charset>] <SpreadsheetJSONCreateRequest>) |
#	 (json file <FileName> [charset <Charset>]))
#	[<DriveFileParentAttribute>]
#	[formatjson] [returnidonly]
def createSheet(users):
  parameters = initDriveFileAttributes()
  parentBody = {}
  changeParents = returnIdOnly = False
  addParents = ''
  removeParents = ROOT
  body = {}
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'json':
      body = getJSON([])
    elif getDriveFileParentAttribute(myarg, parameters):
      changeParents = True
    elif myarg == 'returnidonly':
      returnIdOnly = True
    else:
      FJQC.GetFormatJSON(myarg)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, sheet = buildGAPIServiceObject(API.SHEETS, user, i, count)
    if not sheet:
      continue
    if changeParents:
      user, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
      if not drive:
        continue
      if not _getDriveFileParentInfo(drive, user, i, count, parentBody, parameters):
        continue
      addParents = ','.join(parentBody['parents'])
    try:
      result = callGAPI(sheet.spreadsheets(), 'create',
                        throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                        body=body)
      spreadsheetId = result['spreadsheetId']
      if not returnIdOnly and not FJQC.formatJSON:
        entityActionPerformed([Ent.USER, user, Ent.SPREADSHEET, spreadsheetId], i, count)
      parentId = ROOT
      parentMsg = Act.SUCCESS
      if changeParents:
        try:
          callGAPI(drive.files(), 'update',
                   throwReasons=GAPI.DRIVE_ACCESS_THROW_REASONS+[GAPI.CANNOT_ADD_PARENT, GAPI.INSUFFICIENT_PARENT_PERMISSIONS],
                   fileId=result['spreadsheetId'],
                   addParents=addParents, removeParents=removeParents, fields='', supportsAllDrives=True)
          parentId = addParents
        except (GAPI.fileNotFound, GAPI.forbidden, GAPI.permissionDenied,
                GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions, GAPI.badRequest,
                GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition,
                GAPI.cannotAddParent) as e:
          parentMsg = f'{ERROR_PREFIX}{addParents}: {str(e)}'
        except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
          parentMsg = f'{ERROR_PREFIX}{addParents}: {str(e)}'
      if returnIdOnly:
        writeStdout(f'{spreadsheetId}\n')
        continue
      if FJQC.formatJSON:
        printLine('{'+f'"User": "{user}", "spreadsheetId": "{spreadsheetId}", "parentId": "{parentId}", '\
                    '"parentAssignment": "{parentMsg}", "JSON": {json.dumps(result, ensure_ascii=False, sort_keys=False)}'+'}')
        continue
      Ind.Increment()
      for field in ['spreadsheetId', 'spreadsheetUrl']:
        printKeyValueList([field, result[field]])
      printKeyValueList(['parentId', parentId])
      printKeyValueList(['parentAssignment', parentMsg])
      for field in ['properties', 'sheets', 'namedRanges', 'developerMetadata']:
        if field in result:
          showJSON(field, result[field])
      Ind.Decrement()
    except (GAPI.notFound, GAPI.forbidden, GAPI.internalError,
            GAPI.insufficientFilePermissions, GAPI.unknownError, GAPI.badRequest,
            GAPI.invalid, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.SPREADSHEET, ''], str(e), i, count)
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)

def _validateUserGetSpreadsheetIDs(user, i, count, fileIdEntity, showEntityType):
  user, _, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity, entityType=Ent.SPREADSHEET if showEntityType else None)
  if jcount == 0:
    return (user, None, 0)
  user, sheet = buildGAPIServiceObject(API.SHEETS, user, i, count)
  if not sheet:
    return (user, None, 0)
  return (user, sheet, jcount)

# gam <UserTypeEntity> update sheet <DriveFileEntity>
#	((json [charset <Charset>] <SpreadsheetJSONUpdateRequest>) |
#	 (json file <FileName> [charset <Charset>]))
#	[formatjson]
def updateSheets(users):
  spreadsheetIdEntity = getDriveFileEntity()
  body = {}
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'json':
      body = getJSON([])
    else:
      FJQC.GetFormatJSON(myarg)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, sheet, jcount = _validateUserGetSpreadsheetIDs(user, i, count, spreadsheetIdEntity, not FJQC.formatJSON)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for spreadsheetId in spreadsheetIdEntity['list']:
      j += 1
      try:
        result = callGAPI(sheet.spreadsheets(), 'batchUpdate',
                          throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                          spreadsheetId=spreadsheetId, body=body)
        if FJQC.formatJSON:
          printLine('{'+f'"User": "{user}", "spreadsheetId": "{spreadsheetId}", "JSON": {json.dumps(result, ensure_ascii=False, sort_keys=False)}'+'}')
          continue
        entityActionPerformed([Ent.USER, user, Ent.SPREADSHEET, spreadsheetId], j, jcount)
        Ind.Increment()
        for field in ['replies', 'updatedSpreadsheet']:
          if field in result:
            showJSON(field, result[field])
        Ind.Decrement()
      except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
              GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
              GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SPREADSHEET, spreadsheetId], str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()

SPREADSHEET_FIELDS_CHOICE_MAP = {
  'developermetadata': 'developerMetadata',
  'namedranges': 'namedRanges',
  'properties': 'properties',
  'sheets': 'sheets',
  'spreadsheetid': 'spreadsheetId',
  'spreadsheeturl': 'spreadsheetUrl',
  }

SPREADSHEET_SHEETS_SUBFIELDS_CHOICE_MAP = {
  'properties': 'sheets.properties',
  'data': 'sheets.data',
  'merges': 'sheets.merges',
  'conditionalformats': 'sheets.conditionalFormats',
  'filterviews': 'sheets.filterViews',
  'protectedranges': 'sheets.protectedRanges',
  'basicfilter': 'sheets.basicFilter',
  'charts': 'sheets.charts',
  'bandedranges': 'sheets.bandedRanges',
  'developermetadata': 'sheets.developerMetadata',
  'rowgroups': 'sheets.rowGroups',
  'columngroups': 'sheets.columnGroups',
  'slicers': 'sheets.slicers',
  }

# gam <UserTypeEntity> info|show sheet <DriveFileEntity>
#	[fields <SpreadsheetFieldList>] [sheetsfields <SpreadsheetSheetsFieldList>]
#	(range <SpreadsheetRange>)* (rangelist <SpreadsheetRangeList>)*
#	[includegriddata [<Boolean>]] [shownames]
#	[formatjson]
# gam <UserTypeEntity> print sheet <DriveFileEntity> [todrive <ToDriveAttribute>*]
#	[fields <SpreadsheetFieldList>] [sheetsfields <SpreadsheetSheetsFieldList>]
#	(range <SpreadsheetRange>)* (rangelist <SpreadsheetRangeList>)*
#	[includegriddata [<Boolean>]] [shownames]
#	[formatjson [quotechar <Character>]]
def infoPrintShowSheets(users):
  csvPF = CSVPrintFile(['User', 'spreadsheetId'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  spreadsheetIdEntity = getDriveFileEntity()
  fieldsList = []
  ranges = []
  includeGridData = showSheetNames = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'range':
      ranges.append(getString(Cmd.OB_SPREADSHEET_RANGE))
    elif myarg == 'rangelist':
      ranges.extend(convertEntityToList(getString(Cmd.OB_SPREADSHEET_RANGE_LIST), shlexSplit=True))
    elif myarg == 'includegriddata':
      includeGridData = getBoolean()
    elif getFieldsList(myarg, SPREADSHEET_FIELDS_CHOICE_MAP, fieldsList, initialField='spreadsheetId'):
      pass
    elif myarg == 'sheetsfields':
      for field in _getFieldsList():
        if field in SPREADSHEET_SHEETS_SUBFIELDS_CHOICE_MAP:
          fieldsList.append(SPREADSHEET_SHEETS_SUBFIELDS_CHOICE_MAP[field])
        else:
          invalidChoiceExit(field, SPREADSHEET_SHEETS_SUBFIELDS_CHOICE_MAP, True)
    elif myarg == 'shownames':
      showSheetNames = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if csvPF and showSheetNames:
    csvPF.AddTitles('spreadsheetName')
    csvPF.SetSortAllTitles()
    if FJQC.formatJSON:
      csvPF.AddJSONTitles('spreadsheetName')
      csvPF.MoveJSONTitlesToEnd(['JSON'])
  if includeGridData and fieldsList:
    fieldsList.append(SPREADSHEET_SHEETS_SUBFIELDS_CHOICE_MAP['data'])
  fields = getFieldsFromFieldsList(fieldsList)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, sheet, jcount = _validateUserGetSpreadsheetIDs(user, i, count, spreadsheetIdEntity, not FJQC.formatJSON)
    if jcount == 0:
      continue
    if showSheetNames:
      _, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
      if not drive:
        continue
    Ind.Increment()
    j = 0
    for spreadsheetId in spreadsheetIdEntity['list']:
      j += 1
      try:
        result = callGAPI(sheet.spreadsheets(), 'get',
                          throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                          spreadsheetId=spreadsheetId, ranges=ranges, includeGridData=includeGridData, fields=fields)
        if not includeGridData and 'sheets' in result:
          for usheet in result['sheets']:
            usheet.pop('data', None)
        if showSheetNames:
          try:
            spreadsheetName = callGAPI(drive.files(), 'get',
                                       throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
                                       fileId=spreadsheetId, fields='name', supportsAllDrives=True)['name']
          except (GAPI.fileNotFound, GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy):
            spreadsheetName = spreadsheetId
        if not csvPF:
          if FJQC.formatJSON:
            baserow = {'User': user, 'spreadsheetId': spreadsheetId}
            if showSheetNames:
              baserow['spreadsheetName'] = spreadsheetName
            baserow['JSON'] =  result
            printLine(json.dumps(baserow, ensure_ascii=False, sort_keys=False)+'\n')
            continue
          if showSheetNames:
            printEntity([Ent.SPREADSHEET, f'{spreadsheetName}({spreadsheetId})'], j, jcount)
          else:
            printEntity([Ent.SPREADSHEET, spreadsheetId], j, jcount)
          Ind.Increment()
          if 'spreadsheetUrl' in result:
            printKeyValueList(['spreadsheetUrl', result['spreadsheetUrl']])
          for field in ['properties', 'sheets', 'namedRanges', 'developerMetadata', 'dataSources', 'dataSourceSchedules']:
            if field in result:
              if field != 'sheets':
                showJSON(field, result[field])
              else:
                jcount = len(result[field])
                j = 0
                for usheet in result[field]:
                  j += 1
                  printEntity([Ent.SHEET, usheet.get('properties', {}).get('title', '')], j, jcount)
                  Ind.Increment()
                  showJSON(None, usheet)
                  Ind.Decrement()
          Ind.Decrement()
        else:
          baserow = {'User': user, 'spreadsheetId': spreadsheetId}
          if showSheetNames:
            baserow['spreadsheetName'] = spreadsheetName
          row = flattenJSON(result, flattened=baserow.copy())
          if not FJQC.formatJSON:
            csvPF.WriteRowTitles(row)
          elif csvPF.CheckRowTitles(row):
            baserow['JSON'] = json.dumps(cleanJSON(result), ensure_ascii=False, sort_keys=False)
            csvPF.WriteRowNoFilter(baserow)
      except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
              GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
              GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SPREADSHEET, spreadsheetId], str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('Spreadsheet')

SHEET_VALUE_INPUT_OPTIONS_MAP = {
  'raw': 'RAW',
  'userentered': 'USER_ENTERED',
  }
SHEET_DIMENSIONS_MAP = {
  'rows': 'ROWS',
  'columns': 'COLUMNS',
  }
SHEET_VALUE_RENDER_OPTIONS_MAP = {
  'formula': 'FORMULA',
  'formattedvalue': 'FORMATTED_VALUE',
  'unformattedvalue': 'UNFORMATTED_VALUE',
  }
SHEET_DATETIME_RENDER_OPTIONS_MAP = {
  'serialnumber': 'SERIAL_NUMBER',
  'formattedstring': 'FORMATTED_STRING',
  }
SHEET_INSERT_DATA_OPTIONS_MAP = {
  'overwrite': 'OVERWRITE',
  'insertrows': 'INSERT_ROWS',
  }

def _getSpreadsheetRangesValues(append):
  spreadsheetRangesValues = []
  kwargs = {
    'valueInputOption': 'USER_ENTERED',
    'includeValuesInResponse': False,
    'responseValueRenderOption': 'FORMATTED_VALUE',
    'responseDateTimeRenderOption': 'FORMATTED_STRING',
    }
  if append:
    kwargs['insertDataOption'] = 'INSERT_ROWS'
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'json':
      if append and spreadsheetRangesValues:
        usageErrorExit(Msg.ONLY_ONE_JSON_RANGE_ALLOWED)
      spreadsheetRangeValue = getJSON([])
      if isinstance(spreadsheetRangeValue, dict) and 'valueRanges' in spreadsheetRangeValue:
        spreadsheetRangesValues.extend(spreadsheetRangeValue['valueRanges'])
      elif isinstance(spreadsheetRangeValue, list):
        spreadsheetRangesValues.extend(spreadsheetRangeValue)
      else:
        spreadsheetRangesValues.append(spreadsheetRangeValue)
      if append and len(spreadsheetRangesValues) > 1:
        Cmd.Backup()
        usageErrorExit(Msg.ONLY_ONE_JSON_RANGE_ALLOWED)
    elif myarg in SHEET_VALUE_INPUT_OPTIONS_MAP:
      kwargs['valueInputOption'] = SHEET_VALUE_INPUT_OPTIONS_MAP[myarg]
    elif myarg == 'includevaluesinresponse':
      kwargs['includeValuesInResponse'] = getBoolean()
    elif myarg in SHEET_VALUE_RENDER_OPTIONS_MAP:
      kwargs['responseValueRenderOption'] = SHEET_VALUE_RENDER_OPTIONS_MAP[myarg]
    elif myarg in SHEET_DATETIME_RENDER_OPTIONS_MAP:
      kwargs['responseDateTimeRenderOption'] = SHEET_DATETIME_RENDER_OPTIONS_MAP[myarg]
    elif append and myarg in SHEET_INSERT_DATA_OPTIONS_MAP:
      kwargs['insertDataOption'] = SHEET_INSERT_DATA_OPTIONS_MAP[myarg]
    else:
      FJQC.GetFormatJSON(myarg)
  return (kwargs, spreadsheetRangesValues, FJQC)

def _showValueRange(valueRange):
  Ind.Increment()
  printKeyValueList(['majorDimension', valueRange['majorDimension']])
  printKeyValueList(['range', valueRange['range']])
  printKeyValueList(['value', '{'+f'"values": {json.dumps(valueRange.get("values", []), ensure_ascii=False, sort_keys=False)}'+'}'])
  Ind.Decrement()

def _showUpdateValuesResponse(result, k, kcount):
  printKeyValueListWithCount(['updatedRange', result['updatedRange']], k, kcount)
  Ind.Increment()
  for field in ['updatedRows', 'updatedColumns', 'updatedCells']:
    printKeyValueList([field, result[field]])
  if 'updatedData' in result:
    printKeyValueList(['updatedData', ''])
    _showValueRange(result['updatedData'])
  Ind.Decrement()

# gam <UserTypeEntity> append sheetrange <DriveFileEntity>
#	((json [charset <Charset>] <SpreadsheetJSONRangeValues>|<SpreadsheetJSONRangeValuesList>) |
#	 (json file <FileName> [charset <Charset>]))
#	[overwrite|insertrows]
#	[raw|userentered] [serialnumber|formattedstring] [formula|formattedvalue|unformattedvalue]
#	[includevaluesinresponse [<Boolean>]] [formatjson]
def appendSheetRanges(users):
  spreadsheetIdEntity = getDriveFileEntity()
  kwargs, spreadsheetRangesValues, FJQC = _getSpreadsheetRangesValues(True)
  kcount = len(spreadsheetRangesValues)
  body = spreadsheetRangesValues[0] if kcount > 0 else {}
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, sheet, jcount = _validateUserGetSpreadsheetIDs(user, i, count, spreadsheetIdEntity, not FJQC.formatJSON)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for spreadsheetId in spreadsheetIdEntity['list']:
      j += 1
      if not FJQC.formatJSON:
        entityPerformActionNumItems([Ent.USER, user, Ent.SPREADSHEET, spreadsheetId], kcount, Ent.SPREADSHEET_RANGE, j, jcount)
      Ind.Increment()
      k = 1
      try:
        result = callGAPI(sheet.spreadsheets().values(), 'append',
                          throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                          spreadsheetId=spreadsheetId, range=body['range'], body=body, **kwargs)
        if FJQC.formatJSON:
          printLine('{'+f'"User": "{user}", "spreadsheetId": "{spreadsheetId}", "JSON": {json.dumps(result, ensure_ascii=False, sort_keys=False)}'+'}')
          continue
        for field in ['tableRange']:
          if field in result:
            printKeyValueList([field, result[field]])
        _showUpdateValuesResponse(result['updates'], k, kcount)
      except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
              GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
              GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SPREADSHEET, spreadsheetId], str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
      Ind.Decrement()
    Ind.Decrement()

# gam <UserTypeEntity> update sheetrange <DriveFileEntity>
#	((json [charset <Charset>] <SpreadsheetJSONRangeValues>|<SpreadsheetJSONRangeValuesList>)+
#	 (json file <FileName> [charset <Charset>]))+
#	[raw|userentered] [serialnumber|formattedstring] [formula|formattedvalue|unformattedvalue]
#	[includevaluesinresponse [<Boolean>]] [formatjson]
def updateSheetRanges(users):
  spreadsheetIdEntity = getDriveFileEntity()
  body, spreadsheetRangesValues, FJQC = _getSpreadsheetRangesValues(False)
  body['data'] = spreadsheetRangesValues
  kcount = len(spreadsheetRangesValues)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, sheet, jcount = _validateUserGetSpreadsheetIDs(user, i, count, spreadsheetIdEntity, not FJQC.formatJSON)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for spreadsheetId in spreadsheetIdEntity['list']:
      j += 1
      if not FJQC.formatJSON:
        entityPerformActionNumItems([Ent.USER, user, Ent.SPREADSHEET, spreadsheetId], kcount, Ent.SPREADSHEET_RANGE, j, jcount)
      Ind.Increment()
      try:
        result = callGAPI(sheet.spreadsheets().values(), 'batchUpdate',
                          throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                          spreadsheetId=spreadsheetId, body=body)
        if FJQC.formatJSON:
          printLine('{'+f'"User": "{user}", "spreadsheetId": "{spreadsheetId}", "JSON": {json.dumps(result, ensure_ascii=False, sort_keys=False)}'+'}')
          continue
        for field in ['totalUpdatedRows', 'totalUpdatedColumns', 'totalUpdatedCells', 'totalUpdatedSheets']:
          printKeyValueList([field, result[field]])
        k = 0
        for response in result.get('responses', []):
          k += 1
          _showUpdateValuesResponse(response, k, kcount)
      except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
              GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
              GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SPREADSHEET, spreadsheetId], str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
      Ind.Decrement()
    Ind.Decrement()

# gam <UserTypeEntity> clear sheetrange <DriveFileEntity>
#	(range <SpreadsheetRange>)* (rangelist <SpreadsheetRangeList>)*
#	[formatjson]
def clearSheetRanges(users):
  spreadsheetIdEntity = getDriveFileEntity()
  body = {'ranges': []}
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'range':
      body['ranges'].append(getString(Cmd.OB_SPREADSHEET_RANGE))
    elif myarg == 'rangelist':
      body['ranges'].extend(convertEntityToList(getString(Cmd.OB_SPREADSHEET_RANGE_LIST), shlexSplit=True))
    else:
      FJQC.GetFormatJSON(myarg)
  kcount = len(body['ranges'])
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, sheet, jcount = _validateUserGetSpreadsheetIDs(user, i, count, spreadsheetIdEntity, not FJQC.formatJSON)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for spreadsheetId in spreadsheetIdEntity['list']:
      j += 1
      if not FJQC.formatJSON:
        entityPerformActionNumItems([Ent.USER, user, Ent.SPREADSHEET, spreadsheetId], kcount, Ent.SPREADSHEET_RANGE, j, jcount)
      Ind.Increment()
      try:
        result = callGAPIitems(sheet.spreadsheets().values(), 'batchClear', 'clearedRanges',
                               throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                               spreadsheetId=spreadsheetId, body=body)
        if FJQC.formatJSON:
          printLine('{'+f'"User": "{user}", "spreadsheetId": "{spreadsheetId}", "JSON": {json.dumps({"clearedRanges": result}, ensure_ascii=False, sort_keys=False)}'+'}')
          continue
        k = 0
        for clearedRange in result:
          k += 1
          printKeyValueListWithCount(['range', clearedRange], k, kcount)
      except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
              GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
              GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SPREADSHEET, spreadsheetId], str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
      Ind.Decrement()
    Ind.Decrement()

# gam <UserTypeEntity> print sheetrange <DriveFileEntity> [todrive <ToDriveAttribute>*]
#	(range <SpreadsheetRange>)* (rangelist <SpreadsheetRangeList>)*
#	[rows|columns] [serialnumber|formattedstring] [formula|formattedvalue|unformattedvalue]
#	[formatjson [quotechar <Character>] [valuerangesonly [<Boolean>]]]
# gam <UserTypeEntity> show sheetrange <DriveFileEntity>
#	(range <SpreadsheetRange>)* (rangelist <SpreadsheetRangeList>)*
#	[rows|columns] [serialnumber|formattedstring] [formula|formattedvalue|unformattedvalue]
#	[formatjson [valuerangesonly [<Boolean>]]]
def printShowSheetRanges(users):
  csvPF = CSVPrintFile(['User', 'spreadsheetId'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  spreadsheetIdEntity = getDriveFileEntity()
  spreadsheetRanges = []
  kwargs = {
    'majorDimension': 'ROWS',
    'valueRenderOption': 'FORMATTED_VALUE',
    'dateTimeRenderOption': 'FORMATTED_STRING',
    }
  valueRangesOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'range':
      spreadsheetRanges.append(getString(Cmd.OB_SPREADSHEET_RANGE))
    elif myarg == 'rangelist':
      spreadsheetRanges.extend(convertEntityToList(getString(Cmd.OB_SPREADSHEET_RANGE_LIST), shlexSplit=True))
    elif myarg == 'valuerangesonly':
      valueRangesOnly = getBoolean()
    elif myarg in SHEET_DIMENSIONS_MAP:
      kwargs['majorDimension'] = SHEET_DIMENSIONS_MAP[myarg]
    elif myarg in SHEET_VALUE_RENDER_OPTIONS_MAP:
      kwargs['valueRenderOption'] = SHEET_VALUE_RENDER_OPTIONS_MAP[myarg]
    elif myarg in SHEET_DATETIME_RENDER_OPTIONS_MAP:
      kwargs['dateTimeRenderOption'] = SHEET_DATETIME_RENDER_OPTIONS_MAP[myarg]
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if csvPF and FJQC.formatJSON and valueRangesOnly:
    csvPF.SetJSONTitles(['JSON'])
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, sheet, jcount = _validateUserGetSpreadsheetIDs(user, i, count, spreadsheetIdEntity, not csvPF and not FJQC.formatJSON)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for spreadsheetId in spreadsheetIdEntity['list']:
      j += 1
      try:
        result = callGAPI(sheet.spreadsheets().values(), 'batchGet',
                          throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
                          spreadsheetId=spreadsheetId, ranges=spreadsheetRanges, fields='valueRanges', **kwargs)
        valueRanges = result.get('valueRanges', [])
        kcount = len(valueRanges)
        if not csvPF:
          if FJQC.formatJSON:
            if not valueRangesOnly:
              printLine('{'+f'"User": "{user}", "spreadsheetId": "{spreadsheetId}", "JSON": {json.dumps(result, ensure_ascii=False, sort_keys=False)}'+'}')
            else:
              printLine(json.dumps(result.get('valueRanges', []), ensure_ascii=False, sort_keys=False))
            continue
          entityPerformActionNumItems([Ent.USER, user, Ent.SPREADSHEET, spreadsheetId], kcount, Ent.SPREADSHEET_RANGE, j, jcount)
          Ind.Increment()
          k = 0
          for valueRange in valueRanges:
            k += 1
            printKeyValueListWithCount(['range', valueRange['range']], k, kcount)
            _showValueRange(valueRange)
          Ind.Decrement()
        else:
          if kcount:
            row = flattenJSON(result, flattened={'User': user, 'spreadsheetId': spreadsheetId})
            if not FJQC.formatJSON:
              csvPF.WriteRowTitles(row)
            elif csvPF.CheckRowTitles(row):
              if not valueRangesOnly:
                csvPF.WriteRowNoFilter({'User': user, 'spreadsheetId': spreadsheetId,
                                        'JSON': json.dumps(result, ensure_ascii=False, sort_keys=False)})
              else:
                csvPF.WriteRowNoFilter({'JSON': json.dumps(result.get('valueRanges', []), ensure_ascii=False, sort_keys=False)})
          elif GC.Values[GC.CSV_OUTPUT_USERS_AUDIT]:
            csvPF.WriteRowNoFilter({'User': user})
      except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
              GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
              GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.SPREADSHEET, spreadsheetId], str(e), j, jcount)
      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
        userDriveServiceNotEnabledWarning(user, str(e), i, count)
        break
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('Spreadsheet')

# Token commands utilities
def commonClientIds(clientId):
  if clientId == 'gasmo':
    return '1095133494869.apps.googleusercontent.com'
  return clientId

# gam <UserTypeEntity> delete token|tokens|3lo|oauth clientid <ClientID>
def deleteTokens(users):
  cd = buildGAPIObject(API.DIRECTORY)
  checkArgumentPresent('clientid', required=True)
  clientId = commonClientIds(getString(Cmd.OB_CLIENT_ID))
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    try:
      callGAPI(cd.tokens(), 'get',
               throwReasons=[GAPI.USER_NOT_FOUND, GAPI.DOMAIN_NOT_FOUND,
                             GAPI.DOMAIN_CANNOT_USE_APIS, GAPI.FORBIDDEN,
                             GAPI.NOT_FOUND, GAPI.RESOURCE_NOT_FOUND],
               userKey=user, clientId=clientId, fields='')
      callGAPI(cd.tokens(), 'delete',
               throwReasons=[GAPI.USER_NOT_FOUND, GAPI.DOMAIN_NOT_FOUND,
                             GAPI.DOMAIN_CANNOT_USE_APIS, GAPI.FORBIDDEN,
                             GAPI.NOT_FOUND, GAPI.RESOURCE_NOT_FOUND],
               userKey=user, clientId=clientId)
      entityActionPerformed([Ent.USER, user, Ent.ACCESS_TOKEN, clientId], i, count)
    except (GAPI.notFound, GAPI.resourceNotFound) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.ACCESS_TOKEN, clientId], str(e), i, count)
    except (GAPI.userNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden):
      entityUnknownWarning(Ent.USER, user, i, count)

TOKENS_FIELDS_TITLES = ['clientId', 'displayText', 'anonymous', 'nativeApp', 'userKey', 'scopes']
TOKENS_AGGREGATE_FIELDS_TITLES = ['clientId', 'displayText', 'anonymous', 'nativeApp', 'users', 'scopes']
TOKENS_AGGREGATE_ORDERBY_CHOICE_MAP = {
  'clientid': 'clientId',
  'id': 'clientId',
  'displaytext': 'displayText',
  'appname': 'displayText',
  }
TOKENS_TITLE_MAP = {
  'clientId': 'Client ID',
  'displayText': 'App Name',
  'user': 'user',
  }

def _printShowTokens(entityType, users):
  def _printToken(token):
    row = {}
    for item in token:
      if item != 'scopes':
        row[item] = token.get(item, '')
      else:
        row[item] = delimiter.join(token.get('scopes', []))
    csvPF.WriteRow(row)

  def _showToken(token, keyTitle, keyField, j, jcount):
    printKeyValueListWithCount([keyTitle, token[keyField]], j, jcount)
    Ind.Increment()
    for item in sorted(token):
      if item not in {keyField, 'scopes'}:
        printKeyValueList([item, token.get(item, '')])
    item = 'scopes'
    printKeyValueList([item, None])
    Ind.Increment()
    for it in sorted(token.get(item, [])):
      printKeyValueList([it])
    Ind.Decrement()
    Ind.Decrement()

  cd = buildGAPIObject(API.DIRECTORY)
  csvPF = CSVPrintFile() if Act.csvFormat() else None
  clientId = None
  aggregateUsersBy = ''
  orderBy = 'clientId'
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  aggregateTokensById = {}
  tokenNameIdMap = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'clientid':
      clientId = commonClientIds(getString(Cmd.OB_CLIENT_ID))
    elif myarg == 'orderby':
      orderBy = getChoice(TOKENS_AGGREGATE_ORDERBY_CHOICE_MAP, mapChoice=True)
    elif myarg == 'aggregateusersby':
      aggregateUsersBy = getChoice(TOKENS_AGGREGATE_ORDERBY_CHOICE_MAP, mapChoice=True)
      if aggregateUsersBy == 'displayText':
        tokenNameIdMap = {}
    elif myarg == 'usertokencounts':
      aggregateUsersBy = 'user'
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    elif not entityType:
      Cmd.Backup()
      entityType, users = getEntityToModify(defaultEntityType=Cmd.ENTITY_USERS)
    else:
      unknownArgumentExit()
  if not entityType:
    users = getItemsToModify(Cmd.ENTITY_ALL_USERS_NS, None)
  if csvPF:
    if not aggregateUsersBy:
      csvPF.SetTitles(['user']+TOKENS_FIELDS_TITLES)
    elif aggregateUsersBy != 'user':
      csvPF.SetTitles(TOKENS_AGGREGATE_FIELDS_TITLES)
    else:
      csvPF.SetTitles(['user', 'tokenCount'])
  else:
    if not aggregateUsersBy:
      tokenTitle = TOKENS_TITLE_MAP[orderBy]
    else:
      tokenTitle = TOKENS_TITLE_MAP[aggregateUsersBy]
  fields = ','.join(TOKENS_FIELDS_TITLES)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    try:
      if csvPF or aggregateUsersBy:
        printGettingEntityItemForWhom(Ent.ACCESS_TOKEN, user, i, count)
      if clientId:
        results = [callGAPI(cd.tokens(), 'get',
                            throwReasons=[GAPI.USER_NOT_FOUND, GAPI.DOMAIN_NOT_FOUND,
                                          GAPI.DOMAIN_CANNOT_USE_APIS, GAPI.FORBIDDEN, GAPI.BAD_REQUEST,
                                          GAPI.NOT_FOUND, GAPI.RESOURCE_NOT_FOUND],
                            userKey=user, clientId=clientId, fields=fields)]
      else:
        results = callGAPIitems(cd.tokens(), 'list', 'items',
                                throwReasons=[GAPI.USER_NOT_FOUND, GAPI.DOMAIN_NOT_FOUND,
                                              GAPI.DOMAIN_CANNOT_USE_APIS, GAPI.FORBIDDEN, GAPI.BAD_REQUEST],
                                userKey=user, fields=f'items({fields})')
      jcount = len(results)
      if not aggregateUsersBy:
        if not csvPF:
          entityPerformActionNumItems([Ent.USER, user], jcount, Ent.ACCESS_TOKEN, i, count)
          Ind.Increment()
          j = 0
          for token in sorted(results, key=lambda k: k[orderBy]):
            j += 1
            _showToken(token, tokenTitle, orderBy, j, jcount)
          Ind.Decrement()
        else:
          if results:
            for token in sorted(results, key=lambda k: k[orderBy]):
              row = {'user': user, 'scopes': delimiter.join(token.get('scopes', []))}
              for item in token:
                if item != 'scopes':
                  row[item] = token.get(item, '')
              csvPF.WriteRow(row)
          elif GC.Values[GC.CSV_OUTPUT_USERS_AUDIT]:
            csvPF.WriteRowNoFilter({'user': user})
      elif aggregateUsersBy != 'user':
        if results:
          for token in results:
            tokcid = token['clientId']
            if tokcid not in aggregateTokensById:
              token.pop('userKey', None)
              token['users'] = 0
              aggregateTokensById[tokcid] = token
            aggregateTokensById[tokcid]['users'] += 1
            if tokenNameIdMap is not None:
              tokname = token['displayText']
              if tokname not in tokenNameIdMap:
                tokenNameIdMap[tokname] = set()
              tokenNameIdMap[tokname].add(tokcid)
      else: # aggregateUsersBy == 'user':
        aggregateTokensById[user] = jcount
    except (GAPI.notFound, GAPI.resourceNotFound) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.ACCESS_TOKEN, clientId], str(e), i, count)
    except (GAPI.userNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest):
      entityUnknownWarning(Ent.USER, user, i, count)
  if aggregateUsersBy == 'clientId':
    if not csvPF:
      jcount = len(aggregateTokensById)
      performActionNumItems(jcount, Ent.ACCESS_TOKEN)
      Ind.Increment()
      j = 0
      for _, token in sorted(iter(aggregateTokensById.items())):
        j += 1
        _showToken(token, tokenTitle, aggregateUsersBy, j, jcount)
      Ind.Decrement()
    else:
      for _, token in sorted(iter(aggregateTokensById.items())):
        _printToken(token)
  elif aggregateUsersBy == 'displayText':
    if not csvPF:
      jcount = len(aggregateTokensById)
      performActionNumItems(jcount, Ent.ACCESS_TOKEN)
      Ind.Increment()
      j = 0
      for _, tokenIds in sorted(iter(tokenNameIdMap.items())):
        for tokcid in sorted(tokenIds):
          j += 1
          _showToken(aggregateTokensById[tokcid], tokenTitle, aggregateUsersBy, j, jcount)
      Ind.Decrement()
    else:
      for _, tokenIds in sorted(iter(tokenNameIdMap.items())):
        for tokcid in sorted(tokenIds):
          _printToken(aggregateTokensById[tokcid])
  else: # aggregateUsersBy == 'user':
    if not csvPF:
      jcount = len(aggregateTokensById)
      j = 0
      for user, count in sorted(iter(aggregateTokensById.items())):
        j += 1
        printEntityKVList([Ent.USER, user], [Ent.Plural(Ent.ACCESS_TOKEN), count], j, jcount)
    else:
      for user, count in sorted(iter(aggregateTokensById.items())):
        csvPF.WriteRow({'user': user, 'tokenCount': count})
  if csvPF:
    csvPF.writeCSVfile('OAuth Tokens')

# gam <UserTypeEntity> print tokens|token [todrive <ToDriveAttribute>*] [clientid <ClientID>]
#	[usertokencounts|(aggregateusersby|orderby clientid|id|appname|displaytext)] [delimiter <Character>]
# gam <UserTypeEntity> show tokens|token|3lo|oauth [clientid <ClientID>]
#	[usertokencounts|(aggregateusersby|orderby clientid|id|appname|displaytext)] [delimiter <Character>]
def printShowTokens(users):
  _printShowTokens(Cmd.ENTITY_USERS, users)

# gam print tokens|token [todrive <ToDriveAttribute>*] [clientid <ClientID>]
#	[usertokencounts|(aggregateusersby|orderby clientid|id|appname|displaytext)] [delimiter <Character>]
#	[<UserTypeEntity>]
# gam show tokens|token [clientid <ClientID>]
#	[usertokencounts|(aggregateusersby|orderby clientid|id|appname|displaytext)] [delimiter <Character>]
#	[<UserTypeEntity>]
def doPrintShowTokens():
  _printShowTokens(None, None)

# gam <UserTypeEntity> deprovision|deprov [popimap] [signout] [turnoff2sv]
def deprovisionUser(users):
  cd = buildGAPIObject(API.DIRECTORY)
  disablePopImap = signout = turnoff2sv = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'popimap':
      disablePopImap = True
    elif myarg == 'signout':
      signout = True
    elif myarg == 'turnoff2sv':
      turnoff2sv = True
    else:
      unknownArgumentExit()
  if disablePopImap:
    imapBody = _imapDefaults(False)
    popBody = _popDefaults(False)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user = normalizeEmailAddressOrUID(user)
    userSuspended = checkUserSuspended(cd, user, Ent.USER, i, count)
    if userSuspended is None:
      continue
    try:
      printGettingEntityItemForWhom(Ent.APPLICATION_SPECIFIC_PASSWORD, user, i, count)
      asps = callGAPIitems(cd.asps(), 'list', 'items',
                           throwReasons=[GAPI.USER_NOT_FOUND],
                           userKey=user, fields='items(codeId)')
      codeIds = [asp['codeId'] for asp in asps]
      jcount = len(codeIds)
      entityPerformActionNumItems([Ent.USER, user], jcount, Ent.APPLICATION_SPECIFIC_PASSWORD, i, count)
      if jcount > 0:
        Ind.Increment()
        j = 0
        for codeId in codeIds:
          j += 1
          try:
            callGAPI(cd.asps(), 'delete',
                     throwReasons=[GAPI.USER_NOT_FOUND, GAPI.INVALID, GAPI.INVALID_PARAMETER, GAPI.FORBIDDEN],
                     userKey=user, codeId=codeId)
            entityActionPerformed([Ent.USER, user, Ent.APPLICATION_SPECIFIC_PASSWORD, codeId], j, jcount)
          except (GAPI.invalid, GAPI.invalidParameter, GAPI.forbidden) as e:
            entityActionFailedWarning([Ent.USER, user, Ent.APPLICATION_SPECIFIC_PASSWORD, codeId], str(e), j, jcount)
        Ind.Decrement()
#
      printGettingEntityItemForWhom(Ent.BACKUP_VERIFICATION_CODES, user, i, count)
      try:
        codes = callGAPIitems(cd.verificationCodes(), 'list', 'items',
                              throwReasons=[GAPI.USER_NOT_FOUND],
                              userKey=user, fields='items(verificationCode)')
        jcount = len(codes)
        entityPerformActionNumItems([Ent.USER, user], jcount, Ent.BACKUP_VERIFICATION_CODES, i, count)
        if jcount > 0:
          if not userSuspended:
            callGAPI(cd.verificationCodes(), 'invalidate',
                     throwReasons=[GAPI.USER_NOT_FOUND, GAPI.INVALID, GAPI.INVALID_INPUT],
                     userKey=user)
            entityActionPerformed([Ent.USER, user, Ent.BACKUP_VERIFICATION_CODES, None], i, count)
          else:
            entityActionNotPerformedWarning([Ent.USER, user, Ent.BACKUP_VERIFICATION_CODES, None],
                                            Msg.IS_SUSPENDED_NO_BACKUPCODES, i, count)
      except (GAPI.invalid, GAPI.invalidInput) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.BACKUP_VERIFICATION_CODES, None], str(e), i, count)
#
      printGettingEntityItemForWhom(Ent.ACCESS_TOKEN, user, i, count)
      tokens = callGAPIitems(cd.tokens(), 'list', 'items',
                             throwReasons=[GAPI.USER_NOT_FOUND],
                             userKey=user, fields='items(clientId)')
      jcount = len(tokens)
      entityPerformActionNumItems([Ent.USER, user], jcount, Ent.ACCESS_TOKEN, i, count)
      if jcount > 0:
        Ind.Increment()
        j = 0
        for token in tokens:
          j += 1
          clientId = token['clientId']
          try:
            callGAPI(cd.tokens(), 'delete',
                     throwReasons=[GAPI.USER_NOT_FOUND, GAPI.NOT_FOUND],
                     userKey=user, clientId=clientId)
            entityActionPerformed([Ent.USER, user, Ent.ACCESS_TOKEN, clientId], j, jcount)
          except GAPI.notFound as e:
            entityActionFailedWarning([Ent.USER, user, Ent.ACCESS_TOKEN, clientId], str(e), j, jcount)
        Ind.Decrement()
#
      if turnoff2sv:
        Act.Set(Act.TURNOFF2SV)
        try:
          callGAPI(cd.twoStepVerification(), 'turnOff',
                   throwReasons=[GAPI.USER_NOT_FOUND, GAPI.INVALID, GAPI.DOMAIN_NOT_FOUND,
                                 GAPI.DOMAIN_CANNOT_USE_APIS, GAPI.FORBIDDEN],
                   userKey=user)
          entityActionPerformed([Ent.USER, user], i, count)
        except GAPI.invalid as e:
          entityActionFailedWarning([Ent.USER, user], str(e), i, count)
#
      if signout:
        Act.Set(Act.SIGNOUT)
        callGAPI(cd.users(), 'signOut',
                 throwReasons=[GAPI.USER_NOT_FOUND, GAPI.INVALID, GAPI.DOMAIN_NOT_FOUND,
                               GAPI.DOMAIN_CANNOT_USE_APIS, GAPI.FORBIDDEN],
                 userKey=user)
        entityActionPerformed([Ent.USER, user], i, count)
#
      Act.Set(Act.DEPROVISION)
      if disablePopImap:
        _setImap(user, imapBody, i, count)
        _setPop(user, popBody, i, count)
#
      entityActionPerformed([Ent.USER, user], i, count)
    except GAPI.userNotFound:
      entityUnknownWarning(Ent.USER, user, i, count)
    except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)

# gam <UserTypeEntity> watch gmail [maxmessages <Integer>]
def watchGmail(users):
  maxMessages = 100
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'maxmessages':
      maxMessages = getInteger(minVal=1)
    else:
      unknownArgumentExit()
  project = f'projects/{_getCurrentProjectId()}'
  gamTopics = project+'/topics/gam-pubsub-gmail-'
  gamSubscriptions = project+'/subscriptions/gam-pubsub-gmail-'
  pubsub = buildGAPIObject(API.PUBSUB)
  topics = callGAPIpages(pubsub.projects().topics(), 'list', items='topics',
                         project=project)
  for atopic in topics:
    if atopic['name'].startswith(gamTopics):
      topic = atopic['name']
      break
  else:
    topic = gamTopics+str(uuid.uuid4())
    callGAPI(pubsub.projects().topics(), 'create',
             name=topic)
    body = {'policy': {'bindings': [{'members': ['serviceAccount:gmail-api-push@system.gserviceaccount.com'], 'role': 'roles/pubsub.editor'}]}}
    callGAPI(pubsub.projects().topics(), 'setIamPolicy',
             resource=topic, body=body)
  subscriptions = callGAPIpages(pubsub.projects().topics().subscriptions(), 'list', items='subscriptions',
                                topic=topic)
  for asubscription in subscriptions:
    if asubscription.startswith(gamSubscriptions):
      subscription = asubscription
      break
  else:
    subscription = gamSubscriptions+str(uuid.uuid4())
    callGAPI(pubsub.projects().subscriptions(), 'create',
             name=subscription, body={'topic': topic})
  gmails = {}
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    gmails[user] = {'g': gmail}
    callGAPI(gmails[user]['g'].users(), 'watch',
             userId='me', body={'topicName': topic})
    gmails[user]['seen_historyId'] = callGAPI(gmails[user]['g'].users(), 'getProfile',
                                              userId='me', fields='historyId')['historyId']
  entityPerformActionNumItems([Ent.EVENT, 'gmail'], count, Ent.USER)
  while True:
    results = callGAPI(pubsub.projects().subscriptions(), 'pull',
                       subscription=subscription, body={'maxMessages': maxMessages})
    if 'receivedMessages' in results:
      ackIds = []
      update_history = []
      for message in results['receivedMessages']:
        if 'data' in message['message']:
          try:
            decoded_message = json.loads(base64.b64decode(message['message']['data']))
            if 'historyId' in decoded_message:
              update_history.append(decoded_message['emailAddress'])
          except (IndexError, KeyError, SyntaxError, TypeError, ValueError):
            pass
        if 'ackId' in message:
          ackIds.append(message['ackId'])
      if ackIds:
        callGAPI(pubsub.projects().subscriptions(), 'acknowledge',
                 subscription=subscription, body={'ackIds': ackIds})
      if update_history:
        for a_user in update_history:
          results = callGAPI(gmails[a_user]['g'].users().history(), 'list',
                             userId='me', startHistoryId=gmails[a_user]['seen_historyId'])
          if 'history' in results:
            for history in results['history']:
              if list(history) == ['messages', 'id']:
                continue
              if 'labelsAdded' in history:
                Act.Set(Act.ADD)
                for labelling in history['labelsAdded']:
                  entityActionPerformed([Ent.USER, a_user, Ent.MESSAGE, labelling['message']['id'],
                                         Ent.LABEL, ', '.join(labelling['labelIds'])])
              if 'labelsRemoved' in history:
                Act.Set(Act.REMOVE)
                for labelling in history['labelsRemoved']:
                  entityActionPerformed([Ent.USER, a_user, Ent.MESSAGE, labelling['message']['id'],
                                         Ent.LABEL, ', '.join(labelling['labelIds'])])
              if 'messagesAdded' in history:
                Act.Set(Act.CREATE)
                for adding in history['messagesAdded']:
                  entityActionPerformed([Ent.USER, a_user, Ent.MESSAGE, adding['message']['id'],
                                         Ent.LABEL, ', '.join(adding['message']['labelIds'])])
              if 'messagesDeleted' in history:
                Act.Set(Act.DELETE)
                for deleting in history['messagesDeleted']:
                  entityActionPerformed([Ent.USER, a_user, Ent.MESSAGE, deleting['message']['id']])
          gmails[a_user]['seen_historyId'] = results['historyId']

# gam <UserTypeEntity> print gmailprofile [todrive <ToDriveAttribute>*]
# gam <UserTypeEntity> show gmailprofile
def printShowGmailProfile(users):
  csvPF = CSVPrintFile(['emailAddress'], 'sortall') if Act.csvFormat() else None
  getTodriveOnly(csvPF)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    if csvPF:
      printGettingEntityItemForWhom(Ent.GMAIL_PROFILE, user, i, count)
    try:
      results = callGAPI(gmail.users(), 'getProfile',
                         throwReasons=GAPI.GMAIL_THROW_REASONS,
                         userId='me')
      if not csvPF:
        kvList = []
        for item in ['historyId', 'messagesTotal', 'threadsTotal']:
          kvList += [item, results[item]]
        printEntityKVList([Ent.USER, user], kvList, i, count)
      else:
        csvPF.WriteRowTitles(results)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
  if csvPF:
    csvPF.writeCSVfile('Gmail Profiles')

def _getUserGmailLabels(gmail, user, i, count, fields):
  try:
    labels = callGAPI(gmail.users().labels(), 'list',
                      throwReasons=GAPI.GMAIL_THROW_REASONS,
                      userId='me', fields=fields)
    if not labels:
      labels = {'labels': []}
    return labels
  except GAPI.serviceNotAvailable:
    userGmailServiceNotEnabledWarning(user, i, count)
    return None

def _getLabelId(labels, labelName):
  for label in labels['labels']:
    if (labelName.upper() == label['id']) or (labelName.lower() in {label['id'].lower(), label['name'].lower()}):
      return label['id']
  return None

def _getLabelName(labels, labelId):
  for label in labels['labels']:
    if label['id'] == labelId:
      return label['name']
  return labelId

def _getLabelSet(labels):
  labelSet = {}
  for ulabel in labels['labels']:
    if ulabel['type'] != LABEL_TYPE_SYSTEM:
      labelSet[ulabel['name']] = ulabel['id']
  return labelSet

LABEL_LABEL_LIST_VISIBILITY_CHOICE_MAP = {
  'hide': 'labelHide',
  'show': 'labelShow',
  'showifunread': 'labelShowIfUnread',
  }
LABEL_MESSAGE_LIST_VISIBILITY_CHOICES = ['hide', 'show']
LABEL_TYPE_SYSTEM = 'system'
LABEL_TYPE_USER = 'user'

def getLabelAttributes(myarg, body):
  if myarg == 'labellistvisibility':
    body['labelListVisibility'] = getChoice(LABEL_LABEL_LIST_VISIBILITY_CHOICE_MAP, mapChoice=True)
  elif myarg == 'messagelistvisibility':
    body['messageListVisibility'] = getChoice(LABEL_MESSAGE_LIST_VISIBILITY_CHOICES)
  elif myarg in {'backgroundcolor', 'backgroundcolour'}:
    body.setdefault('color', {})
    body['color']['backgroundColor'] = getLabelColor(LABEL_BACKGROUND_COLORS)
  elif myarg in {'textcolor', 'textcolour'}:
    body.setdefault('color', {})
    body['color']['textColor'] = getLabelColor(LABEL_TEXT_COLORS)
  else:
    unknownArgumentExit()

def checkLabelColor(body):
  if 'color' not in body:
    return
  if 'backgroundColor' in body['color']:
    if 'textColor' in body['color']:
      return
    missingArgumentExit('textcolor <LabelColorHex>')
  missingArgumentExit('backgroundcolor <LabelColorHex>')

def buildLabelPath(gmail, user, i, count, body, label, labelSet, l=0, lcount=0):
  label = label.strip('/')
  if label in labelSet:
    entityActionFailedWarning([Ent.USER, user, Ent.LABEL, label], Msg.DUPLICATE, l, lcount)
    return
  labelParts = label.split('/')
  invalid = False
  for j, labelPart in enumerate(labelParts):
    labelParts[j] = labelPart.strip()
    if not labelParts[j]:
      entityActionFailedWarning([Ent.USER, user, Ent.LABEL, label], Msg.INVALID, l, lcount)
      invalid = True
      break
  if invalid:
    return
  decrement = False
  duplicate = True
  labelPath = ''
  j = 0
  for k, labelPart in enumerate(labelParts):
    if labelPath != '':
      labelPath += '/'
    labelPath += labelPart
    if labelPath not in labelSet:
      if duplicate:
        jcount = len(labelParts)-k
        entityPerformActionNumItems([Ent.USER, user], jcount, Ent.LABEL, i, count)
        Ind.Increment()
        decrement = True
        duplicate = False
      j += 1
      body['name'] = labelPath
      try:
        newLabel = callGAPI(gmail.users().labels(), 'create',
                            throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.DUPLICATE],
                            userId='me', body=body, fields='id,name')
        labelSet[newLabel['name']] = newLabel['id']
        entityActionPerformed([Ent.USER, user, Ent.LABEL, labelPath], j, jcount)
      except GAPI.duplicate:
        entityActionFailedWarning([Ent.USER, user, Ent.LABEL, labelPath], Msg.DUPLICATE, j, jcount)
        break
      except GAPI.serviceNotAvailable:
        userGmailServiceNotEnabledWarning(user, i, count)
        break
  if duplicate:
    entityActionFailedWarning([Ent.USER, user, Ent.LABEL, labelPath], Msg.DUPLICATE, l, lcount)
  if decrement:
    Ind.Decrement()

def createLabels(users, labelEntity):
  body = {}
  buildPath = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'buildpath':
      buildPath = getBoolean()
    else:
      getLabelAttributes(myarg, body)
  checkLabelColor(body)
  if not isinstance(labelEntity, dict):
    userLabelList = None
    labelList = labelEntity
  else:
    userLabelList = labelEntity
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    if userLabelList:
      labelList = userLabelList[origUser]
    lcount = len(labelList)
    if buildPath:
      labels = _getUserGmailLabels(gmail, user, i, count, 'labels(id,name,type)')
      if not labels:
        continue
      labelSet = _getLabelSet(labels)
    entityPerformActionNumItems([Ent.USER, user], lcount, Ent.LABEL, i, count)
    Ind.Increment()
    l = 0
    for label in labelList:
      l += 1
      body['name'] = label
      if not buildPath:
        try:
          callGAPI(gmail.users().labels(), 'create',
                   throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.DUPLICATE, GAPI.INVALID, GAPI.PERMISSION_DENIED],
                   userId='me', body=body, fields='')
          entityActionPerformed([Ent.USER, user, Ent.LABEL, label], l, lcount)
        except GAPI.duplicate:
          entityActionFailedWarning([Ent.USER, user, Ent.LABEL, label], Msg.DUPLICATE, l, lcount)
        except (GAPI.invalid, GAPI.permissionDenied) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.LABEL, label], str(e), l, lcount)
        except GAPI.serviceNotAvailable:
          userGmailServiceNotEnabledWarning(user, i, count)
      else:
        buildLabelPath(gmail, user, i, count, body, label, labelSet, l, lcount)
    Ind.Decrement()

# gam <UserTypeEntity> [create] label|labels <String>
#	[messagelistvisibility hide|show] [labellistvisibility hide|show|showifunread]
#	[backgroundcolor <LabelColorHex>] [textcolor <LabelColorHex>]
#	[buildpath [<Boolean>]]
def createLabel(users):
  createLabels(users, getStringReturnInList(Cmd.OB_LABEL_NAME))

# gam <UserTypeEntity> create labellist <LabelNameEntity>
#	[messagelistvisibility hide|show] [labellistvisibility hide|show|showifunread]
#	[backgroundcolor <LabelColorHex>] [textcolor <LabelColorHex>]
#	[buildpath [<Boolean>]]
def createLabelList(users):
  createLabels(users, getEntityList(Cmd.OB_LABEL_NAME_LIST, shlexSplit=True))

# gam <UserTypeEntity> update labelsettings <LabelName> [name <String>]
#	[messagelistvisibility hide|show] [labellistvisibility hide|show|showifunread]
#	[backgroundcolor <LabelColorHex>] [textcolor <LabelColorHex>]
def updateLabelSettings(users):
  label_name = getString(Cmd.OB_LABEL_NAME)
  label_name_lower = label_name.lower()
  body = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'name':
      body['name'] = getString(Cmd.OB_STRING)
    else:
      getLabelAttributes(myarg, body)
  checkLabelColor(body)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    labels = _getUserGmailLabels(gmail, user, i, count, 'labels(id,name)')
    if not labels:
      continue
    try:
      for label in labels['labels']:
        if label['name'].lower() == label_name_lower:
          result = callGAPI(gmail.users().labels(), 'patch',
                            throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT],
                            userId='me', id=label['id'], body=body, fields='name')
          entityActionPerformed([Ent.USER, user, Ent.LABEL, result['name']], i, count)
          break
      else:
        entityActionFailedWarning([Ent.USER, user, Ent.LABEL, label_name], Msg.DOES_NOT_EXIST, i, count)
    except (GAPI.notFound, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.LABEL, label_name], str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)

# gam <UserTypeEntity> update labelid <LabelID> [name <String>]
#	[messagelistvisibility hide|show] [labellistvisibility hide|show|showifunread]
#	[backgroundcolor <LabelColorHex>] [textcolor <LabelColorHex>]
def updateLabelSettingsById(users):
  labelId = getString(Cmd.OB_LABEL_ID)
  body = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'name':
      body['name'] = getString(Cmd.OB_STRING)
    else:
      getLabelAttributes(myarg, body)
  checkLabelColor(body)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    try:
      result = callGAPI(gmail.users().labels(), 'patch',
                        throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT],
                        userId='me', id=labelId, body=body, fields='name')
      entityActionPerformed([Ent.USER, user, Ent.LABEL_ID, labelId, Ent.LABEL, result['name']], i, count)
    except (GAPI.notFound, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.LABEL_ID, labelId], str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
#
def cleanLabelQuery(labelQuery):
  for ch in '/ (){}':
    labelQuery = labelQuery.replace(ch, '-')
  return labelQuery.lower()

# gam <UserTypeEntity> update label|labels
#	[search <REMatchPattern>] [replace <RESubstitution>] [merge [keepoldlabel]]
#	search defaults to '^Inbox/(.*)$' which will find all labels in the Inbox
#	replace defaults to '%s'
def updateLabels(users):
  search = '^Inbox/(.*)$'
  pattern = re.compile(search, re.IGNORECASE)
  replace = '%s'
  keepOldLabel = merge = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'search':
      search = getString(Cmd.OB_RE_PATTERN)
      pattern = validateREPattern(search, re.IGNORECASE)
    elif myarg == 'replace':
      replaceLocation = Cmd.Location()
      replace = getString(Cmd.OB_RE_SUBSTITUTION)
    elif myarg == 'merge':
      merge = True
    elif myarg == 'keepoldlabel':
      keepOldLabel = True
    else:
      unknownArgumentExit()
# Validate that number of substitions in replace matches the number of groups in pattern
  useRegexSub = replace.find('%s') == -1
  if useRegexSub:
    Cmd.SetLocation(replaceLocation)
    validateREPatternSubstitution(pattern, replace)
  else:
    if pattern.groups != replace.count('%s'):
      Cmd.SetLocation(replaceLocation)
      usageErrorExit(Msg.MISMATCH_SEARCH_REPLACE_SUBFIELDS.format(pattern.groups, search, replace.count('%s'), replace))
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    labels = _getUserGmailLabels(gmail, user, i, count, 'labels(id,name,type)')
    if not labels:
      continue
    try:
      labelMatches = 0
      for label in sorted(labels['labels'], key=lambda k: k['name'], reverse=True):
        if label['type'] == LABEL_TYPE_SYSTEM:
          continue
        match_result = pattern.match(label['name'])
        if match_result is not None:
          labelMatches += 1
          if useRegexSub:
            newLabelName = pattern.sub(replace, label['name'])
          else:
            newLabelName = replace % match_result.groups()
          newLabelNameLower = newLabelName.lower()
          try:
            Act.Set(Act.RENAME)
            callGAPI(gmail.users().labels(), 'patch',
                     throwReasons=[GAPI.ABORTED, GAPI.DUPLICATE],
                     userId='me', id=label['id'], body={'name': newLabelName}, fields='')
            entityModifierNewValueActionPerformed([Ent.USER, user, Ent.LABEL, label['name']], Act.MODIFIER_TO, newLabelName, i, count)
          except (GAPI.aborted, GAPI.duplicate):
            if merge:
              Act.Set(Act.MERGE)
              entityPerformActionModifierNewValue([Ent.USER, user, Ent.LABEL, label['name']], Act.MODIFIER_WITH, newLabelName, i, count)
              messagesToRelabel = callGAPIpages(gmail.users().messages(), 'list', 'messages',
                                                userId='me', q=f'label:{cleanLabelQuery(label["name"])}')
              Act.Set(Act.RELABEL)
              jcount = len(messagesToRelabel)
              Ind.Increment()
              if jcount > 0:
                for new_label in labels['labels']:
                  if new_label['name'].lower() == newLabelNameLower:
                    body = {'addLabelIds': [new_label['id']]}
                    break
                j = 0
                for message in messagesToRelabel:
                  j += 1
                  callGAPI(gmail.users().messages(), 'modify',
                           userId='me', id=message['id'], body=body, fields='')
                  entityActionPerformed([Ent.USER, user, Ent.MESSAGE, message['id']], j, jcount)
              else:
                printEntityKVList([Ent.USER, user],
                                  [Msg.NO_MESSAGES_WITH_LABEL, label['name']],
                                  i, count)
              Ind.Decrement()
              if not keepOldLabel:
                callGAPI(gmail.users().labels(), 'delete',
                         userId='me', id=label['id'])
                Act.Set(Act.DELETE)
                entityActionPerformed([Ent.USER, user, Ent.LABEL, label['name']], i, count)
            else:
              entityActionNotPerformedWarning([Ent.USER, user, Ent.LABEL, newLabelName], Msg.ALREADY_EXISTS_USE_MERGE_ARGUMENT, i, count)
      if labels and (labelMatches == 0):
        printEntityKVList([Ent.USER, user],
                          [Msg.NO_LABELS_MATCH, search],
                          i, count)
    except (GAPI.serviceNotAvailable, GAPI.badRequest):
      userGmailServiceNotEnabledWarning(user, i, count)

def _validateLabelList(user, i, count, labels, labelList, userOnly):
  validLabels = []
  for label in labelList:
    label_name_lower = label.lower()
    if label_name_lower[:6] == 'regex:':
      labelPattern = validateREPattern(label[6:])
    else:
      labelPattern = None
    if label.upper() == '--ALL_LABELS--':
      count = len(labels['labels'])
      for delLabel in sorted(labels['labels'], key=lambda k: k['name'], reverse=True):
        if (not userOnly or delLabel['type'] != LABEL_TYPE_SYSTEM):
          validLabels.append(delLabel)
    elif labelPattern:
      for delLabel in sorted(labels['labels'], key=lambda k: k['name'], reverse=True):
        if (not userOnly or delLabel['type'] != LABEL_TYPE_SYSTEM) and labelPattern.match(delLabel['name']):
          validLabels.append(delLabel)
    else:
      for delLabel in sorted(labels['labels'], key=lambda k: k['name'], reverse=True):
        if (not userOnly or delLabel['type'] != LABEL_TYPE_SYSTEM) and label_name_lower == delLabel['name'].lower():
          validLabels.append(delLabel)
          break
      else:
        entityActionFailedWarning([Ent.USER, user, Ent.LABEL, label], Msg.DOES_NOT_EXIST, i, count)
  return (validLabels, len(validLabels))

def deleteLabels(users, labelEntity):
  def _handleProcessGmailError(exception, ri):
    http_status, reason, message = checkGAPIError(exception)
    entityActionFailedWarning([Ent.USER, ri[RI_ENTITY], Ent.LABEL, labelIdToNameMap[ri[RI_ITEM]]], formatHTTPError(http_status, reason, message), int(ri[RI_J]), int(ri[RI_JCOUNT]))

  def _callbackDeleteLabel(request_id, _, exception):
    ri = request_id.splitlines()
    if exception is None:
      entityActionPerformed([Ent.USER, ri[RI_ENTITY], Ent.LABEL, labelIdToNameMap[ri[RI_ITEM]]], int(ri[RI_J]), int(ri[RI_JCOUNT]))
    else:
      _handleProcessGmailError(exception, ri)

  if not isinstance(labelEntity, dict):
    userLabelList = None
    labelList = labelEntity
  else:
    userLabelList = labelEntity
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    if userLabelList:
      labelList = userLabelList[origUser]
    try:
      printGettingAllEntityItemsForWhom(Ent.LABEL, user, i, count)
      labels = _getUserGmailLabels(gmail, user, i, count, 'labels(id,name,type)')
      if not labels:
        continue
      delLabels, jcount = _validateLabelList(user, i, count, labels, labelList, True)
      labelIdToNameMap = {}
      entityPerformActionNumItems([Ent.USER, user], jcount, Ent.LABEL, i, count)
      if jcount == 0:
        continue
      Ind.Increment()
      svcargs = dict([('userId', 'me'), ('id', None), ('fields', '')]+GM.Globals[GM.EXTRA_ARGS_LIST])
      method = getattr(gmail.users().labels(), 'delete')
      dbatch = gmail.new_batch_http_request(callback=_callbackDeleteLabel)
      bcount = 0
      j = 0
      for del_me in delLabels:
        j += 1
        svcparms = svcargs.copy()
        svcparms['id'] = del_me['id']
        labelIdToNameMap[del_me['id']] = del_me['name']
        dbatch.add(method(**svcparms), request_id=batchRequestID(user, i, count, j, jcount, del_me['id']))
        bcount += 1
        if bcount == 10:
          executeBatch(dbatch)
          dbatch = gmail.new_batch_http_request(callback=_callbackDeleteLabel)
          bcount = 0
      if bcount > 0:
        dbatch.execute()
      Ind.Decrement()
    except (GAPI.serviceNotAvailable, GAPI.badRequest):
      userGmailServiceNotEnabledWarning(user, i, count)

# gam <UserTypeEntity> delete label|labels <LabelName>|regex:<REMatchPattern>
def deleteLabel(users):
  deleteLabels(users, getStringReturnInList(Cmd.OB_LABEL_NAME))

# gam <UserTypeEntity> delete labellist <LabelNameEntity>
def deleteLabelList(users):
  deleteLabels(users, getEntityList(Cmd.OB_LABEL_NAME_LIST, shlexSplit=True))

def deleteLabelIds(users, labelEntity):
  if not isinstance(labelEntity, dict):
    userLabelList = None
    labelList = labelEntity
  else:
    userLabelList = labelEntity
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    if userLabelList:
      labelList = userLabelList[origUser]
    lcount = len(labelList)
    entityPerformActionNumItems([Ent.USER, user], lcount, Ent.LABEL, i, count)
    Ind.Increment()
    l = 0
    for labelId in labelList:
      l += 1
      try:
        callGAPI(gmail.users().labels(), 'delete',
                 throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.INVALID,
                                                        GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                 userId='me', id=labelId)
        entityActionPerformed([Ent.USER, user, Ent.LABEL_ID, labelId], l, lcount)
      except (GAPI.notFound, GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.LABEL_ID, labelId], str(e), l, lcount)
      except GAPI.serviceNotAvailable:
        userGmailServiceNotEnabledWarning(user, i, count)
        break
    Ind.Decrement()

# gam <UserTypeEntity> delete labelid <LabelID>
def deleteLabelId(users):
  deleteLabelIds(users, getStringReturnInList(Cmd.OB_LABEL_ID))

# gam <UserTypeEntity> delete labelidlist <LabelIDEntity>
def deleteLabelIdList(users):
  deleteLabelIds(users, getEntityList(Cmd.OB_LABEL_ID_LIST))

PRINT_LABELS_TITLES = ['User', 'type', 'name', 'id']
SHOW_LABELS_DISPLAY_CHOICES = ['allfields', 'basename', 'fullname']
LABEL_DISPLAY_FIELDS_LIST = ['type', 'id', 'labelListVisibility', 'messageListVisibility', 'color']
LABEL_COUNTS_FIELDS_LIST = ['messagesTotal', 'messagesUnread', 'threadsTotal', 'threadsUnread']
LABEL_COUNTS_FIELDS = ','.join(LABEL_COUNTS_FIELDS_LIST)

# gam <UserTypeEntity> print labels|label [todrive <ToDriveAttribute>*]
#	[onlyuser|useronly [<Boolean>]] [showcounts [<Boolean>]]
#	[labellist <LabelNameEntity>]
# gam <UserTypeEntity> show labels|label
#	[onlyuser|useronly [<Boolean>]] [showcounts [<Boolean>]]
#	[nested [<Boolean>]] [display allfields|basename|fullname]
#	[labellist <LabelNameEntity>]
def printShowLabels(users):
  def _buildLabelTree(labels):
    def _checkChildLabel(label):
      labelItemList = label.split('/')
      i = len(labelItemList)-1
      while i > 0:
        parent = '/'.join(labelItemList[:i])
        base = '/'.join(labelItemList[i:])
        if parent in labelTree:
          if label in labelTree:
            labelTree[label]['info']['base'] = base
            labelTree[parent]['children'].append(labelTree.pop(label))
          _checkChildLabel(parent)
          return
        i -= 1

    labelTree = {}
    for label in labels['labels']:
      if not onlyUser or (label['type'] != LABEL_TYPE_SYSTEM):
        label['base'] = label['name']
        labelTree[label['name']] = {'info': label, 'children': []}
    labelList = sorted(list(labelTree), reverse=True)
    for label in labelList:
      _checkChildLabel(label)
    return labelTree

  def _printLabel(label):
    if not displayAllFields:
      if not showCounts:
        printKeyValueList([label[nameField]])
      else:
        counts = callGAPI(gmail.users().labels(), 'get',
                          throwReasons=GAPI.GMAIL_THROW_REASONS,
                          userId='me', id=label['id'],
                          fields=LABEL_COUNTS_FIELDS)
        kvlist = [label[nameField], 'Counts']
        for a_key in LABEL_COUNTS_FIELDS_LIST:
          kvlist.extend([a_key, counts[a_key]])
        printKeyValueList(kvlist)
    else:
      printKeyValueList([label[nameField]])
      Ind.Increment()
      for a_key in LABEL_DISPLAY_FIELDS_LIST:
        if a_key in label:
          if a_key != 'color':
            printKeyValueList([a_key, label[a_key]])
          else:
            printKeyValueList(['backgroundColor', label[a_key]['backgroundColor']])
            printKeyValueList(['textColor', label[a_key]['textColor']])
      if showCounts:
        counts = callGAPI(gmail.users().labels(), 'get',
                          throwReasons=GAPI.GMAIL_THROW_REASONS,
                          userId='me', id=label['id'],
                          fields=LABEL_COUNTS_FIELDS)
        for a_key in LABEL_COUNTS_FIELDS_LIST:
          printKeyValueList([a_key, counts[a_key]])
      Ind.Decrement()

  def _printFlatLabel(label):
    _printLabel(label['info'])
    if label['children']:
      for child in sorted(label['children'], key=lambda k: k['info']['name']):
        _printFlatLabel(child)

  def _printNestedLabel(label):
    _printLabel(label['info'])
    if label['children']:
      Ind.Increment()
      if displayAllFields:
        printKeyValueList(['nested', len(label['children'])])
        Ind.Increment()
        for child in sorted(label['children'], key=lambda k: k['info']['name']):
          _printNestedLabel(child)
        Ind.Decrement()
      else:
        for child in sorted(label['children'], key=lambda k: k['info']['name']):
          _printNestedLabel(child)
      Ind.Decrement()

  csvPF = CSVPrintFile(PRINT_LABELS_TITLES, 'sortall') if Act.csvFormat() else None
  onlyUser = showCounts = showNested = False
  displayAllFields = True
  nameField = 'name'
  labelEntity = []
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'onlyuser', 'useronly'}:
      onlyUser = getBoolean()
    elif myarg == 'showcounts':
      showCounts = getBoolean()
    elif not csvPF and myarg == 'nested':
      showNested = getBoolean()
    elif not csvPF and myarg == 'display':
      fields = getChoice(SHOW_LABELS_DISPLAY_CHOICES)
      nameField = 'name' if fields != 'basename' else 'base'
      displayAllFields = fields == 'allfields'
    elif myarg == 'labellist':
      labelEntity = getEntityList(Cmd.OB_LABEL_NAME_LIST, shlexSplit=True)
    else:
      unknownArgumentExit()
  if not isinstance(labelEntity, dict):
    userLabelList = None
    labelList = labelEntity
  else:
    userLabelList = labelEntity
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    origUser = user
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    if userLabelList:
      labelList = userLabelList[origUser]
    if csvPF:
      printGettingEntityItemForWhom(Ent.LABEL, user, i, count)
    labels = _getUserGmailLabels(gmail, user, i, count, 'labels')
    if not labels:
      continue
    if not labelList:
      jcount = len(labels['labels'])
      if (jcount > 0) and onlyUser:
        for label in labels['labels']:
          if label['type'] == LABEL_TYPE_SYSTEM:
            jcount -= 1
    else:
      labels['labels'], jcount = _validateLabelList(user, i, count, labels, labelList, onlyUser)
    try:
      if not csvPF:
        entityPerformActionNumItems([Ent.USER, user], jcount, Ent.LABEL, i, count)
      if jcount == 0:
        setSysExitRC(NO_ENTITIES_FOUND_RC)
        continue
      if not csvPF:
        labelTree = _buildLabelTree(labels)
        Ind.Increment()
        if not showNested:
          for label, _ in sorted(iter(labelTree.items()), key=lambda k: (k[1]['info']['type'], k[1]['info']['name'])):
            _printFlatLabel(labelTree[label])
        else:
          for label, _ in sorted(iter(labelTree.items()), key=lambda k: (k[1]['info']['type'], k[1]['info']['name'])):
            _printNestedLabel(labelTree[label])
        Ind.Decrement()
      else:
        for label in sorted(labels['labels'], key=lambda k: (k['type'], k['name'])):
          if not onlyUser or label['type'] != LABEL_TYPE_SYSTEM:
            if showCounts:
              counts = callGAPI(gmail.users().labels(), 'get',
                                throwReasons=GAPI.GMAIL_THROW_REASONS,
                                userId='me', id=label['id'],
                                fields=LABEL_COUNTS_FIELDS)
              for a_key in LABEL_COUNTS_FIELDS_LIST:
                label[a_key] = counts[a_key]
            csvPF.WriteRowTitles(flattenJSON(label, flattened={'User': user}))
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
  if csvPF:
    csvPF.writeCSVfile('Labels')

GMAIL_SYSTEM_LABELS = {
  'CHAT': 'CHAT',
  'DRAFT': 'DRAFT',
  'IMPORTANT': 'IMPORTANT',
  'INBOX': 'INBOX',
  'SENT': 'SENT',
  'SPAM': 'SPAM',
  'STARRED': 'STARRED',
  'TRASH': 'TRASH',
  'UNREAD': 'UNREAD',
  }
GMAIL_CATEGORY_LABELS = {
  'CATEGORY_PERSONAL': 'CATEGORY_PERSONAL',
  'CATEGORY_SOCIAL': 'CATEGORY_SOCIAL',
  'CATEGORY_PROMOTIONS': 'CATEGORY_PROMOTIONS',
  'CATEGORY_UPDATES': 'CATEGORY_UPDATES',
  'CATEGORY_FORUMS': 'CATEGORY_FORUMS',
  }

def _initLabelNameMap(userGmailLabels):
  labelNameMap = {}
  labelNameMap.update(GMAIL_SYSTEM_LABELS)
  labelNameMap.update(GMAIL_CATEGORY_LABELS)
  for label in userGmailLabels['labels']:
    if label['type'] == 'system':
      labelNameMap[label['id']] = label['id']
    else:
      labelNameMap[label['name']] = labelNameMap[label['name'].upper()] = label['id']
  return labelNameMap

def _convertLabelNamesToIds(gmail, user, i, count, bodyLabels, labelNameMap, addLabel):
  labelIds = []
  for label in bodyLabels:
    if label in labelNameMap:
      labelIds.append(labelNameMap[label])
      continue
    if label.upper() in labelNameMap:
      labelIds.append(labelNameMap[label.upper()])
      continue
    if not addLabel:
      entityListDoesNotExistWarning([Ent.USER, user, Ent.LABEL, label], i, count)
      continue
    try:
      results = callGAPI(gmail.users().labels(), 'create',
                         throwReasons=[GAPI.INVALID],
                         userId='me', body={'labelListVisibility': 'labelShow', 'messageListVisibility': 'show', 'name': label}, fields='id')
    except GAPI.invalid as e:
      action = Act.Get()
      Act.Set(Act.CREATE)
      entityActionFailedWarning([Ent.USER, user, Ent.LABEL, label], str(e), i, count)
      Act.Set(action)
      continue
    labelNameMap[label] = labelNameMap[label.upper()] = results['id']
    labelIds.append(results['id'])
    if label.find('/') != -1:
      # make sure to create parent labels for proper nesting
      parent_label = label[:label.rfind('/')]
      while True:
        if (not parent_label in labelNameMap) and (not parent_label.upper() in labelNameMap):
          result = callGAPI(gmail.users().labels(), 'create',
                            userId='me', body={'name': parent_label}, fields='id')
          labelNameMap[parent_label] = labelNameMap[parent_label.upper()] = result['id']
        if parent_label.find('/') == -1:
          break
        parent_label = parent_label[:parent_label.rfind('/')]
  return labelIds

MESSAGES_MAX_TO_KEYWORDS = {
  Act.ARCHIVE: 'maxtoarchive',
  Act.DELETE: 'maxtodelete',
  Act.EXPORT: 'maxtoexport',
  Act.FORWARD: 'maxtoforward',
  Act.MODIFY: 'maxtomodify',
  Act.PRINT: 'maxtoprint',
  Act.SHOW: 'maxtoshow',
  Act.SPAM: 'maxtospam',
  Act.TRASH: 'maxtotrash',
  Act.UNTRASH: 'maxtountrash',
  }

def _initMessageThreadParameters(entityType, doIt, maxToProcess):
  listType = 'messages' if entityType == Ent.MESSAGE else 'threads'
  return {'currLabelOp': 'and', 'prevLabelOp': 'and', 'labelGroupOpen':  False,
          'query': '', 'queryTimes': {},
          'entityType': entityType, 'messageEntity': None, 'doIt': doIt, 'quick': True,
          'labelMatchPattern': None, 'senderMatchPattern': None,
          'maxToProcess': maxToProcess, 'maxItems': 0, 'maxMessagesPerThread': 0,
          'maxToKeywords': [MESSAGES_MAX_TO_KEYWORDS[Act.Get()], 'maxtoprocess'],
          'listType': listType, 'fields': f'nextPageToken,{listType}(id)'}

LABEL_QUERY_REPLACEMENT_CHARACTERS = ' &()"|{}/'

def _getMessageSelectParameters(myarg, parameters):
  if myarg == 'query':
    parameters['query'] += f' ({getString(Cmd.OB_QUERY)})'
  elif myarg.startswith('querytime'):
    parameters['queryTimes'][myarg] = getDateOrDeltaFromNow().replace('-', '/')
  elif myarg == 'matchlabel':
    labelTemp = getString(Cmd.OB_LABEL_NAME).lower()
    labelName = ''
    for c in labelTemp:
      labelName += c if c not in LABEL_QUERY_REPLACEMENT_CHARACTERS else '-'
    if not parameters['labelGroupOpen']:
      parameters['query'] += '('
      parameters['labelGroupOpen'] = True
    parameters['query'] += f' label:{labelName}'
  elif myarg in {'or', 'and'}:
    parameters['prevLabelOp'] = parameters['currLabelOp']
    parameters['currLabelOp'] = myarg
    if parameters['labelGroupOpen'] and parameters['currLabelOp'] != parameters['prevLabelOp']:
      parameters['query'] += ')'
      parameters['labelGroupOpen'] = False
    if parameters['currLabelOp'] == 'or':
      parameters['query'] += ' OR '
  elif myarg == 'labelmatchpattern':
    parameters['labelMatchPattern'] = getREPattern(re.IGNORECASE)
  elif myarg == 'sendermatchpattern':
    parameters['senderMatchPattern'] = getREPattern(re.IGNORECASE)
  elif myarg == 'ids':
    parameters['messageEntity'] = getUserObjectEntity(Cmd.OB_MESSAGE_ID, parameters['entityType'])
  elif myarg == 'quick':
    parameters['quick'] = True
  elif myarg == 'notquick':
    parameters['quick'] = False
  elif myarg == 'doit':
    parameters['doIt'] = True
  elif myarg in parameters['maxToKeywords']:
    parameters['maxToProcess'] = getInteger(minVal=0)
  elif myarg == 'maxmessagesperthread':
    parameters['maxMessagesPerThread'] = getInteger(minVal=0)
  else:
    return False
  return True

MESSAGE_TIME_QUERY_PATTERN = re.compile(r'(after:|before:|older:|newer:)(\d{4})/(\d{2})/(\d{2})')

def _mapMessageQueryDates(parameters):
  query = parameters['query']
  pos = 0
  while True:
    mg = MESSAGE_TIME_QUERY_PATTERN.search(query, pos)
    if not mg:
      break
    try:
      dt = datetime.datetime(int(mg.groups()[1]), int(mg.groups()[2]), int(mg.groups()[3]), tzinfo=GC.Values[GC.TIMEZONE])
      query = query[:mg.start(2)]+str(int(datetime.datetime.timestamp(dt)))+query[mg.end(4):]
    except ValueError:
      pass
    pos = mg.end()
  parameters['query'] = query

def _finalizeMessageSelectParameters(parameters, queryOrIdsRequired):
  if parameters['query']:
    if parameters['labelGroupOpen']:
      parameters['query'] += ')'
    if parameters['queryTimes']:
      for queryTimeName, queryTimeValue in iter(parameters['queryTimes'].items()):
        parameters['query'] = parameters['query'].replace(f'#{queryTimeName}#', queryTimeValue)
    _mapMessageQueryDates(parameters)
  elif queryOrIdsRequired and parameters['messageEntity'] is None:
    missingArgumentExit('query|matchlabel|ids')
  else:
    parameters['query'] = None
  parameters['maxItems'] = parameters['maxToProcess'] if parameters['quick'] and not parameters['labelMatchPattern'] else 0

# gam <UserTypeEntity> archive messages <GroupItem>
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])+ [quick|notquick] [doit] [max_to_archive <Number>])|(ids <MessageIDEntity>)
#	[csv [todrive <ToDriveAttribute>*]]
def archiveMessages(users):
  def _processMessageFailed(user, idsList, errMsg, j=0, jcount=0):
    if not csvPF:
      entityActionFailedWarning([Ent.USER, user, entityType, idsList], errMsg, j, jcount)
    else:
      csvPF.WriteRow({'User': user, entityHeader: idsList, 'action': Act.Failed(), 'error': errMsg})

  entityType = Ent.MESSAGE
  entityHeader = 'id'
  parameters = _initMessageThreadParameters(entityType, False, 0)
  group = getEmailAddress()
  csvPF = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if _getMessageSelectParameters(myarg, parameters):
      pass
    elif myarg == 'csv':
      csvPF = CSVPrintFile(['User', entityHeader, 'action', 'error'])
    elif csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    else:
      unknownArgumentExit()
  _finalizeMessageSelectParameters(parameters, False)
  if not GC.Values[GC.USER_SERVICE_ACCOUNT_ACCESS_ONLY]:
    gm = buildGAPIObject(API.GROUPSMIGRATION)
    cd = buildGAPIObject(API.DIRECTORY)
    try:
      group = callGAPI(cd.groups(), 'get',
                       throwReasons=GAPI.GROUP_GET_THROW_REASONS,
                       groupKey=group, fields='email')['email']
    except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest):
      entityDoesNotExistExit(Ent.GROUP, group)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail, messageIds = _validateUserGetMessageIds(user, i, count, parameters['messageEntity'])
    if not gmail:
      continue
    if GC.Values[GC.USER_SERVICE_ACCOUNT_ACCESS_ONLY]:
      _, gm = buildGAPIServiceObject(API.GROUPSMIGRATION, user, i, count)
      if not gm:
        continue
    service = gmail.users().messages()
    try:
      if parameters['messageEntity'] is None:
        printGettingAllEntityItemsForWhom(entityType, user, i, count)
        listResult = callGAPIpages(service, 'list', parameters['listType'],
                                   pageMessage=getPageMessageForWhom(), maxItems=parameters['maxItems'],
                                   throwReasons=GAPI.GMAIL_THROW_REASONS+GAPI.GMAIL_LIST_THROW_REASONS,
                                   userId='me', q=parameters['query'], fields=parameters['fields'],
                                   maxResults=GC.Values[GC.MESSAGE_MAX_RESULTS])
        messageIds = [message['id'] for message in listResult]
    except (GAPI.failedPrecondition, GAPI.permissionDenied, GAPI.invalid, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
      continue
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
      continue
    jcount = len(messageIds)
    if jcount == 0:
      entityNumEntitiesActionNotPerformedWarning([Ent.USER, user], entityType, jcount, Msg.NO_ENTITIES_MATCHED.format(Ent.Plural(entityType)), i, count)
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      continue
    if parameters['messageEntity'] is None:
      if parameters['maxToProcess'] and jcount > parameters['maxToProcess']:
        entityNumEntitiesActionNotPerformedWarning([Ent.USER, user], entityType, jcount, Msg.COUNT_N_EXCEEDS_MAX_TO_PROCESS_M.format(jcount, Act.ToPerform(), parameters['maxToProcess']), i, count)
        continue
      if not parameters['doIt']:
        entityNumEntitiesActionNotPerformedWarning([Ent.USER, user], entityType, jcount, Msg.USE_DOIT_ARGUMENT_TO_PERFORM_ACTION, i, count)
        continue
    entityPerformActionNumItems([Ent.USER, user], jcount, entityType, i, count)
    Ind.Increment()
    j = 0
    for messageId in messageIds:
      j += 1
      try:
        message = callGAPI(service, 'get',
                           throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.INVALID_MESSAGE_ID],
                           userId='me', id=messageId, format='raw')
        stream = io.BytesIO()
        stream.write(base64.urlsafe_b64decode(str(message['raw'])))
        try:
          callGAPI(gm.archive(), 'insert',
                   throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.BAD_REQUEST, GAPI.INVALID,
                                                          GAPI.FAILED_PRECONDITION, GAPI.FORBIDDEN],
                   retryReasons=[GAPI.NOT_FOUND],
                   groupId=group, media_body=googleapiclient.http.MediaIoBaseUpload(stream, mimetype='message/rfc822', resumable=True))
          if not csvPF:
            entityActionPerformed([Ent.USER, user, entityType, messageId], j, jcount)
          else:
            csvPF.WriteRow({'User': user, entityHeader: messageId, 'action': Act.Performed()})
        except GAPI.serviceNotAvailable:
          userGmailServiceNotEnabledWarning(user, i, count)
          break
        except GAPI.notFound as e:
          _processMessageFailed(user, messageId, str(e), j, jcount)
          break
        except (GAPI.badRequest, GAPI.invalid, GAPI.failedPrecondition, GAPI.forbidden,
                googleapiclient.errors.MediaUploadSizeError) as e:
          _processMessageFailed(user, messageId, str(e), j, jcount)
      except GAPI.serviceNotAvailable:
        userGmailServiceNotEnabledWarning(user, i, count)
        break
      except (GAPI.notFound, GAPI.invalidMessageId) as e:
        _processMessageFailed(user, messageId, str(e), j, jcount)
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile(f'{Act.ToPerform()} Messages')

def _processMessagesThreads(users, entityType):
  def _processMessageFailed(user, idsList, errMsg, j=0, jcount=0):
    if not csvPF:
      entityActionFailedWarning([Ent.USER, user, entityType, idsList], errMsg, j, jcount)
    else:
      csvPF.WriteRow({'User': user, entityHeader: idsList, 'action': Act.Failed(), 'error': errMsg})

  def _batchDeleteModifyMessages(gmail, function, user, jcount, messageIds, body):
    mcount = 0
    bcount = min(jcount-mcount, GC.Values[GC.MESSAGE_BATCH_SIZE])
    while bcount > 0:
      body['ids'] = messageIds[mcount:mcount+bcount]
      idsCount = min(5, bcount)
      idsList = ','.join(body['ids'][0:idsCount])
      if bcount > 5:
        idsList += ',...'
      try:
        callGAPI(gmail.users().messages(), function,
                 throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.INVALID_MESSAGE_ID, GAPI.INVALID, GAPI.INVALID_ARGUMENT,
                                                        GAPI.FAILED_PRECONDITION, GAPI.PERMISSION_DENIED],
                 userId='me', body=body)
        for messageId in body['ids']:
          mcount += 1
          if not csvPF:
            entityActionPerformed([Ent.USER, user, entityType, messageId], mcount, jcount)
          else:
            csvPF.WriteRow({'User': user, entityHeader: messageId, 'action': Act.Performed()})
      except GAPI.serviceNotAvailable:
        mcount += bcount
      except (GAPI.invalid, GAPI.invalidArgument, GAPI.permissionDenied) as e:
        _processMessageFailed(user, idsList, f'{str(e)} ({mcount+1}-{mcount+bcount}/{jcount})')
        mcount += bcount
      except GAPI.invalidMessageId:
        _processMessageFailed(user, idsList, f'{Msg.INVALID_MESSAGE_ID} ({mcount+1}-{mcount+bcount}/{jcount})')
        mcount += bcount
      except GAPI.failedPrecondition:
        _processMessageFailed(user, idsList, f'{Msg.FAILED_PRECONDITION} ({mcount+1}-{mcount+bcount}/{jcount})')
        mcount += bcount
      bcount = min(jcount-mcount, GC.Values[GC.MESSAGE_BATCH_SIZE])

  _GMAIL_ERROR_REASON_TO_MESSAGE_MAP = {GAPI.NOT_FOUND: Msg.DOES_NOT_EXIST,
                                        GAPI.INVALID_MESSAGE_ID: Msg.INVALID_MESSAGE_ID,
                                        GAPI.FAILED_PRECONDITION: Msg.FAILED_PRECONDITION}

  def _callbackProcessMessage(request_id, _, exception):
    ri = request_id.splitlines()
    if exception is None:
      if not csvPF:
        entityActionPerformed([Ent.USER, ri[RI_ENTITY], entityType, ri[RI_ITEM]], int(ri[RI_J]), int(ri[RI_JCOUNT]))
      else:
        csvPF.WriteRow({'User': ri[RI_ENTITY], entityHeader: ri[RI_ITEM], 'action': Act.Performed()})
    else:
      http_status, reason, message = checkGAPIError(exception)
      _processMessageFailed(ri[RI_ENTITY], ri[RI_ITEM], getHTTPError(_GMAIL_ERROR_REASON_TO_MESSAGE_MAP, http_status, reason, message), int(ri[RI_J]), int(ri[RI_JCOUNT]))

  def _batchProcessMessagesThreads(service, function, user, jcount, messageIds, **kwargs):
    svcargs = dict([('userId', 'me'), ('id', None), ('fields', '')]+list(kwargs.items())+GM.Globals[GM.EXTRA_ARGS_LIST])
    method = getattr(service, function)
    dbatch = gmail.new_batch_http_request(callback=_callbackProcessMessage)
    bcount = 0
    j = 0
    for messageId in messageIds:
      j += 1
      svcparms = svcargs.copy()
      svcparms['id'] = messageId
      dbatch.add(method(**svcparms), request_id=batchRequestID(user, 0, 0, j, jcount, svcparms['id']))
      bcount += 1
      if bcount == GC.Values[GC.EMAIL_BATCH_SIZE]:
        executeBatch(dbatch)
        dbatch = gmail.new_batch_http_request(callback=_callbackProcessMessage)
        bcount = 0
    if bcount > 0:
      dbatch.execute()

  parameters = _initMessageThreadParameters(entityType, False, 1)
  includeSpamTrash = False
  function = {Act.DELETE: 'delete', Act.MODIFY: 'modify', Act.SPAM: 'spam', Act.TRASH: 'trash', Act.UNTRASH: 'untrash'}[Act.Get()]
  labelNameMap = {}
  addLabelNames = []
  addLabelIds = []
  removeLabelNames = []
  removeLabelIds = []
  csvPF = None
  entityHeader = 'id' if entityType == Ent.MESSAGE else 'threadId'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if _getMessageSelectParameters(myarg, parameters):
      pass
    elif (function == 'modify') and (myarg == 'addlabel'):
      addLabelNames.append(getString(Cmd.OB_LABEL_NAME))
    elif (function == 'modify') and (myarg == 'removelabel'):
      removeLabelNames.append(getString(Cmd.OB_LABEL_NAME))
    elif myarg == 'csv':
      csvPF = CSVPrintFile(['User', entityHeader, 'action', 'error'])
    elif csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    else:
      unknownArgumentExit()
  if function == 'modify' and not addLabelNames and not removeLabelNames:
    missingArgumentExit('(addlabel <LabelName>)|(removelabel <LabelName>)')
  _finalizeMessageSelectParameters(parameters, True)
  includeSpamTrash = Act.Get() in [Act.DELETE, Act.MODIFY, Act.UNTRASH]
  if function == 'spam':
    function = 'modify'
    addLabelIds = ['SPAM']
    removeLabelIds = ['INBOX']
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail, messageIds = _validateUserGetMessageIds(user, i, count, parameters['messageEntity'])
    if not gmail:
      continue
    service = gmail.users().messages() if entityType == Ent.MESSAGE else gmail.users().threads()
    if addLabelNames or removeLabelNames:
      userGmailLabels = _getUserGmailLabels(gmail, user, i, count, 'labels(id,name,type)')
      if not userGmailLabels:
        continue
      labelNameMap = _initLabelNameMap(userGmailLabels)
      addLabelIds = _convertLabelNamesToIds(gmail, user, i, count, addLabelNames, labelNameMap, True)
      removeLabelIds = _convertLabelNamesToIds(gmail, user, i, count, removeLabelNames, labelNameMap, False)
      if not addLabelIds and not removeLabelIds:
        entityActionNotPerformedWarning([Ent.USER, user], Msg.NO_LABELS_TO_PROCESS, i, count)
        continue
    try:
      if parameters['messageEntity'] is None:
        printGettingAllEntityItemsForWhom(Ent.MESSAGE, user, i, count)
        listResult = callGAPIpages(service, 'list', parameters['listType'],
                                   pageMessage=getPageMessageForWhom(), maxItems=parameters['maxItems'],
                                   throwReasons=GAPI.GMAIL_THROW_REASONS+GAPI.GMAIL_LIST_THROW_REASONS,
                                   userId='me', q=parameters['query'], fields=parameters['fields'], includeSpamTrash=includeSpamTrash,
                                   maxResults=GC.Values[GC.MESSAGE_MAX_RESULTS])
        messageIds = [message['id'] for message in listResult]
      else:
        # Need to get authorization set up for batch
        callGAPI(gmail.users(), 'getProfile',
                 throwReasons=GAPI.GMAIL_THROW_REASONS+GAPI.GMAIL_LIST_THROW_REASONS,
                 userId='me', fields='')
    except (GAPI.failedPrecondition, GAPI.permissionDenied, GAPI.invalid, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
      continue
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
      continue
    jcount = len(messageIds)
    if jcount == 0:
      entityNumEntitiesActionNotPerformedWarning([Ent.USER, user], entityType, jcount, Msg.NO_ENTITIES_MATCHED.format(Ent.Plural(entityType)), i, count)
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      continue
    if parameters['messageEntity'] is None:
      if parameters['maxToProcess'] and jcount > parameters['maxToProcess']:
        entityNumEntitiesActionNotPerformedWarning([Ent.USER, user], entityType, jcount, Msg.COUNT_N_EXCEEDS_MAX_TO_PROCESS_M.format(jcount, Act.ToPerform(), parameters['maxToProcess']), i, count)
        continue
      if not parameters['doIt']:
        entityNumEntitiesActionNotPerformedWarning([Ent.USER, user], entityType, jcount, Msg.USE_DOIT_ARGUMENT_TO_PERFORM_ACTION, i, count)
        continue
    entityPerformActionNumItems([Ent.USER, user], jcount, entityType, i, count)
    Ind.Increment()
    if function == 'delete' and entityType == Ent.MESSAGE:
      _batchDeleteModifyMessages(gmail, 'batchDelete', user, jcount, messageIds, {'ids': []})
    elif function == 'modify' and entityType == Ent.MESSAGE:
      _batchDeleteModifyMessages(gmail, 'batchModify', user, jcount, messageIds, {'ids': [], 'addLabelIds': addLabelIds, 'removeLabelIds': removeLabelIds})
    else:
      if addLabelIds or removeLabelIds:
        kwargs = {'body': {'addLabelIds': addLabelIds, 'removeLabelIds': removeLabelIds}}
      else:
        kwargs = {}
      _batchProcessMessagesThreads(service, function, user, jcount, messageIds, **kwargs)
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile(f'{Act.ToPerform()} Messages')

# gam <UserTypeEntity> delete message|messages
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])+ [quick|notquick] [doit] [max_to_delete <Number>])|(ids <MessageIDEntity>)
#	[csv [todrive <ToDriveAttribute>*]]
# gam <UserTypeEntity> modify message|messages
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])+ [quick|notquick] [doit] [max_to_modify <Number>])|(ids <MessageIDEntity>)
#	(addlabel <LabelName>)* (removelabel <LabelName>)*
#	[csv [todrive <ToDriveAttribute>*]]
# gam <UserTypeEntity> spam message|messages
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])+ [quick|notquick] [doit] [max_to_spam <Number>])|(ids <MessageIDEntity>)
#	[csv [todrive <ToDriveAttribute>*]]
# gam <UserTypeEntity> trash message|messages
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])+ [quick|notquick] [doit] [max_to_trash <Number>])|(ids <MessageIDEntity>)
#	[csv [todrive <ToDriveAttribute>*]]
# gam <UserTypeEntity> untrash message|messages
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])+ [quick|notquick] [doit] [max_to_untrash <Number>])|(ids <MessageIDEntity>)
#	[csv [todrive <ToDriveAttribute>*]]
def processMessages(users):
  _processMessagesThreads(users, Ent.MESSAGE)

# gam <UserTypeEntity> delete thread|threads
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])+ [quick|notquick] [doit] [max_to_delete <Number>])|(ids <ThreadIDEntity>)
#	[csv [todrive <ToDriveAttribute>*]]
# gam <UserTypeEntity> modify thread|threads
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])+ [quick|notquick] [doit] [max_to_modify <Number>])|(ids <ThreadIDEntity>)
#	(addlabel <LabelName>)* (removelabel <LabelName>)*
#	[csv [todrive <ToDriveAttribute>*]]
# gam <UserTypeEntity> spam thread|threads
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])+ [quick|notquick] [doit] [max_to_spam <Number>])|(ids <ThreadIDEntity>)
#	[csv [todrive <ToDriveAttribute>*]]
# gam <UserTypeEntity> trash thread|threads
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])+ [quick|notquick] [doit] [max_to_trash <Number>])|(ids <MessageIDEntity>)
#	[csv [todrive <ToDriveAttribute>*]]
# gam <UserTypeEntity> untrash thread|threads
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])+ [quick|notquick] [doit] [max_to_untrash <Number>])|(ids <ThreadIDEntity>)
#	[csv [todrive <ToDriveAttribute>*]]
def processThreads(users):
  _processMessagesThreads(users, Ent.THREAD)

def exportMessagesThreads(users, entityType):
  parameters = _initMessageThreadParameters(entityType, False, 1)
  targetFolderPattern = GC.Values[GC.DRIVE_DIR]
  targetNamePattern = None
  includeSpamTrash = overwrite = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if _getMessageSelectParameters(myarg, parameters):
      pass
    elif myarg == 'targetfolder':
      targetFolderPattern = os.path.expanduser(getString(Cmd.OB_FILE_PATH))
    elif myarg == 'targetname':
      targetNamePattern = getString(Cmd.OB_FILE_NAME)
    elif myarg == 'overwrite':
      overwrite = getBoolean()
    else:
      unknownArgumentExit()
  _finalizeMessageSelectParameters(parameters, True)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail, entityIds = _validateUserGetMessageIds(user, i, count, parameters['messageEntity'])
    if not gmail:
      continue
    _, userName, _ = splitEmailAddressOrUID(user)
    targetFolder = _substituteForUser(targetFolderPattern, user, userName)
    if not os.path.isdir(targetFolder):
      os.makedirs(targetFolder)
    targetName = _substituteForUser(targetNamePattern, user, userName) if targetNamePattern else None
    service = gmail.users().messages() if entityType == Ent.MESSAGE else gmail.users().threads()
    try:
      if parameters['messageEntity'] is None:
        printGettingAllEntityItemsForWhom(entityType, user, i, count)
        listResult = callGAPIpages(service, 'list', parameters['listType'],
                                   pageMessage=getPageMessageForWhom(), maxItems=parameters['maxItems'],
                                   throwReasons=GAPI.GMAIL_THROW_REASONS+GAPI.GMAIL_LIST_THROW_REASONS,
                                   userId='me', q=parameters['query'], fields=parameters['fields'], includeSpamTrash=includeSpamTrash,
                                   maxResults=GC.Values[GC.MESSAGE_MAX_RESULTS])
        entityIds = [entity['id'] for entity in listResult]
    except (GAPI.failedPrecondition, GAPI.permissionDenied, GAPI.invalid, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
      continue
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
      continue
    jcount = len(entityIds)
    if jcount == 0:
      entityNumEntitiesActionNotPerformedWarning([Ent.USER, user], entityType, jcount, Msg.NO_ENTITIES_MATCHED.format(Ent.Plural(entityType)), i, count)
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      continue
    if parameters['messageEntity'] is None:
      if parameters['maxToProcess'] and jcount > parameters['maxToProcess']:
        entityNumEntitiesActionNotPerformedWarning([Ent.USER, user], entityType, jcount, Msg.COUNT_N_EXCEEDS_MAX_TO_PROCESS_M.format(jcount, Act.ToPerform(), parameters['maxToProcess']), i, count)
        continue
    entityPerformActionNumItems([Ent.USER, user], jcount, entityType, i, count)
    Ind.Increment()
    j = 0
    for entityId in entityIds:
      j += 1
      if entityType == Ent.THREAD:
        try:
          result = callGAPI(gmail.users().threads(), 'get',
                             throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT],
                             userId='me', id=entityId, fields='messages(id)')
          messageIds = [message['id'] for message in result['messages']]
          kcount = len(messageIds)
          entityPerformActionNumItems([Ent.USER, user, Ent.THREAD, entityId], kcount, Ent.MESSAGE, j, jcount)
          Ind.Increment()
          k = 0
        except GAPI.serviceNotAvailable:
          userGmailServiceNotEnabledWarning(user, i, count)
          break
        except (GAPI.notFound, GAPI.invalidArgument) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.THREAD, entityId], str(e), j, jcount)
          continue
      else:
        messageIds = [entityId]
        kcount = jcount
        k = j-1
      for messageId in messageIds:
        k += 1
        try:
          result = callGAPI(gmail.users().messages(), 'get',
                            throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.INVALID_MESSAGE_ID],
                            userId='me', id=messageId, format='raw')
          if targetName:
            msgName = targetName.replace('#id#', messageId)
          else:
            msgName = f'Msg-{messageId}.eml'
          filename, _ = uniqueFilename(targetFolder, msgName, overwrite)
          status, e = writeFileReturnError(filename, base64.urlsafe_b64decode(str(result['raw'])), mode='wb')
          if status:
            entityActionPerformed([Ent.MESSAGE, filename])
          else:
            entityActionFailedWarning([Ent.MESSAGE, filename], str(e))
        except GAPI.serviceNotAvailable:
          userGmailServiceNotEnabledWarning(user, i, count)
          break
        except (GAPI.notFound, GAPI.invalidMessageId) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.MESSAGE, messageId], str(e), k, kcount)
          continue
      if entityType == Ent.THREAD:
        Ind.Decrement()
    Ind.Decrement()

# gam <UserTypeEntity> export message|messages
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])+ [quick|notquick] [max_to_export <Number>])|(ids <MessageIDEntity>)
#	[targetfolder <FilePath>] [targetname <FileName>] [overwrite [<Boolean>]]
def exportMessages(users):
  exportMessagesThreads(users, Ent.MESSAGE)

# gam <UserTypeEntity> export thread|threads
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])+ [quick|notquick] [max_to_export <Number>])|(ids <ThreadIDEntity>)
#	[targetfolder <FilePath>] [targetname <FileName>] [overwrite [<Boolean>]]
def exportThreads(users):
  exportMessagesThreads(users, Ent.THREAD)

HEADER_ENCODE_PATTERN = re.compile(r'=\?([^?]*?)\?[qQbB]\?(.*?)\?=', re.VERBOSE | re.MULTILINE)

def _decodeHeader(header):
  header = header.encode(UTF8, 'replace').decode(UTF8)
  while True:
    mg = HEADER_ENCODE_PATTERN.search(header)
    if not mg:
      return header
    try:
      header = header[:mg.start()]+decode_header(mg.group())[0][0].decode(mg.group(1))+header[mg.end():]
    except LookupError:
      stderrWarningMsg(Msg.INVALID_CHARSET.format(mg.group(1)))
      return header

# gam <UserTypeEntity> forward message|messages recipient|to <RecipientEntity>
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])+ [quick|notquick] [doit] [max_to_forward <Number>])|(ids <MessageIDEntity>)
#	[subject <String>] [addorigfieldstosubject [<Boolean>]] [altcharset <String>]
# gam <UserTypeEntity> forward thread|threads recipient|to <RecipientEntity>
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])+ [quick|notquick] [doit] [max_to_forward <Number>])|(ids <ThreadIDEntity>)
#	[subject <String>] [addorigfieldstosubject [<Boolean>]] [altcharset <String>]
def forwardMessagesThreads(users, entityType):
  checkArgumentPresent({'recipient', 'recipients', 'to'})
  recipients = getRecipients()
  parameters = _initMessageThreadParameters(entityType, False, 1)
  addOriginalFieldsToSubject = includeSpamTrash = False
  subject = ''
  encodings = [UTF8]
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if _getMessageSelectParameters(myarg, parameters):
      pass
    elif myarg == 'subject':
      subject = getString(Cmd.OB_STRING)
    elif myarg == 'addorigfieldstosubject':
      addOriginalFieldsToSubject = getBoolean()
    elif myarg == 'altcharset':
      encodings.append(getString(Cmd.OB_CHAR_SET))
    else:
      unknownArgumentExit()
  _finalizeMessageSelectParameters(parameters, True)
  msgTo = ','.join(recipients)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail, entityIds = _validateUserGetMessageIds(user, i, count, parameters['messageEntity'])
    if not gmail:
      continue
    service = gmail.users().messages() if entityType == Ent.MESSAGE else gmail.users().threads()
    if parameters['messageEntity'] is None:
      try:
        printGettingAllEntityItemsForWhom(entityType, user, i, count)
        listResult = callGAPIpages(service, 'list', parameters['listType'],
                                   pageMessage=getPageMessageForWhom(), maxItems=parameters['maxItems'],
                                   throwReasons=GAPI.GMAIL_THROW_REASONS+GAPI.GMAIL_LIST_THROW_REASONS,
                                   userId='me', q=parameters['query'], fields=parameters['fields'], includeSpamTrash=includeSpamTrash,
                                   maxResults=GC.Values[GC.MESSAGE_MAX_RESULTS])
        entityIds = [entity['id'] for entity in listResult]
      except (GAPI.failedPrecondition, GAPI.permissionDenied, GAPI.invalid, GAPI.invalidArgument) as e:
        entityActionFailedWarning([Ent.USER, user], str(e), i, count)
        continue
      except GAPI.serviceNotAvailable:
        userGmailServiceNotEnabledWarning(user, i, count)
        continue
    jcount = len(entityIds)
    if jcount == 0:
      entityNumEntitiesActionNotPerformedWarning([Ent.USER, user], entityType, jcount, Msg.NO_ENTITIES_MATCHED.format(Ent.Plural(entityType)), i, count)
      setSysExitRC(NO_ENTITIES_FOUND_RC)
      continue
    if parameters['messageEntity'] is None:
      if parameters['maxToProcess'] and jcount > parameters['maxToProcess']:
        entityNumEntitiesActionNotPerformedWarning([Ent.USER, user], entityType, jcount, Msg.COUNT_N_EXCEEDS_MAX_TO_PROCESS_M.format(jcount, Act.ToPerform(), parameters['maxToProcess']), i, count)
        continue
      if not parameters['doIt']:
        entityNumEntitiesActionNotPerformedWarning([Ent.USER, user], entityType, jcount, Msg.USE_DOIT_ARGUMENT_TO_PERFORM_ACTION, i, count)
        continue
    entityPerformActionNumItems([Ent.USER, user], jcount, entityType, i, count)
    Ind.Increment()
    j = 0
    for entityId in entityIds:
      j += 1
      if entityType == Ent.THREAD:
        try:
          result = callGAPI(gmail.users().threads(), 'get',
                             throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.INVALID_MESSAGE_ID],
                             userId='me', id=entityId, fields='messages(id)')
          messageIds = [message['id'] for message in result['messages']]
          kcount = len(messageIds)
          entityPerformActionNumItems([Ent.USER, user, Ent.THREAD, entityId], kcount, Ent.MESSAGE, j, jcount)
          Ind.Increment()
          k = 0
        except GAPI.serviceNotAvailable:
          userGmailServiceNotEnabledWarning(user, i, count)
          break
        except (GAPI.notFound, GAPI.invalidMessageId) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.THREAD, entityId], str(e), j, jcount)
          continue
      else:
        messageIds = [entityId]
        kcount = jcount
        k = j-1
      for messageId in messageIds:
        k += 1
        try:
          result = callGAPI(gmail.users().messages(), 'get',
                            throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.INVALID_MESSAGE_ID],
                            userId='me', id=messageId, format='raw')
          for encoding in encodings:
            try:
              message = message_from_string(base64.urlsafe_b64decode(str(result['raw'])).decode(encoding), policy=policySMTP)
              break
            except UnicodeDecodeError as e:
              errMsg = str(e)
          else:
            entityActionNotPerformedWarning([Ent.RECIPIENT, msgTo, entityType, messageId], errMsg, k, kcount)
            continue
          if not subject:
            msgSubject = f"Fwd: {_decodeHeader(message['Subject'])}"
          else:
            msgSubject = f"Subject: {subject}"
          for header in ['To', 'Cc', 'Subject']:
            if header in message:
              del message[header]
          message['To'] = msgTo
          if addOriginalFieldsToSubject:
            msgSubject += ' (Original'
            for header in ['From', 'To', 'Date']:
              if header in message:
                msgSubject += f' {header}: {message[header]}'
            msgSubject += ')'
          message['Subject'] = msgSubject
          try:
            result = callGAPI(gmail.users().messages(), 'send',
                              throwReasons=[GAPI.SERVICE_NOT_AVAILABLE, GAPI.AUTH_ERROR, GAPI.DOMAIN_POLICY,
                                            GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED],
                              userId='me', body={'raw': base64.urlsafe_b64encode(message.as_bytes()).decode(encoding)}, fields='id')
            entityActionPerformedMessage([Ent.RECIPIENT, msgTo], f"{result['id']}", k, kcount)
          except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy,
                  GAPI.invalid, GAPI.invalidArgument, GAPI.forbidden, GAPI.permissionDenied, UnicodeEncodeError) as e:
            entityActionFailedWarning([Ent.RECIPIENT, msgTo], str(e), k, kcount)
        except GAPI.serviceNotAvailable:
          userGmailServiceNotEnabledWarning(user, i, count)
          break
        except (GAPI.notFound, GAPI.invalidMessageId) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.MESSAGE, messageId], str(e), k, kcount)
          continue
      if entityType == Ent.THREAD:
        Ind.Decrement()
    Ind.Decrement()

SMTP_HEADERS_MAP = {
  'accept-language': 'Accept-Language',
  'alternate-recipient': 'Alternate-Recipient',
  'autoforwarded': 'Autoforwarded',
  'autosubmitted': 'Autosubmitted',
  'bcc': 'Bcc',
  'cc': 'Cc',
  'comments': 'Comments',
  'content-alternative': 'Content-Alternative',
  'content-base': 'Content-Base',
  'content-description': 'Content-Description',
  'content-disposition': 'Content-Disposition',
  'content-duration': 'Content-Duration',
  'content-id': 'Content-ID',
  'content-identifier': 'Content-Identifier',
  'content-language': 'Content-Language',
  'content-location': 'Content-Location',
  'content-md5': 'Content-MD5',
  'content-return': 'Content-Return',
  'content-transfer-encoding': 'Content-Transfer-Encoding',
  'content-type': 'Content-Type',
  'content-features': 'Content-features',
  'conversion': 'Conversion',
  'conversion-with-loss': 'Conversion-With-Loss',
  'dl-expansion-history': 'DL-Expansion-History',
  'date': 'Date',
  'deferred-delivery': 'Deferred-Delivery',
  'delivered-to': 'Delivered-To',
  'delivery-date': 'Delivery-Date',
  'discarded-x400-ipms-extensions': 'Discarded-X400-IPMS-Extensions',
  'discarded-x400-mts-extensions': 'Discarded-X400-MTS-Extensions',
  'disclose-recipients': 'Disclose-Recipients',
  'disposition-notification-options': 'Disposition-Notification-Options',
  'disposition-notification-to': 'Disposition-Notification-To',
  'encoding': 'Encoding',
  'encrypted': 'Encrypted',
  'expires': 'Expires',
  'expiry-date': 'Expiry-Date',
  'from': 'From',
  'generate-delivery-report': 'Generate-Delivery-Report',
  'importance': 'Importance',
  'in-reply-to': 'In-Reply-To',
  'incomplete-copy': 'Incomplete-Copy',
  'keywords': 'Keywords',
  'language': 'Language',
  'latest-delivery-time': 'Latest-Delivery-Time',
  'list-archive': 'List-Archive',
  'list-help': 'List-Help',
  'list-id': 'List-ID',
  'list-owner': 'List-Owner',
  'list-post': 'List-Post',
  'list-subscribe': 'List-Subscribe',
  'list-unsubscribe': 'List-Unsubscribe',
  'mime-version': 'MIME-Version',
  'message-context': 'Message-Context',
  'message-id': 'Message-ID',
  'message-type': 'Message-Type',
  'obsoletes': 'Obsoletes',
  'original-encoded-information-types': 'Original-Encoded-Information-Types',
  'original-message-id': 'Original-Message-ID',
  'originator-return-address': 'Originator-Return-Address',
  'pics-label': 'PICS-Label',
  'prevent-nondelivery-report': 'Prevent-NonDelivery-Report',
  'priority': 'Priority',
  'received': 'Received',
  'recipient': 'To',
  'references': 'References',
  'reply-by': 'Reply-By',
  'reply-to': 'Reply-To',
  'resent-bcc': 'Resent-Bcc',
  'resent-cc': 'Resent-Cc',
  'resent-date': 'Resent-Date',
  'resent-from': 'Resent-From',
  'resent-message-id': 'Resent-Message-ID',
  'resent-reply-to': 'Resent-Reply-To',
  'resent-sender': 'Resent-Sender',
  'resent-to': 'Resent-To',
  'return-path': 'Return-Path',
  'sender': 'Sender',
  'sensitivity': 'Sensitivity',
  'subject': 'Subject',
  'supersedes': 'Supersedes',
  'to': 'To',
  'x400-content-identifier': 'X400-Content-Identifier',
  'x400-content-return': 'X400-Content-Return',
  'x400-content-type': 'X400-Content-Type',
  'x400-mts-identifier': 'X400-MTS-Identifier',
  'x400-originator': 'X400-Originator',
  'x400-received': 'X400-Received',
  'x400-recipients': 'X400-Recipients',
  'x400-trace': 'X400-Trace',
  }

SMTP_ADDRESS_HEADERS = {
  'Bcc',
  'Cc',
  'Delivered-To',
  'From',
  'Reply-To',
  'Resent-Bcc',
  'Resent-Cc',
  'Resent-Reply-To',
  'Resent-Sender',
  'Resent-To',
  'Sender',
  'To',
  }

SMTP_DATE_HEADERS = {
  'date',
  'delivery-date',
  'expires',
  'expiry-date',
  'latest-delivery-time',
  'reply-by',
  'resent-date',
  }

SMTP_NAME_ADDRESS_PATTERN = re.compile(r'^(.+?)\s*<(.+)>$')

IMPORT_INSERT = {'import', 'insert'}

def _draftImportInsertMessage(users, operation):
  def _appendToHeader(header, value):
    try:
      header.append(value)
    except UnicodeDecodeError:
      header.append(value, UTF8)

  labelNameMap = {}
  addLabelNames = []
  msgHTML = msgText = ''
  msgHeaders = {}
  tagReplacements = _initTagReplacements()
  attachments = []
  embeddedImages = []
  internalDateSource = 'receivedTime'
  deleted = processForCalendar = substituteForUserInHeaders = False
  neverMarkSpam = True
  emlFile = False
  emlEncoding = 'ascii'
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in SMTP_HEADERS_MAP:
      if myarg == 'content-type':
        unknownArgumentExit()
      if myarg in SMTP_DATE_HEADERS:
        msgDate, _, _ = getTimeOrDeltaFromNow(True)
        msgHeaders[SMTP_HEADERS_MAP[myarg]] = formatdate(time.mktime(msgDate.timetuple()) + msgDate.microsecond/1E6, True)
        if myarg == 'date':
          internalDateSource = 'dateHeader'
      else:
        value = getString(Cmd.OB_STRING)
        if (value.find('#user#') >= 0) or (value.find('#email#') >= 0) or (value.find('#username#') >= 0):
          substituteForUserInHeaders = True
        msgHeaders[SMTP_HEADERS_MAP[myarg]] = value
    elif myarg == 'header':
      header = getString(Cmd.OB_STRING, minLen=1)
      if header.lower() == 'content-type':
        unknownArgumentExit()
      value = getString(Cmd.OB_STRING)
      if (value.find('#user#') >= 0) or (value.find('#email#') >= 0) or (value.find('#username#') >= 0):
        substituteForUserInHeaders = True
      msgHeaders[SMTP_HEADERS_MAP.get(header.lower(), header)] = value
    elif myarg in SORF_MSG_FILE_ARGUMENTS:
      if 'html' in myarg:
        msgHTML, _, _ = getStringOrFile(myarg)
      else:
        msgText, _, _ = getStringOrFile(myarg)
      emlFile = False
    elif myarg == 'emlfile':
      filename = getString(Cmd.OB_FILE_NAME)
      if checkArgumentPresent('charset'):
        emlEncoding = getString(Cmd.OB_CHAR_SET)
      msgText = readFile(filename, encoding=emlEncoding)
      emlFile = True
      internalDateSource = 'dateHeader'
      if checkArgumentPresent('emlutf8'):
        emlEncoding = UTF8
    elif _getTagReplacement(myarg, tagReplacements, True):
      pass
    elif operation in IMPORT_INSERT and myarg == 'addlabel':
      addLabelNames.append(getString(Cmd.OB_LABEL_NAME, minLen=1))
    elif operation in IMPORT_INSERT and myarg == 'labels':
      addLabelNames.extend(shlexSplitList(getString(Cmd.OB_LABEL_NAME_LIST)))
    elif operation in IMPORT_INSERT and myarg == 'deleted':
      deleted = getBoolean()
    elif myarg == 'attach':
      attachments.append((getFilename(), getCharSet()))
    elif myarg == 'embedimage':
      embeddedImages.append((getFilename(), getString(Cmd.OB_STRING)))
    elif operation == 'import' and myarg == 'nevermarkspam':
      neverMarkSpam = getBoolean()
    elif operation == 'import' and myarg == 'checkspam':
      neverMarkSpam = not getBoolean()
    elif operation == 'import' and myarg == 'processforcalendar':
      processForCalendar = getBoolean()
    else:
      unknownArgumentExit()
  if not msgText and not msgHTML:
    missingArgumentExit('textmessage|textfile|htmlmessage|htmlfile|empfile')
  if not emlFile:
    msgText = msgText.replace('\r', '').replace('\\n', '\n')
    msgHTML = msgHTML.replace('\r', '').replace('\\n', '<br/>')
    if not tagReplacements['tags']:
      tmpText = msgText
      tmpHTML = msgHTML
  if operation != 'draft':
    if not emlFile:
      if 'To' not in msgHeaders:
        msgHeaders['To'] = '#user#'
        substituteForUserInHeaders = True
      if 'From' not in msgHeaders:
        msgHeaders['From'] = _getAdminEmail()
    kwargs = {'internalDateSource': internalDateSource, 'deleted': deleted}
    if operation == 'import':
      function = 'import_'
      kwargs.update({'neverMarkSpam': neverMarkSpam, 'processForCalendar': processForCalendar})
    else: #'insert':
      function = 'insert'
  else:
    function = 'create'
  add_charset(UTF8, QP, QP, UTF8)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    userName, _ = splitEmailAddress(user)
    if not emlFile:
      if tagReplacements['tags']:
        if tagReplacements['subs']:
          _getTagReplacementFieldValues(user, i, count, tagReplacements)
        tmpText = _processTagReplacements(tagReplacements, msgText)
        tmpHTML = _processTagReplacements(tagReplacements, msgHTML)
      if attachments or embeddedImages:
        if tmpText and tmpHTML:
          message = MIMEMultipart('alternative')
          textpart = MIMEText(tmpText, 'plain', UTF8)
          message.attach(textpart)
          htmlpart = MIMEText(tmpHTML, 'html', UTF8)
          message.attach(htmlpart)
        elif tmpHTML:
          message = MIMEMultipart()
          htmlpart = MIMEText(tmpHTML, 'html', UTF8)
          message.attach(htmlpart)
        else:
          message = MIMEMultipart()
          textpart = MIMEText(tmpText, 'plain', UTF8)
          message.attach(textpart)
        _addAttachmentsToMessage(message, attachments)
        _addEmbeddedImagesToMessage(message, embeddedImages)
      else:
        if tmpText and tmpHTML:
          message = MIMEMultipart('alternative')
          textpart = MIMEText(tmpText, 'plain', UTF8)
          message.attach(textpart)
          htmlpart = MIMEText(tmpHTML, 'html', UTF8)
          message.attach(htmlpart)
        elif tmpHTML:
          message = MIMEText(tmpHTML, 'html', UTF8)
        else:
          message = MIMEText(tmpText, 'plain', UTF8)
      for header, value in iter(msgHeaders.items()):
        if substituteForUserInHeaders:
          value = _substituteForUser(value, user, userName)
        message[header] = Header()
        if header in SMTP_ADDRESS_HEADERS:
          match = SMTP_NAME_ADDRESS_PATTERN.match(value.strip())
          if match:
            _appendToHeader(message[header], match.group(1))
            _appendToHeader(message[header], match.group(2))
          else:
            _appendToHeader(message[header], value)
        else:
          _appendToHeader(message[header], value)
      tmpFile = TemporaryFile(mode='w+', encoding=UTF8)
      g = Generator(tmpFile, False)
      g.flatten(message)
      tmpFile.seek(0)
      body = {'raw': base64.urlsafe_b64encode(bytes(tmpFile.read(), UTF8)).decode()}
      tmpFile.close()
    else:
      for header, value in iter(msgHeaders.items()):
        if substituteForUserInHeaders:
          value = _substituteForUser(value, user, userName)
        msgText = re.sub(fr'(?sm)\n{header}:.+?(?=[\r\n]+[a-zA-Z0-9-]+:)', f'\n{header}: {value}', msgText, 1)
      message_bytes = msgText.encode(emlEncoding)
      base64_bytes = base64.b64encode(message_bytes)
      body = {'raw': base64_bytes.decode(emlEncoding)}
    try:
      if operation != 'draft':
        if addLabelNames:
          userGmailLabels = _getUserGmailLabels(gmail, user, i, count, 'labels(id,name,type)')
          if not userGmailLabels:
            continue
          labelNameMap = _initLabelNameMap(userGmailLabels)
          body['labelIds'] = _convertLabelNamesToIds(gmail, user, i, count, addLabelNames, labelNameMap, True)
        else:
          body['labelIds'] = ['INBOX']
        result = callGAPI(gmail.users().messages(), function,
                          throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.PERMISSION_DENIED],
                          userId='me', body=body, fields='id', **kwargs)
      else:
        result = callGAPI(gmail.users().drafts(), function,
                          throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                          userId='me', body={'message': body}, fields='id')
      entityActionPerformed([Ent.USER, user, Ent.MESSAGE, result['id']], i, count)
    except (GAPI.invalidArgument, GAPI.permissionDenied) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)

# gam <UserTypeEntity> draft message
#	<MessageContent>
#	(replace <Tag> <UserReplacement>)*
#	(replaceregex <REMatchPattern> <RESubstitution> <Tag> <UserReplacement>)*
#	(<SMTPDateHeader> <Time>)* (<SMTPHeader> <String>)* (header <String> <String>)*
#	(attach <FileName> [charset <CharSet>])*
#	(embedimage <FileName> <String>)*
def draftMessage(users):
  _draftImportInsertMessage(users, 'draft')

# gam <UserTypeEntity> import message
#	<MessageContent>
#	(replace <Tag> <UserReplacement>)*
#	(replaceregex <REMatchPattern> <RESubstitution> <Tag> <UserReplacement>)*
#	(<SMTPDateHeader> <Time>)* (<SMTPHeader> <String>)* (header <String> <String>)*
#	(addlabel <LabelName>)* [labels <LabelNameList>]
#	(attach <FileName> [charset <CharSet>])*
#	(embedimage <FileName> <String>)*
#	[deleted [<Boolean>]] [nevermarkspam [<Boolean>]] [processforcalendar [<Boolean>]]
def importMessage(users):
  _draftImportInsertMessage(users, 'import')

# gam <UserTypeEntity> insert message
#	<MessageContent>
#	(replace <Tag> <UserReplacement>)*
#	(replaceregex <REMatchPattern> <RESubstitution> <Tag> <UserReplacement>)*
#	(<SMTPDateHeader> <Time>)* (<SMTPHeader> <String>)* (header <String> <String>)*
#	(addlabel <LabelName>)* [labels <LabelNameList>]
#	(attach <FileName> [charset <CharSet>])*
#	(embedimage <FileName> <String>)*
#	[deleted [<Boolean>]]
def insertMessage(users):
  _draftImportInsertMessage(users, 'insert')

def printShowMessagesThreads(users, entityType):

  def _getBodyData(payload, getOrigMsg):
    data = headers = ''
    for part in payload.get('parts', []):
      if getOrigMsg:
        if show_all_headers:
          if not headers:
            headers = '---------- Original message ----------\n'
          for header in part.get('headers', []):
            headers += header['name']+': '+_decodeHeader(header['value'])+'\n'
        elif 'headers' in part:
          for name in headersToShow:
            for header in part['headers']:
              if name == header['name'].lower():
                if not headers:
                  headers = '---------- Original message ----------\n'
                headers += SMTP_HEADERS_MAP.get(name, header['name'])+': '+_decodeHeader(header['value'])+'\n'
        if headers:
          headers += 'Body:\n'
          data = Ind.INDENT_SPACES_PER_LEVEL
      if part['mimeType'] == 'text/plain':
        if 'data' in part['body']:
          data += base64.urlsafe_b64decode(str(part['body']['data'])).decode(UTF8)+'\n'
      elif show_html and part['mimeType'] == 'text/html':
        if 'data' in part['body']:
          data += base64.urlsafe_b64decode(str(part['body']['data'])).decode(UTF8)+'\n'
      elif part['mimeType'] == 'text/rfc822-headers':
        if 'data' in part['body']:
          data += _decodeHeader(base64.urlsafe_b64decode(str(part['body']['data'])).decode(UTF8)+'\n')
      else:
        data += _getBodyData(part, part['mimeType'] == 'message/rfc822')
    if getOrigMsg:
      data = data.replace('\n', f'\n{Ind.INDENT_SPACES_PER_LEVEL}').rstrip()
    return headers+data

  def _getMessageBody(payload):
    if 'attachmentId' not in payload.get('body', {}) and 'data' in payload.get('body', {}):
      return base64.urlsafe_b64decode(str(payload['body']['data'])).decode(UTF8)
    data = _getBodyData(payload, False)
    if data:
      return data
    return 'Body not available'

  ATTACHMENT_NAME_PATTERN = re.compile(r'^.*name="?(.*?)(?:"|;|$)')
  CHARSET_NAME_PATTERN = re.compile(r'^.*charset="?(.*?)(?:"|;|$)')

  def _showAttachmentMimeTypeSizeCharset(part, charset):
    printKeyValueList(['mimeType', part['mimeType']])
    printKeyValueList(['size', part['body']['size']])
    if charset:
      printKeyValueList(['charset', charset])

  def _showSaveAttachments(messageId, payload, attachmentNamePattern, j, jcount):
    for part in payload.get('parts', []):
      if 'attachmentId' in part['body']:
        for header in part['headers']:
          if header['name'] in {'Content-Type', 'Content-Disposition'}:
            mg = ATTACHMENT_NAME_PATTERN.match(header['value'])
            if not mg:
              continue
            attachmentName = mg.group(1)
            if (not attachmentNamePattern) or attachmentNamePattern.match(attachmentName):
              charset = ''
              if part['mimeType'] == 'text/plain':
                mg = CHARSET_NAME_PATTERN.match(header['value'])
                if mg:
                  charset = mg.group(1)
              if (part['mimeType'] == 'text/plain' and not noshow_text_plain) or save_attachments or upload_attachments:
                try:
                  result = callGAPI(gmail.users().messages().attachments(), 'get',
                                    throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.NOT_FOUND],
                                    messageId=messageId, id=part['body']['attachmentId'], userId='me')
                  if 'data' in result:
                    if show_attachments:
                      printKeyValueList(['Attachment', attachmentName])
                      Ind.Increment()
                      if part['mimeType'] == 'text/plain':
                        try:
                          printKeyValueList([Ind.MultiLineText(base64.urlsafe_b64decode(str(result['data'])).decode(charset)+'\n')])
                        except (LookupError, UnicodeDecodeError, UnicodeError):
                          _showAttachmentMimeTypeSizeCharset(part, charset)
                      else:
                        _showAttachmentMimeTypeSizeCharset(part, charset)
                      Ind.Decrement()
                    if save_attachments:
                      filename, _ = uniqueFilename(targetFolder, cleanFilename(attachmentName), overwrite)
                      action = Act.Get()
                      Act.Set(Act.DOWNLOAD)
                      status, e = writeFileReturnError(filename, base64.urlsafe_b64decode(str(result['data'])), mode='wb')
                      if status:
                        entityActionPerformed([Ent.ATTACHMENT, filename])
                      else:
                        entityActionFailedWarning([Ent.ATTACHMENT, filename], str(e))
                      Act.Set(action)
                    if upload_attachments:
                      filename = cleanFilename(attachmentName)
                      uploadAttachmentBody.update({'name': filename, 'mimeType': part['mimeType']})
                      action = Act.Get()
                      Act.Set(Act.CREATE)
                      media_body = googleapiclient.http.MediaIoBaseUpload(io.BytesIO(base64.urlsafe_b64decode(str(result['data']))), mimetype=part['mimeType'], resumable=True)
                      try:
                        result = callGAPI(drive.files(), 'create',
                                          throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                                      GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.CANNOT_ADD_PARENT,
                                                                                      GAPI.FILE_NOT_FOUND, GAPI.UNKNOWN_ERROR, GAPI.INTERNAL_ERROR,
                                                                                      GAPI.STORAGE_QUOTA_EXCEEDED, GAPI.TEAMDRIVES_SHARING_RESTRICTION_NOT_ALLOWED,
                                                                                      GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP,
                                                                                      GAPI.UPLOAD_TOO_LARGE, GAPI.TEAMDRIVES_SHORTCUT_FILE_NOT_SUPPORTED],
                                          media_body=media_body, body=uploadAttachmentBody, fields='id,name', supportsAllDrives=True)
                        entityModifierItemValueListActionPerformed([Ent.DRIVE_FILE, f"{result['name']}({result['id']})"],
                                                                   Act.MODIFIER_WITH_CONTENT_FROM, [Ent.ATTACHMENT, filename], j, jcount)
                      except (GAPI.forbidden, GAPI.insufficientPermissions, GAPI.insufficientParentPermissions,
                              GAPI.invalid, GAPI.badRequest, GAPI.cannotAddParent,
                              GAPI.fileNotFound, GAPI.unknownError, GAPI.internalError,
                              GAPI.storageQuotaExceeded, GAPI.teamdrivesSharingRestrictionNotAllowed,
                              GAPI.teamdrivefileLimitExceeded, GAPI.teamdriveHierarchyTooDeep,
                              GAPI.uploadTooLarge, GAPI.teamdrivesShortcutFileNotSupported) as e:
                        entityModifierItemValueListActionFailedWarning([Ent.DRIVE_FILE, None],
                                                                       Act.MODIFIER_WITH_CONTENT_FROM, [Ent.ATTACHMENT, filename], str(e), j, jcount)
                      except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
                        userDriveServiceNotEnabledWarning(user, str(e), i, count)
                      Act.Set(action)
                except (GAPI.serviceNotAvailable, GAPI.notFound):
                  pass
              elif show_attachments:
                printKeyValueList(['Attachment', attachmentName])
                Ind.Increment()
                _showAttachmentMimeTypeSizeCharset(part, charset)
                Ind.Decrement()
            break
      else:
        _showSaveAttachments(messageId, part, attachmentNamePattern, j, jcount)

  def _initSenderLabelsMap(sender):
    if sender not in senderLabelsMaps:
      senderLabelsMaps[sender] = {'*None*': {'name': '*None*', 'count': 0, 'size': 0, 'type': LABEL_TYPE_USER, 'match': labelMatchPattern is None}}
      for label in labels['labels']:
        senderLabelsMaps[sender][label['id']] = {'name': label['name'], 'count': 0, 'size': 0, 'type': label['type'],
                                                'match': True if not labelMatchPattern else labelMatchPattern.match(label['name']) is not None}
    return senderLabelsMaps[sender]

  def _getMatchMessageLabels(result, sender):
    labelsMap = _initSenderLabelsMap(sender)
    messageLabels = []
    match = False
    for labelId in result.get('labelIds', []):
      if labelId in labelsMap:
        match |= labelsMap[labelId]['match']
        if not onlyUser or labelsMap[labelId]['type'] != LABEL_TYPE_SYSTEM:
          messageLabels.append(labelsMap[labelId]['name'])
      else:
        messageLabels.append(labelId)
    if labelMatchPattern and not match:
      return None
    return messageLabels

  def _checkSenderMatch(result):
    for header in result['payload'].get('headers', []):
      sender = _decodeHeader(header['value'])
      if header['name'] == 'Sender' and senderMatchPattern.match(sender):
        return sender
    return None

  def _checkSenderMatchCount(result):
    for header in result['payload'].get('headers', []):
      sender = _decodeHeader(header['value'])
      if header['name'] == 'Sender' and senderMatchPattern.match(sender):
        senderCounts.setdefault(sender, {'count': 0, 'size': 0})
        senderCounts[sender]['count'] += 1
        senderCounts[sender]['size'] += result['sizeEstimate']
        return sender
    return None

  def _qualifyMessage(user, result):
    if senderMatchPattern:
      sender = _checkSenderMatchCount(result)
      if not sender:
        return (False, None)
    else:
      sender = user
    if show_labels or labelMatchPattern:
      messageLabels = _getMatchMessageLabels(result, sender)
      if messageLabels is None:
        return (False, None)
      return (True, messageLabels)
    return (True, None)

  def _convertDateTime(headerValue):
    dateTimeValue = headerValue.replace('GMT', '+0000')
    dateTimeValue = dateTimeValue.replace('UT', '+0000')
    pLoc = dateTimeValue.find(' (')
    if pLoc > 0:
      dateTimeValue = dateTimeValue[:pLoc]
    try:
      dateTimeValue = datetime.datetime.strptime(dateTimeValue, RFC2822_TIME_FORMAT)
      if dateHeaderConvertTimezone:
        dateTimeValue = dateTimeValue.astimezone(GC.Values[GC.TIMEZONE])
      return dateTimeValue.strftime(dateHeaderFormat)
    except ValueError:
      return headerValue

  def _showMessage(user, result, j, jcount, checkMax=True):
    if checkMax and parameters['maxToProcess'] and parameters['messagesProcessed'] == parameters['maxToProcess']:
      return
    status, messageLabels = _qualifyMessage(user, result)
    if not status:
      return
    printEntity([Ent.MESSAGE, result['id']], j, jcount)
    Ind.Increment()
    if show_snippet:
      printKeyValueList(['Snippet', dehtml(result['snippet']).replace('\n', ' ')])
    if show_all_headers:
      for header in result['payload'].get('headers', []):
        headerValue = _decodeHeader(header['value'])
        if dateHeaderFormat and header['name'].lower() in SMTP_DATE_HEADERS:
          headerValue = _convertDateTime(headerValue)
        printKeyValueList([header['name'], headerValue])
    else:
      for name in headersToShow:
        for header in result['payload'].get('headers', []):
          if name == header['name'].lower():
            headerValue = _decodeHeader(header['value'])
            if dateHeaderFormat and name in SMTP_DATE_HEADERS:
              headerValue = _convertDateTime(headerValue)
            printKeyValueList([SMTP_HEADERS_MAP.get(name, header['name']), headerValue])
    if show_date:
      printKeyValueList(['Date', formatLocalTimestamp(result['internalDate'])])
    if show_size:
      printKeyValueList(['SizeEstimate', result['sizeEstimate']])
    if show_labels:
      printKeyValueList(['Labels', delimiter.join(messageLabels)])
    if show_body:
      printKeyValueList(['Body', None])
      Ind.Increment()
      printKeyValueList([Ind.MultiLineText(_getMessageBody(result['payload']))])
      Ind.Decrement()
    if show_attachments or save_attachments or upload_attachments:
      _showSaveAttachments(result['id'], result['payload'], attachmentNamePattern, j, jcount)
    Ind.Decrement()
    if checkMax:
      parameters['messagesProcessed'] += 1

  def _getAttachments(messageId, payload, attachmentNamePattern, attachments):
    for part in payload.get('parts', []):
      if 'attachmentId' in part['body']:
        for header in part['headers']:
          if header['name'] in {'Content-Type', 'Content-Disposition'}:
            mg = ATTACHMENT_NAME_PATTERN.match(header['value'])
            if not mg:
              continue
            attachmentName = mg.group(1)
            charset = ''
            if part['mimeType'] == 'text/plain':
              mg = CHARSET_NAME_PATTERN.match(header['value'])
              if mg:
                charset = mg.group(1)
            if (not attachmentNamePattern) or attachmentNamePattern.match(attachmentName):
              attachments.append((attachmentName, part['mimeType'], part['body']['size'], charset))
            break
      else:
        _getAttachments(messageId, part, attachmentNamePattern, attachments)

  def _printMessage(user, result, checkMax=True):
    if checkMax and parameters['maxToProcess'] and parameters['messagesProcessed'] == parameters['maxToProcess']:
      return
    status, messageLabels = _qualifyMessage(user, result)
    if not status:
      return
    row = {'User': user, 'threadId': result['threadId'], 'id': result['id']}
    if show_snippet:
      row['Snippet'] = dehtml(result['snippet']).replace('\n', ' ')
    if show_all_headers:
      headerCounts = {}
      for header in result['payload'].get('headers', []):
        headerCounts.setdefault(header['name'], 0)
        headerValue = _decodeHeader(header['value'])
        if dateHeaderFormat and header['name'].lower() in SMTP_DATE_HEADERS:
          headerValue = _convertDateTime(headerValue)
        if headerCounts[header['name']] == 0:
          row[header['name']] = headerValue
        else:
          row[f'{header["name"]} {headerCounts[header["name"]]}'] = headerValue
        headerCounts[header['name']] += 1
    else:
      for name in headersToShow:
        j = 0
        for header in result['payload'].get('headers', []):
          if name == header['name'].lower():
            headerValue = _decodeHeader(header['value'])
            if dateHeaderFormat and name in SMTP_DATE_HEADERS:
              headerValue = _convertDateTime(headerValue)
            if j == 0:
              row[SMTP_HEADERS_MAP.get(name, header['name'])] = headerValue
            else:
              row[f'{SMTP_HEADERS_MAP.get(name, header["name"])} {j}'] = headerValue
            j += 1
    if show_date:
      row['Date'] = formatLocalTimestamp(result['internalDate'])
    if show_size:
      row['SizeEstimate'] = result['sizeEstimate']
    if show_labels:
      row['LabelsCount'] = len(messageLabels)
      row['Labels'] = delimiter.join(messageLabels)
    if show_body:
      if not convertCRNL:
        row['Body'] = _getMessageBody(result['payload'])
      else:
        row['Body'] = escapeCRsNLs(_getMessageBody(result['payload']))
    if show_attachments:
      attachments = []
      _getAttachments(result['id'], result['payload'], attachmentNamePattern, attachments)
      row['Attachments'] = len(attachments)
      for i, attachment in enumerate(attachments):
        row[f'Attachments{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{i}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}name'] = attachment[0]
        row[f'Attachments{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{i}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}mimeType'] = attachment[1]
        row[f'Attachments{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{i}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}size'] = attachment[2]
        row[f'Attachments{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{i}{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}charset'] = attachment[3]
    csvPF.WriteRowTitles(row)
    if checkMax:
      parameters['messagesProcessed'] += 1

  def _countMessageLabels(user, result):
    if senderMatchPattern:
      sender = _checkSenderMatch(result)
      if not sender:
        return False
    else:
      sender = user
    labelsMap = _initSenderLabelsMap(sender)
    labelIds = result.get('labelIds', [])
    if labelIds:
      for labelId in labelIds:
        if labelId in labelsMap:
          labelsMap[labelId]['count'] += 1
          labelsMap[labelId]['size'] += result['sizeEstimate']
        else:
          labelsMap[labelId] = {'name': labelId, 'count': 1, 'size': result['sizeEstimate'], 'type': LABEL_TYPE_USER,
                                'match': True if not labelMatchPattern else labelMatchPattern.match(labelId) is not None}
    elif not labelMatchPattern:
      labelsMap['*None*']['count'] += 1
      labelsMap['*None*']['size'] += result['sizeEstimate']

  def _countMessages(_, result):
    if senderMatchPattern and not _checkSenderMatchCount(result):
      return
    messageThreadCounts['messages'] += 1
    messageThreadCounts['size'] += result['sizeEstimate']

  def _showThread(user, result, j, jcount):
    if parameters['maxToProcess'] and parameters['messagesProcessed'] == parameters['maxToProcess']:
      return
    if senderMatchPattern:
      for message in result['messages']:
        if _checkSenderMatch(message):
          break
      else:
        return
    messageThreadCounts['threads'] += 1
    printEntity([Ent.THREAD, result['id']], j, jcount)
    Ind.Increment()
    if show_snippet and 'snippet' in result:
      printKeyValueList(['Snippet', dehtml(result['snippet']).replace('\n', ' ')])
    kcount = len(result['messages'])
    k = 0
    for message in result['messages']:
      k += 1
      _showMessage(user, message, k, kcount, False)
      if k == parameters['maxMessagesPerThread']:
        break
    Ind.Decrement()
    parameters['messagesProcessed'] += 1

  def _printThread(user, result):
    if parameters['maxToProcess'] and parameters['messagesProcessed'] == parameters['maxToProcess']:
      return
    if senderMatchPattern:
      for message in result['messages']:
        if _checkSenderMatch(message):
          break
      else:
        return
    k = 0
    for message in result['messages']:
      k += 1
      _printMessage(user, message, False)
      if k == parameters['maxMessagesPerThread']:
        break
    messageThreadCounts['threads'] += 1
    parameters['messagesProcessed'] += 1

  def _countThreadLabels(user, result):
    for message in result['messages']:
      _countMessageLabels(user, message)

  def _countThreads(_, result):
    if senderMatchPattern:
      for message in result['messages']:
        if _checkSenderMatchCount(message):
          messageThreadCounts['size'] += message['sizeEstimate']
          break
      else:
        return
    else:
      k = 0
      for message in result['messages']:
        k += 1
        messageThreadCounts['size'] += message['sizeEstimate']
        if k == parameters['maxMessagesPerThread']:
          break
    messageThreadCounts['threads'] += 1

  _GMAIL_ERROR_REASON_TO_MESSAGE_MAP = {GAPI.NOT_FOUND: Msg.DOES_NOT_EXIST, GAPI.INVALID_MESSAGE_ID: Msg.INVALID_MESSAGE_ID}

  def _handleGmailError(exception, ri):
    http_status, reason, message = checkGAPIError(exception)
    errMsg = getHTTPError(_GMAIL_ERROR_REASON_TO_MESSAGE_MAP, http_status, reason, message)
    if reason not in GAPI.DEFAULT_RETRY_REASONS:
      if not csvPF:
        printKeyValueListWithCount([Ent.Singular(entityType), ri[RI_ITEM], errMsg], int(ri[RI_J]), int(ri[RI_JCOUNT]))
        setSysExitRC(ACTION_FAILED_RC)
      else:
        entityActionFailedWarning([Ent.USER, ri[RI_ENTITY], entityType, ri[RI_ITEM]], errMsg, int(ri[RI_J]), int(ri[RI_JCOUNT]))
      return
    try:
      response = callGAPI(service, 'get',
                          throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.INVALID_MESSAGE_ID],
                          userId='me', id=ri[RI_ITEM], format=['metadata', 'full'][show_size or show_body or show_attachments or save_attachments or upload_attachments])
      if countsOnly:
        _callbacks['process'](ri[RI_ENTITY], response)
      else:
        if not csvPF:
          _callbacks['process'](ri[RI_ENTITY], response, int(ri[RI_J]), int(ri[RI_JCOUNT]))
        else:
          _callbacks['process'](ri[RI_ENTITY], response)
    except GAPI.notFound:
      entityActionFailedWarning([Ent.USER, ri[RI_ENTITY], entityType, ri[RI_ITEM]], Msg.DOES_NOT_EXIST, int(ri[RI_J]), int(ri[RI_JCOUNT]))
    except GAPI.invalidMessageId:
      entityActionFailedWarning([Ent.USER, ri[RI_ENTITY], entityType, ri[RI_ITEM]], Msg.INVALID_MESSAGE_ID, int(ri[RI_J]), int(ri[RI_JCOUNT]))
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(ri[RI_ENTITY], int(ri[RI_I]), int(ri[RI_COUNT]))

  def _callbackShow(request_id, response, exception):
    ri = request_id.splitlines()
    if exception is None:
      _callbacks['process'](ri[RI_ENTITY], response, int(ri[RI_J]), int(ri[RI_JCOUNT]))
    else:
      _handleGmailError(exception, ri)

  def _callbackPrint(request_id, response, exception):
    ri = request_id.splitlines()
    if exception is None:
      _callbacks['process'](ri[RI_ENTITY], response)
    else:
      _handleGmailError(exception, ri)

  def _callbackCountLabels(request_id, response, exception):
    ri = request_id.splitlines()
    if exception is None:
      _callbacks['process'](ri[RI_ENTITY], response)
    else:
      _handleGmailError(exception, ri)

  def _batchPrintShowMessagesThreads(service, user, jcount, messageIds):
    svcargs = dict([('userId', 'me'), ('id', None), ('format', ['metadata', 'full'][show_body or show_attachments or save_attachments or upload_attachments])]+GM.Globals[GM.EXTRA_ARGS_LIST])
    if countsOnly:
      if show_labels:
        if not senderMatchPattern:
          svcargs['fields'] = 'labelIds,sizeEstimate' if entityType == Ent.MESSAGE else 'messages(labelIds,sizeEstimate)'
        else:
          svcargs['fields'] = 'labelIds,sizeEstimate,payload' if entityType == Ent.MESSAGE else 'messages(labelIds,sizeEstimate,payload)'
      else:
        if not senderMatchPattern:
          svcargs['fields'] = 'sizeEstimate' if entityType == Ent.MESSAGE else 'messages(sizeEstimate)'
        else:
          svcargs['fields'] = 'sizeEstimate,payload' if entityType == Ent.MESSAGE else 'messages(sizeEstimate,payload)'
    method = getattr(service, 'get')
    dbatch = gmail.new_batch_http_request(callback=_callbacks['batch'])
    bcount = 0
    j = 0
    for messageId in messageIds:
      j += 1
      svcparms = svcargs.copy()
      svcparms['id'] = messageId
      dbatch.add(method(**svcparms), request_id=batchRequestID(user, 0, 0, j, jcount, svcparms['id']))
      bcount += 1
      if not labelMatchPattern and parameters['maxToProcess'] and j == parameters['maxToProcess']:
        break
      if bcount == GC.Values[GC.EMAIL_BATCH_SIZE]:
        executeBatch(dbatch)
        dbatch = gmail.new_batch_http_request(callback=_callbacks['batch'])
        bcount = 0
        if labelMatchPattern and parameters['messagesProcessed'] == parameters['maxToProcess']:
          break
    if bcount > 0:
      dbatch.execute()

  parameters = _initMessageThreadParameters(entityType, True, 0)
  convertCRNL = GC.Values[GC.CSV_OUTPUT_CONVERT_CR_NL]
  delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
  countsOnly = positiveCountsOnly = includeSpamTrash = onlyUser = overwrite = save_attachments = upload_attachments = False
  show_all_headers = show_attachments = show_body = show_date = show_html = show_labels = show_size = show_snippet = False
  noshow_text_plain = False
  attachmentNamePattern = None
  targetFolderPattern = GC.Values[GC.DRIVE_DIR]
  defaultHeaders = ['Date', 'Subject', 'From', 'Reply-To', 'To', 'Delivered-To', 'Content-Type', 'Message-ID']
  headersToShow = [header.lower() for header in defaultHeaders]
  csvPF = CSVPrintFile() if Act.csvFormat() else None
  showMode = Act.Get() == Act.SHOW
  dateHeaderFormat = ''
  dateHeaderConvertTimezone = False
  uploadAttachmentBody = {}
  addCSVData = {}
  parentParms = initDriveFileAttributes()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif _getMessageSelectParameters(myarg, parameters):
      pass
    elif myarg == 'headers':
      headersToShow = getString(Cmd.OB_STRING, minLen=0).lower().replace(',', ' ').split()
      show_all_headers = headersToShow and headersToShow[0] == 'all'
    elif not showMode and myarg in {'convertcrnl', 'converttextnl', 'convertbodynl'}:
      convertCRNL = True
    elif myarg == 'delimiter':
      delimiter = getCharacter()
    elif myarg == 'showdate':
      show_date = True
    elif myarg == 'showbody':
      show_body = True
    elif myarg == 'showhtml':
      show_html = True
    elif myarg == 'showlabels':
      show_labels = True
    elif myarg == 'showsize':
      show_size = True
    elif myarg == 'showsnippet':
      show_snippet = True
    elif myarg == 'showattachments':
      show_attachments = True
    elif myarg == 'noshowtextplain':
      noshow_text_plain = True
    elif myarg == 'attachmentnamepattern':
      attachmentNamePattern = getREPattern(re.IGNORECASE)
    elif showMode and myarg == 'saveattachments':
      save_attachments = True
    elif showMode and myarg == 'targetfolder':
      targetFolderPattern = os.path.expanduser(getString(Cmd.OB_FILE_PATH))
    elif showMode and myarg == 'overwrite':
      overwrite = getBoolean()
    elif showMode and myarg == 'uploadattachments':
      upload_attachments = True
    elif showMode and getDriveFileParentAttribute(myarg, parentParms):
      pass
    elif myarg == 'includespamtrash':
      includeSpamTrash = True
    elif myarg == 'countsonly':
      countsOnly = True
    elif myarg == 'positivecountsonly':
      countsOnly = positiveCountsOnly = True
    elif myarg in {'onlyuser', 'useronly'}:
      onlyUser = getBoolean()
    elif myarg == 'dateheaderformat':
      dateHeaderFormat = getString(Cmd.OB_STRING, minLen=0)
      if dateHeaderFormat == 'iso':
        dateHeaderFormat = IS08601_TIME_FORMAT
      elif dateHeaderFormat == 'rfc2822':
        dateHeaderFormat = RFC2822_TIME_FORMAT
    elif myarg == 'dateheaderconverttimezone':
      dateHeaderConvertTimezone = getBoolean()
      if not dateHeaderFormat:
        dateHeaderFormat = RFC2822_TIME_FORMAT
    elif csvPF and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    else:
      unknownArgumentExit()
  labelMatchPattern = parameters['labelMatchPattern']
  senderMatchPattern = parameters['senderMatchPattern']
  if senderMatchPattern and not show_all_headers and 'sender' not in headersToShow:
    headersToShow.append('sender')
  _finalizeMessageSelectParameters(parameters, False)
  if csvPF:
    if countsOnly:
      if show_labels:
        if not senderMatchPattern:
          sortTitles = ['User']
        else:
          sortTitles = ['User', 'Sender']
        csvPF.SetIndexedTitles(['Labels'])
        _callbacks = {'batch': _callbackCountLabels, 'process': _countMessageLabels if entityType == Ent.MESSAGE else _countThreadLabels}
      else:
        if not senderMatchPattern:
          sortTitles = ['User', parameters['listType']]
        else:
          sortTitles = ['User', 'Sender', parameters['listType']]
        _callbacks = {'batch': _callbackCountLabels, 'process': _countMessages if entityType == Ent.MESSAGE else _countThreads}
      if show_size:
        sortTitles.append('size')
      if addCSVData:
        sortTitles.extend(sorted(addCSVData.keys()))
      csvPF.SetTitles(sortTitles)
    else:
      sortTitles = ['User', 'threadId', 'id']
      sortTitles.extend(defaultHeaders)
      if show_size:
        sortTitles.append('SizeEstimate')
      if show_labels:
        sortTitles.extend(['LabelsCount', 'Labels'])
      if show_snippet:
        sortTitles.append('Snippet')
      if show_body:
        sortTitles.append('Body')
      if addCSVData:
        sortTitles.extend(sorted(addCSVData.keys()))
      _callbacks = {'batch': _callbackPrint, 'process': _printMessage if entityType == Ent.MESSAGE else _printThread}
    csvPF.SetTitles(sortTitles)
    csvPF.SetSortTitles(sortTitles)
  else:
    if countsOnly:
      if show_labels:
        _callbacks = {'batch': _callbackCountLabels, 'process': _countMessageLabels if entityType == Ent.MESSAGE else _countThreadLabels}
      else:
        _callbacks = {'batch': _callbackCountLabels, 'process': _countMessages if entityType == Ent.MESSAGE else _countThreads}
    else:
      _callbacks = {'batch': _callbackShow, 'process': _showMessage if entityType == Ent.MESSAGE else _showThread}
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail, messageIds = _validateUserGetMessageIds(user, i, count, parameters['messageEntity'])
    if not gmail:
      continue
    service = gmail.users().messages() if entityType == Ent.MESSAGE else gmail.users().threads()
    if upload_attachments:
      _, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
      if not drive:
        continue
      if not _getDriveFileParentInfo(drive, user, i, count, uploadAttachmentBody, parentParms):
        continue
    if show_labels or labelMatchPattern:
      labels = _getUserGmailLabels(gmail, user, i, count, 'labels(id,name,type)')
      if not labels:
        continue
      senderLabelsMaps = {}
      if not senderMatchPattern:
        _initSenderLabelsMap(user)
    messageThreadCounts = {'User': user, parameters['listType']: 0, 'size': 0}
    if addCSVData:
      messageThreadCounts.update(addCSVData)
    senderCounts = {}
    if save_attachments:
      _, userName, _ = splitEmailAddressOrUID(user)
      targetFolder = _substituteForUser(targetFolderPattern, user, userName)
      if not os.path.isdir(targetFolder):
        os.makedirs(targetFolder)
    try:
      if parameters['messageEntity'] is None:
        printGettingAllEntityItemsForWhom(entityType, user, i, count)
        listResult = callGAPIpages(service, 'list', parameters['listType'],
                                   pageMessage=getPageMessageForWhom(), maxItems=parameters['maxItems'],
                                   throwReasons=GAPI.GMAIL_THROW_REASONS+GAPI.GMAIL_LIST_THROW_REASONS,
                                   userId='me', q=parameters['query'], fields=parameters['fields'], includeSpamTrash=includeSpamTrash,
                                   maxResults=GC.Values[GC.MESSAGE_MAX_RESULTS])
        messageIds = [message['id'] for message in listResult]
      else:
        # Need to get authorization set up for batch
        callGAPI(gmail.users(), 'getProfile',
                 throwReasons=GAPI.GMAIL_THROW_REASONS+GAPI.GMAIL_LIST_THROW_REASONS,
                 userId='me', fields='')
    except (GAPI.failedPrecondition, GAPI.permissionDenied, GAPI.invalid, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
      continue
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
      continue
    jcount = len(messageIds)
    if jcount == 0:
      setSysExitRC(NO_ENTITIES_FOUND_RC)
    if countsOnly and not show_labels and not senderMatchPattern and not show_size:
      if not positiveCountsOnly or jcount > 0:
        if not csvPF:
          printEntityKVList([Ent.USER, user], [parameters['listType'], jcount], i, count)
        else:
          row = {'User': user, parameters['listType']: jcount}
          if addCSVData:
            row.update(addCSVData)
          csvPF.WriteRow(row)
      continue
    if not csvPF and not countsOnly:
      if (parameters['messageEntity'] is not None or
          ((parameters['maxToProcess'] == 0 or jcount <= parameters['maxToProcess']) and
           (not labelMatchPattern and not senderMatchPattern))):
        entityPerformActionNumItems([Ent.USER, user], jcount, entityType, i, count)
      elif not labelMatchPattern and not senderMatchPattern:
        entityPerformActionNumItemsModifier([Ent.USER, user], parameters['maxToProcess'], entityType, f'of {jcount} Total {Ent.Plural(entityType)}', i, count)
      else:
        entityPerformActionModifierNumItemsModifier([Ent.USER, user], Msg.MAXIMUM_OF, parameters['maxToProcess'] or jcount, entityType,
                                                    f'of {jcount} Total {Ent.Plural(entityType)}', i, count)
    if parameters['messageEntity'] is None and not labelMatchPattern and parameters['maxToProcess'] and (jcount > parameters['maxToProcess']):
      jcount = parameters['maxToProcess']
    parameters['messagesProcessed'] = 0
    if not csvPF:
      Ind.Increment()
      _batchPrintShowMessagesThreads(service, user, jcount, messageIds)
      Ind.Decrement()
    else:
      _batchPrintShowMessagesThreads(service, user, jcount, messageIds)
    if countsOnly:
      if show_labels:
        if onlyUser or positiveCountsOnly or labelMatchPattern:
          for sender in senderLabelsMaps:
            userLabelsMap = {}
            for labelId, label in iter(senderLabelsMaps[sender].items()):
              if (label['match'] and
                  (not onlyUser or label['type'] != LABEL_TYPE_SYSTEM) and
                  (not positiveCountsOnly or label['count'] > 0)):
                userLabelsMap[labelId] = label
            senderLabelsMaps[sender] = userLabelsMap
        if not csvPF:
          for sender, labelsMap in sorted(iter(senderLabelsMaps.items())):
            jcount = len(labelsMap)
            kvlist = [Ent.USER, user]
            if senderMatchPattern:
              kvlist.extend([Ent.SENDER, sender])
            entityPerformActionNumItems(kvlist, jcount, Ent.LABEL, i, count)
            Ind.Increment()
            j = 0
            for label in sorted(iter(labelsMap.values()), key=lambda k: k['name']):
              j += 1
              if not show_size:
                printEntityKVList([Ent.LABEL, label['name']], ['Count', label['count'], 'Type', label['type']], j, jcount)
              else:
                printEntityKVList([Ent.LABEL, label['name']], ['Count', label['count'], 'Size', label['size'], 'Type', label['type']], j, jcount)
            Ind.Decrement()
        else:
          for sender, labelsMap in sorted(iter(senderLabelsMaps.items())):
            row = {'User': user}
            if senderMatchPattern:
              row['Sender'] = sender
            if not show_size:
              for label in labelsMap.values():
                label.pop('size', None)
            if addCSVData:
              row.update(addCSVData)
            csvPF.WriteRowTitles(flattenJSON({'Labels': sorted(iter(labelsMap.values()), key=lambda k: k['name'])}, flattened=row))
      elif not senderMatchPattern:
        v = messageThreadCounts[parameters['listType']]
        if not positiveCountsOnly or v > 0:
          if not csvPF:
            if not show_size:
              printEntityKVList([Ent.USER, user], [parameters['listType'], v], i, count)
            else:
              printEntityKVList([Ent.USER, user], [parameters['listType'], v, 'size', messageThreadCounts['size']], i, count)
          else:
            if not show_size:
              messageThreadCounts.pop('size', None)
            csvPF.WriteRow(messageThreadCounts)
      else:
        if not show_size:
          if not csvPF:
            for k, v in sorted(iter(senderCounts.items())):
              if not positiveCountsOnly or v['count'] > 0:
                printEntityKVList([Ent.USER, user, Ent.SENDER, k], [parameters['listType'], v['count']], i, count)
          else:
            for k, v in sorted(iter(senderCounts.items())):
              if not positiveCountsOnly or v['count'] > 0:
                row = {'User': user, 'Sender': k, parameters['listType']: v['count']}
                if addCSVData:
                  row.update(addCSVData)
                csvPF.WriteRow(row)
        else:
          if not csvPF:
            for k, v in sorted(iter(senderCounts.items())):
              if not positiveCountsOnly or v['count'] > 0:
                printEntityKVList([Ent.USER, user, Ent.SENDER, k], [parameters['listType'], v['count'], 'size', v['size']], i, count)
          else:
            for k, v in sorted(iter(senderCounts.items())):
              if not positiveCountsOnly or v['count'] > 0:
                row = {'User': user, 'Sender': k, parameters['listType']: v['count'], 'size': v['size']}
                if addCSVData:
                  row.update(addCSVData)
                csvPF.WriteRow(row)
  if csvPF:
    if not countsOnly:
      csvPF.RemoveTitles(['SizeEstimate', 'LabelsCount', 'Labels', 'Snippet', 'Body'])
      if show_size:
        csvPF.AddTitle('SizeEstimate')
      if show_labels:
        csvPF.AddTitles(['LabelsCount', 'Labels'])
      if show_snippet:
        csvPF.AddTitle('Snippet')
      if show_body:
        csvPF.AddTitle('Body')
      csvPF.SetSortAllTitles()
      csvPF.writeCSVfile('Messages')
    else:
      csvPF.writeCSVfile('Message Counts' if not show_labels else 'Message Label Counts')

# gam <UserTypeEntity> print message|messages [todrive <ToDriveAttribute>*]
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])* [quick|notquick] [max_to_print <Number>] [includespamtrash])|(ids <MessageIDEntity>)
#	[labelmatchpattern <REMatchPattern>] [sendermatchpattern <REMatchPattern>]
#	[headers all|<SMTPHeaderList>] [dateheaderformat iso|rfc2822|<String>] [dateheaderconverttimezone [<Boolean>]]
#	[showlabels] [showbody] [showhtml] [showdate] [showsize] [showsnippet]
#	[convertcrnl] [delimiter <Character>]
#	[countsonly|positivecountsonly] [useronly]
#	[[attachmentnamepattern <REMatchPattern>]
#	    [showattachments [noshowtextplain]]]
#	(addcsvdata <FieldName> <String>)*
# gam <UserTypeEntity> show message|messages
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])* [quick|notquick] [max_to_show <Number>] [includespamtrash])|(ids <MessageIDEntity>)
#	[labelmatchpattern <REMatchPattern>] [sendermatchpattern <REMatchPattern>]
#	[headers all|<SMTPHeaderList>] [dateheaderformat iso|rfc2822|<String>] [dateheaderconverttimezone [<Boolean>]]
#	[showlabels] [showbody] [showhtml] [showdate] [showsize] [showsnippet]
#	[countsonly|positivecountsonly] [useronly]
#	[[attachmentnamepattern <REMatchPattern>]
#	    [showattachments [noshowtextplain]]
#	    [saveattachments [targetfolder <FilePath>] [overwrite [<Boolean>]]]
#	    [uploadattachments [<DriveFileParentAttribute>]]]
def printShowMessages(users):
  printShowMessagesThreads(users, Ent.MESSAGE)

# gam <UserTypeEntity> print thread|threads [todrive <ToDriveAttribute>*]
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])* [quick|notquick] [max_to_print <Number>] [includespamtrash])|(ids <ThreadIDEntity>)
#	[labelmatchpattern <REMatchPattern>]
#	[headers all|<SMTPHeaderList>] [dateheaderformat iso|rfc2822|<String>] [dateheaderconverttimezone [<Boolean>]]
#	[showlabels] [showbody] [showhtml] [showdate] [showsize] [showsnippet]
#	[convertcrnl] [delimiter <Character>]
#	[countsonly|positivecountsonly] [useronly]
#	[[attachmentnamepattern <REMatchPattern>]
#	    [showattachments [noshowtextplain]]]
#	(addcsvdata <FieldName> <String>)*
# gam <UserTypeEntity> show thread|threads
#	(((query <QueryGmail> [querytime<String> <Date>]*) (matchlabel <LabelName>) [or|and])* [quick|notquick] [max_to_show <Number>] [includespamtrash])|(ids <ThreadIDEntity>)
#	[labelmatchpattern <REMatchPattern>]
#	[headers all|<SMTPHeaderList>] [dateheaderformat iso|rfc2822|<String>] [dateheaderconverttimezone [<Boolean>]]
#	[showlabels] [showbody] [showhtml] [showdate] [showsize] [showsnippet]
#	[countsonly|positivecountsonly] [useronly]
#	[[attachmentnamepattern <REMatchPattern>]
#	    [showattachments [noshowtextplain]]
#	    [saveattachments [targetfolder <FilePath>] [overwrite [<Boolean>]]]
#	    [uploadattachments [<DriveFileParentAttribute>]]]
def printShowThreads(users):
  printShowMessagesThreads(users, Ent.THREAD)

# gam <UserTypeEntity> create delegate|delegates [convertalias] <UserEntity>
# gam <UserTypeEntity> delete delegate|delegates [convertalias] <UserEntity>
def processDelegates(users):
  cd = buildGAPIObject(API.DIRECTORY)
  function = 'delete' if Act.Get() == Act.DELETE else 'create'
  aliasAllowed = not checkArgumentPresent(['convertalias'])
  delegateEntity = getUserObjectEntity(Cmd.OB_USER_ENTITY, Ent.DELEGATE)
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail, delegates, jcount = _validateUserGetObjectList(user, i, count, delegateEntity)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for delegate in delegates:
      j += 1
      delegateEmail = convertUIDtoEmailAddress(delegate, cd=cd, emailTypes=['user', 'group'], aliasAllowed=aliasAllowed)
      try:
        if function == 'create':
          callGAPI(gmail.users().settings().delegates(), function,
                   throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.ALREADY_EXISTS, GAPI.FAILED_PRECONDITION, GAPI.INVALID,
                                                          GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                   userId='me', body={'delegateEmail': delegateEmail})
        else:
          callGAPI(gmail.users().settings().delegates(), function,
                   throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.INVALID_INPUT, GAPI.PERMISSION_DENIED],
                   userId='me', delegateEmail=delegateEmail)
        entityActionPerformed([Ent.USER, user, Ent.DELEGATE, delegateEmail], j, jcount)
      except (GAPI.alreadyExists, GAPI.failedPrecondition, GAPI.invalid,
              GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.DELEGATE, delegateEmail], str(e), j, jcount)
      except GAPI.serviceNotAvailable:
        userGmailServiceNotEnabledWarning(user, i, count)
    Ind.Decrement()

# gam <UserTypeEntity> delegate to [convertalias] <UserEntity>
def delegateTo(users):
  checkArgumentPresent('to', required=True)
  processDelegates(users)

# gam <UserTypeEntity> update delegate|delegates [convertalias] [<UserEntity>]
def updateDelegates(users):
  cd = buildGAPIObject(API.DIRECTORY)
  aliasAllowed = not checkArgumentPresent(['convertalias'])
  if Cmd.ArgumentsRemaining():
    delegateEntity = getUserObjectEntity(Cmd.OB_USER_ENTITY, Ent.DELEGATE)
    checkForExtraneousArguments()
  else:
    delegateEntity = None
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    if delegateEntity is None:
      user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
      if not gmail:
        continue
      try:
        result = callGAPI(gmail.users().settings().delegates(), 'list',
                          throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.PERMISSION_DENIED],
                          userId='me')
      except GAPI.permissionDenied as e:
        entityActionFailedWarning([Ent.USER, user, Ent.DELEGATE, None], str(e), i, count)
        continue
      except GAPI.serviceNotAvailable:
        userGmailServiceNotEnabledWarning(user, i, count)
        continue
      delegates = result.get('delegates', []) if result is not None else []
      jcount = len(delegates)
      entityPerformActionModifierNumItems([Ent.USER, user], Msg.MAXIMUM_OF, jcount, Ent.DELEGATE, i, count)
    else:
      user, gmail, delegates, jcount = _validateUserGetObjectList(user, i, count, delegateEntity)
      if jcount == 0:
        continue
    Ind.Increment()
    j = 0
    for delegate in delegates:
      j += 1
      if delegateEntity is not None or delegate['verificationStatus'] == 'accepted':
        delegateEmail = delegate['delegateEmail'] if delegateEntity is None else convertUIDtoEmailAddress(delegate, cd=cd, aliasAllowed=aliasAllowed)
        try:
          callGAPI(gmail.users().settings().delegates(), 'create',
                   throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.ALREADY_EXISTS, GAPI.FAILED_PRECONDITION,
                                                          GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                   userId='me', body={'delegateEmail': delegateEmail, 'verificationStatus': 'accepted'})
          entityActionPerformed([Ent.USER, user, Ent.DELEGATE, delegateEmail], j, jcount)
        except GAPI.alreadyExists:
          entityActionPerformed([Ent.USER, user, Ent.DELEGATE, delegateEmail], j, jcount)
        except (GAPI.failedPrecondition, GAPI.notFound, GAPI.invalidArgument, GAPI.permissionDenied) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.DELEGATE, delegateEmail], str(e), j, jcount)
        except GAPI.serviceNotAvailable:
          userGmailServiceNotEnabledWarning(user, i, count)
    Ind.Decrement()

# gam <UserTypeEntity> print delegates|delegate [todrive <ToDriveAttribute>*] [shownames]
# gam <UserTypeEntity> show delegates|delegate [shownames] [csv]
def printShowDelegates(users):
  titlesList = ['User', 'delegateAddress', 'delegationStatus']
  csvPF = CSVPrintFile() if Act.csvFormat() else None
  cd = None
  csvStyle = showNames = False
  delegateNames = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif not csvPF and myarg == 'csv':
      csvStyle = True
    elif myarg == 'shownames':
      cd = buildGAPIObject(API.DIRECTORY)
      titlesList = ['User', 'delegateName', 'delegateAddress', 'delegationStatus']
      showNames = True
    else:
      unknownArgumentExit()
  if csvPF:
    csvPF.AddTitles(titlesList)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    if csvPF:
      printGettingAllEntityItemsForWhom(Ent.DELEGATE, user, i, count)
    try:
      result = callGAPI(gmail.users().settings().delegates(), 'list',
                        throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.PERMISSION_DENIED, GAPI.FAILED_PRECONDITION],
                        userId='me')
      delegates = result.get('delegates', []) if result is not None else []
      jcount = len(delegates)
      if not csvPF:
        if not csvStyle:
          entityPerformActionNumItems([Ent.USER, user], jcount, Ent.DELEGATE, i, count)
          Ind.Increment()
          j = 0
          for delegate in delegates:
            j += 1
            status = delegate['verificationStatus']
            delegateEmail = delegate['delegateEmail']
            if cd:
              printEntity([Ent.DELEGATE, _getDelegateName(cd, delegateEmail, delegateNames)], j, jcount)
              Ind.Increment()
              printKeyValueList(['Status', status])
              printKeyValueList(['Delegate Email', delegateEmail])
              Ind.Decrement()
            else:
              printEntity([Ent.DELEGATE, delegateEmail], j, jcount)
              Ind.Increment()
              printKeyValueList(['Status', status])
              Ind.Decrement()
          Ind.Decrement()
        else:
          j = 0
          for delegate in delegates:
            j += 1
            status = delegate['verificationStatus']
            delegateEmail = delegate['delegateEmail']
            if cd:
              writeStdout(f'{user},{_getDelegateName(cd, delegateEmail, delegateNames)},{status},{delegateEmail}\n')
            else:
              writeStdout(f'{user},{status},{delegateEmail}\n')
      else:
        if delegates:
          if showNames:
            for delegate in delegates:
              csvPF.WriteRow({'User': user, 'delegateName': _getDelegateName(cd, delegate['delegateEmail'], delegateNames),
                              'delegateAddress': delegate['delegateEmail'], 'delegationStatus': delegate['verificationStatus']})
          else:
            for delegate in delegates:
              csvPF.WriteRow({'User': user, 'delegateAddress': delegate['delegateEmail'],
                              'delegationStatus': delegate['verificationStatus']})
        elif GC.Values[GC.CSV_OUTPUT_USERS_AUDIT]:
          csvPF.WriteRowNoFilter({'User': user})
    except (GAPI.permissionDenied, GAPI.failedPrecondition) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.DELEGATE, None], str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
  if csvPF:
    csvPF.writeCSVfile('Delegates')

FILTER_ADD_LABEL_TO_ARGUMENT_MAP = {
  'IMPORTANT': 'important',
  'STARRED': 'star',
  'TRASH': 'trash',
  }

FILTER_REMOVE_LABEL_TO_ARGUMENT_MAP = {
  'IMPORTANT': 'notimportant',
  'INBOX': 'archive',
  'SPAM': 'neverspam',
  'UNREAD': 'markread',
  }

def _printFilter(user, userFilter, labels):
  row = {'User': user, 'id': userFilter['id']}
  if 'criteria' in userFilter:
    for item in userFilter['criteria']:
      if item in {'hasAttachment', 'excludeChats'}:
        row[item] = item
      elif item == 'size':
        row[item] = f'size {userFilter["criteria"]["sizeComparison"]} {formatMaxMessageBytes(userFilter["criteria"][item], ONE_KILO_10_BYTES, ONE_MEGA_10_BYTES)}'
      elif item == 'sizeComparison':
        pass
      else:
        row[item] = f'{item} {userFilter["criteria"][item]}'
  else:
    row['error'] = 'NoCriteria'
  if 'action' in userFilter:
    for labelId in userFilter['action'].get('addLabelIds', []):
      if labelId in FILTER_ADD_LABEL_TO_ARGUMENT_MAP:
        row[FILTER_ADD_LABEL_TO_ARGUMENT_MAP[labelId]] = FILTER_ADD_LABEL_TO_ARGUMENT_MAP[labelId]
      else:
        row['label'] = f'label {_getLabelName(labels, labelId)}'
    for labelId in userFilter['action'].get('removeLabelIds', []):
      if labelId in FILTER_REMOVE_LABEL_TO_ARGUMENT_MAP:
        row[FILTER_REMOVE_LABEL_TO_ARGUMENT_MAP[labelId]] = FILTER_REMOVE_LABEL_TO_ARGUMENT_MAP[labelId]
    if userFilter['action'].get('forward'):
      row['forward'] = f'forward {userFilter["action"]["forward"]}'
  else:
    row['error'] = 'NoActions'
  return row

def _mapFilterLabelIdsToNames(userFilter, labels):
  # Map user label IDs to label names
  if 'action' in userFilter:
    for field in ['addLabelIds', 'removeLabelIds']:
      if field in userFilter['action']:
        for i, labelId in enumerate(userFilter['action'][field]):
          if labelId not in GMAIL_SYSTEM_LABELS and labelId not in GMAIL_CATEGORY_LABELS:
            userFilter['action'][field][i] = _getLabelName(labels, labelId)

def _showFilter(userFilter, j, jcount, labels, FJQC=None):
  if FJQC is not None and FJQC.formatJSON:
    if labels['labels']:
      _mapFilterLabelIdsToNames(userFilter, labels)
    printLine(json.dumps(cleanJSON(userFilter), ensure_ascii=False, sort_keys=False))
    return
  printEntity([Ent.FILTER, userFilter['id']], j, jcount)
  Ind.Increment()
  printEntitiesCount(Ent.CRITERIA, None)
  Ind.Increment()
  if 'criteria' in userFilter:
    for item in sorted(userFilter['criteria']):
      if item in {'hasAttachment', 'excludeChats'}:
        printKeyValueList([item])
      elif item == 'size':
        printKeyValueList([f'{item} {userFilter["criteria"]["sizeComparison"]} {formatMaxMessageBytes(userFilter["criteria"][item], ONE_KILO_10_BYTES, ONE_MEGA_10_BYTES)}'])
      elif item == 'sizeComparison':
        pass
      else:
        printKeyValueList([f'{item} "{userFilter["criteria"][item]}"'])
  else:
    printKeyValueList([ERROR, Msg.NO_FILTER_CRITERIA.format(Ent.Singular(Ent.FILTER))])
  Ind.Decrement()
  printEntitiesCount(Ent.ACTION, None)
  Ind.Increment()
  if 'action' in userFilter:
    for labelId in sorted(userFilter['action'].get('addLabelIds', [])):
      if labelId in FILTER_ADD_LABEL_TO_ARGUMENT_MAP:
        printKeyValueList([FILTER_ADD_LABEL_TO_ARGUMENT_MAP[labelId]])
      else:
        printKeyValueList([f'label "{_getLabelName(labels, labelId)}"'])
    for labelId in sorted(userFilter['action'].get('removeLabelIds', [])):
      if labelId in FILTER_REMOVE_LABEL_TO_ARGUMENT_MAP:
        printKeyValueList([FILTER_REMOVE_LABEL_TO_ARGUMENT_MAP[labelId]])
    Ind.Decrement()
    if userFilter['action'].get('forward'):
      printEntity([Ent.FORWARDING_ADDRESS, userFilter['action']['forward']])
  else:
    printKeyValueList([ERROR, Msg.NO_FILTER_ACTIONS.format(Ent.Singular(Ent.FILTER))])
    Ind.Decrement()
  Ind.Decrement()
#
FILTER_CATEGORY_CHOICE_MAP = {
  'personal': 'CATEGORY_PERSONAL',
  'social': 'CATEGORY_SOCIAL',
  'promotions': 'CATEGORY_PROMOTIONS',
  'updates': 'CATEGORY_UPDATES',
  'forums': 'CATEGORY_FORUMS',
  }
FILTER_CRITERIA_CHOICE_MAP = {
  'excludechats': 'excludeChats',
  'from': 'from',
  'hasattachment': 'hasAttachment',
  'haswords': 'query',
  'musthaveattachment': 'hasAttachment',
  'negatedquery': 'negatedQuery',
  'nowords': 'negatedQuery',
  'query': 'query',
  'size': 'size',
  'subject': 'subject',
  'to': 'to',
  }
FILTER_ADD_LABEL_ACTIONS = ['important', 'star', 'trash']
FILTER_REMOVE_LABEL_ACTIONS = ['markread', 'notimportant', 'archive', 'neverspam']
FILTER_ACTION_CHOICES = FILTER_ADD_LABEL_ACTIONS+FILTER_REMOVE_LABEL_ACTIONS+['category', 'forward', 'label']
FILTER_ACTION_LABEL_MAP = {
  'archive': 'INBOX',
  'important': 'IMPORTANT',
  'markread': 'UNREAD',
  'neverspam': 'SPAM',
  'notimportant': 'IMPORTANT',
  'star': 'STARRED',
  'trash': 'TRASH',
  }

# gam <UserTypeEntity> [create]
#	(filter <FilterCriteria>+ <FilterAction>+) |
#	((json [charset <Charset>] <String>) |
#	 (json file <FileName> [charset <Charset>]))
#	[buildpath [<Boolean>]]
def createFilter(users):
  body = {'criteria': {}, 'action': {'addLabelIds': [], 'removeLabelIds': []}}
  buildPath = False
  jsonData = None
  categorySpecified = labelSpecified = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if jsonData is None and myarg in FILTER_CRITERIA_CHOICE_MAP:
      myarg = FILTER_CRITERIA_CHOICE_MAP[myarg]
      if myarg in {'from', 'to', 'subject', 'query', 'negatedQuery'}:
        body['criteria'][myarg] = getString(Cmd.OB_STRING)
      elif myarg in {'hasAttachment', 'excludeChats'}:
        body['criteria'][myarg] = True
      elif myarg == 'size':
        body['criteria']['sizeComparison'] = getChoice(['larger', 'smaller'])
        body['criteria'][myarg] = getMaxMessageBytes(ONE_KILO_10_BYTES, ONE_MEGA_10_BYTES)
    elif jsonData is None and myarg in FILTER_ACTION_CHOICES:
      if myarg in FILTER_ADD_LABEL_ACTIONS:
        myarg = FILTER_ACTION_LABEL_MAP[myarg]
        body['action']['addLabelIds'].append(myarg)
        if (myarg == 'IMPORTANT') and (myarg in body['action']['removeLabelIds']):
          body['action']['removeLabelIds'].remove(myarg)
      elif myarg in FILTER_REMOVE_LABEL_ACTIONS:
        myarg = FILTER_ACTION_LABEL_MAP[myarg]
        body['action']['removeLabelIds'].append(myarg)
        if (myarg == 'IMPORTANT') and (myarg in body['action']['addLabelIds']):
          body['action']['addLabelIds'].remove(myarg)
      elif myarg == 'forward':
        body['action']['forward'] = getEmailAddress(noUid=True)
      elif myarg == 'label':
        label = getString(Cmd.OB_LABEL_NAME)
        labelUpper = label.upper()
        if labelUpper not in GMAIL_SYSTEM_LABELS:
          if labelUpper not in GMAIL_CATEGORY_LABELS:
            if not labelSpecified:
              body['action']['addLabelIds'].append(label)
              labelSpecified = True
            else:
              Cmd.Backup()
              usageErrorExit(Msg.FILTER_CAN_ONLY_CONTAIN_ONE_USER_LABEL)
          elif not categorySpecified:
            body['action']['addLabelIds'].append(labelUpper)
            categorySpecified = True
          else:
            Cmd.Backup()
            usageErrorExit(Msg.FILTER_CAN_ONLY_CONTAIN_ONE_CATEGORY_LABEL)
        else:
          body['action']['addLabelIds'].append(labelUpper)
          if (labelUpper == 'IMPORTANT') and (labelUpper in body['action']['removeLabelIds']):
            body['action']['removeLabelIds'].remove(labelUpper)
      elif myarg == 'category':
        if not categorySpecified:
          body['action']['addLabelIds'].append(getChoice(FILTER_CATEGORY_CHOICE_MAP, mapChoice=True))
          categorySpecified = True
        else:
          Cmd.Backup()
          usageErrorExit(Msg.FILTER_CAN_ONLY_CONTAIN_ONE_CATEGORY_LABEL)
      else:
        unknownArgumentExit()
    elif myarg == 'json':
      jsonData = getJSON([])
      body['criteria'] = jsonData['criteria']
      body['action'] = jsonData['action']
    elif myarg == 'buildpath':
      buildPath = getBoolean()
    else:
      unknownArgumentExit()
  if not body['criteria']:
    missingChoiceExit(FILTER_CRITERIA_CHOICE_MAP)
  if not body['action'].get('addLabelIds') and not body['action'].get('removeLabelIds') and 'forward' not in body['action']:
    missingChoiceExit(FILTER_ACTION_CHOICES)
  addLabelIndicies = {}
  for field in ['addLabelIds', 'removeLabelIds']:
    for i, labelId in enumerate(body['action'].get(field, [])):
      if labelId not in GMAIL_SYSTEM_LABELS and labelId not in GMAIL_CATEGORY_LABELS:
        addLabelIndicies.setdefault(labelId, {})
        addLabelIndicies[labelId][field] = i
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    if addLabelIndicies:
      labels = _getUserGmailLabels(gmail, user, i, count, 'labels(id,name,type)')
      if not labels:
        continue
      labelSet = _getLabelSet(labels)
    try:
      lcount = len(addLabelIndicies)
      l = 0
      for addLabelName, addLabelData in iter(addLabelIndicies.items()):
        l += 1
        retries = 3
        for _ in range(1, retries+1):
          addLabelId = _getLabelId(labels, addLabelName)
          if addLabelId:
            retries = 0
            break
          lbody = {'name': addLabelName}
          if not buildPath:
            try:
              result = callGAPI(gmail.users().labels(), 'create',
                                throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.DUPLICATE, GAPI.PERMISSION_DENIED],
                                userId='me', body=lbody, fields='id')
              entityActionPerformed([Ent.USER, user, Ent.LABEL, addLabelName], l, lcount)
              addLabelId = result['id']
              labels['labels'].append({'id': result['id'], 'name': addLabelName})
              retries = 0
              break
            except GAPI.duplicate:
              labels = _getUserGmailLabels(gmail, user, i, count, 'labels(id,name,type)')
              labelSet = _getLabelSet(labels)
          else:
            buildLabelPath(gmail, user, i, count, lbody, addLabelName, labelSet, l, lcount)
            labels = _getUserGmailLabels(gmail, user, i, count, 'labels(id,name, type)')
            labelSet = _getLabelSet(labels)
        if retries:
          entityActionFailedWarning([Ent.USER, user, Ent.LABEL, addLabelName], Msg.DUPLICATE, i, count)
          continue
        for field in ['addLabelIds', 'removeLabelIds']:
          if field in addLabelData:
            body['action'][field][addLabelData[field]] = addLabelId
      result = callGAPI(gmail.users().settings().filters(), 'create',
                        throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.INVALID, GAPI.INVALID_ARGUMENT,
                                                               GAPI.FAILED_PRECONDITION, GAPI.PERMISSION_DENIED],
                        userId='me', body=body, fields='id')
      if result:
        entityActionPerformed([Ent.USER, user, Ent.FILTER, result['id']], i, count)
    except (GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition, GAPI.permissionDenied) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.FILTER, ''], str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)

# gam <UserTypeEntity> delete filter <FilterIDEntity>
def deleteFilters(users):
  filterIdEntity = getUserObjectEntity(Cmd.OB_FILTER_ID_ENTITY, Ent.FILTER)
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail, filterIds, jcount = _validateUserGetObjectList(user, i, count, filterIdEntity)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for filterId in filterIds:
      j += 1
      try:
        callGAPI(gmail.users().settings().filters(), 'delete',
                 throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED],
                 userId='me', id=filterId)
        entityActionPerformed([Ent.USER, user, Ent.FILTER, filterId], j, jcount)
      except (GAPI.notFound, GAPI.permissionDenied) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.FILTER, filterId], str(e), j, jcount)
      except GAPI.serviceNotAvailable:
        userGmailServiceNotEnabledWarning(user, i, count)
        break
    Ind.Decrement()

# gam <UserTypeEntity> info filters <FilterIDEntity> [labelidsonly] [formatjson]
def infoFilters(users):
  labelIdsOnly = False
  filterIdEntity = getUserObjectEntity(Cmd.OB_FILTER_ID_ENTITY, Ent.FILTER)
  FJQC = FormatJSONQuoteChar()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'labelidsonly':
      labelIdsOnly = True
    else:
      FJQC.GetFormatJSON(myarg)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail, filterIds, jcount = _validateUserGetObjectList(user, i, count, filterIdEntity,
                                                                showAction=FJQC is None or not FJQC.formatJSON)
    if jcount == 0:
      continue
    if not labelIdsOnly:
      labels = _getUserGmailLabels(gmail, user, i, count, 'labels(id,name)')
      if not labels:
        continue
    else:
      labels = {'labels': []}
    Ind.Increment()
    j = 0
    for filterId in filterIds:
      j += 1
      try:
        result = callGAPI(gmail.users().settings().filters(), 'get',
                          throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.NOT_FOUND],
                          userId='me', id=filterId)
        if not FJQC.formatJSON:
          printEntityKVList([Ent.USER, user],
                            [Ent.Singular(Ent.FILTER), result['id']],
                            i, count)
        Ind.Increment()
        _showFilter(result, j, jcount, labels, FJQC)
        Ind.Decrement()
      except GAPI.notFound as e:
        entityActionFailedWarning([Ent.USER, user, Ent.FILTER, filterId], str(e), j, jcount)
      except GAPI.serviceNotAvailable:
        userGmailServiceNotEnabledWarning(user, i, count)
        break
    Ind.Decrement()

# gam <UserTypeEntity> print filters [labelidsonly] [todrive <ToDriveAttribute>*]
#	[formatjson] [quotechar <Character>]
# gam <UserTypeEntity> show filters [labelidsonly] [formatjson]
def printShowFilters(users):
  labelIdsOnly = False
  csvPF = CSVPrintFile() if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'labelidsonly':
      labelIdsOnly = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg)
  if csvPF:
    csvPF.SetFormatJSON(False)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    if not labelIdsOnly:
      labels = _getUserGmailLabels(gmail, user, i, count, 'labels(id,name)')
      if not labels:
        continue
    else:
      labels = {'labels': []}
    if csvPF:
      printGettingEntityItemForWhom(Ent.FILTER, user, i, count)
    try:
      results = callGAPIitems(gmail.users().settings().filters(), 'list', 'filter',
                              throwReasons=GAPI.GMAIL_THROW_REASONS,
                              userId='me')
      jcount = len(results)
      if not csvPF:
        if not FJQC.formatJSON:
          entityPerformActionNumItems([Ent.USER, user], jcount, Ent.FILTER, i, count)
        Ind.Increment()
        j = 0
        for userFilter in results:
          j += 1
          _showFilter(userFilter, j, jcount, labels, FJQC)
        Ind.Decrement()
      else:
        if results:
          for userFilter in results:
            row = _printFilter(user, userFilter, labels)
            if FJQC.formatJSON:
              if labels['labels']:
                _mapFilterLabelIdsToNames(userFilter, labels)
              row['JSON'] = json.dumps(userFilter, ensure_ascii=False, sort_keys=False)
            csvPF.WriteRowTitles(row)
        elif GC.Values[GC.CSV_OUTPUT_USERS_AUDIT]:
          csvPF.WriteRowNoFilter({'User': user})
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
  if csvPF:
    csvPF.SetFormatJSON(False)
    csvPF.SetSortTitles(['User', 'id', 'from', 'to', 'subject', 'query', 'negatedQuery', 'hasAttachment', 'excludeChats', 'size', 'forward',
                         'archive', 'important', 'label', 'markread', 'star'])
    csvPF.SortTitles()
    if FJQC.formatJSON:
      csvPF.MoveTitlesToEnd(['JSON'])
    csvPF.SetSortTitles([])
    csvPF.writeCSVfile('Filters')

def findFormRequest(field, newRequest, ubody):
  for request in ubody['requests']:
    if field in request:
      return request
  ubody['requests'].append(newRequest)
  return ubody['requests'][-1]

def updateFormInfoRequest(key, value, ubody):
  request = findFormRequest('updateFormInfo',
                            {'updateFormInfo': {'info': {}, 'updateMask': []}},
                            ubody)
  request['updateFormInfo']['info'][key] = value
  request['updateFormInfo']['updateMask'].append(key)

def updateFormSettingsRequest(key, value, ubody):
  request = findFormRequest('updateSettings',
                            {'updateSettings': {'settings': {'quizSettings': {}}, 'updateMask': []}},
                            ubody)
  request['updateSettings']['settings']['quizSettings'][key] = value
  request['updateSettings']['updateMask'].append(f'quizSettings.{key}')

def updateFormRequestUpdateMasks(ubody):
  for request in ubody['requests']:
    for k, v in request.items():
      if k in {'updateFormInfo', 'updateSettings'}:
        v['updateMask'] = ','.join(v['updateMask'])
        break

# gam <UserTypeEntity> create form
#	title <String> [description <String>] [isquiz [<Boolean>]] [<JSONData>]
#	[drivefilename <DriveFileName>] [<DriveFileParentAttribute>]
#	[(csv [todrive <ToDriveAttribute>*]) | returnidonly]
def createForm(users):
  csvPF = None
  returnIdOnly = False
  title = ''
  body = {'mimeType': MIMETYPE_GA_FORM}
  ubody = {'includeFormInResponse': True, 'requests': []}
  parentParms = initDriveFileAttributes()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'title':
      title = getString(Cmd.OB_STRING)
      updateFormInfoRequest(myarg, title, ubody)
    elif myarg == 'description':
      updateFormInfoRequest(myarg, getString(Cmd.OB_STRING, minLen=0), ubody)
    elif myarg == 'isquiz':
      updateFormSettingsRequest('isQuiz', getBoolean(), ubody)
    elif myarg == 'json':
      jsonData = getJSON([])
      ubody['requests'].extend(jsonData.get('requests', []))
    elif myarg == 'drivefilename':
      body['name'] = getString(Cmd.OB_DRIVE_FILE_NAME)
    elif getDriveFileParentAttribute(myarg, parentParms):
      pass
    elif myarg == 'returnidonly':
      returnIdOnly = True
    elif myarg == 'csv':
      csvPF = CSVPrintFile(['User', 'formId', 'name', 'title', 'responderUri'])
    elif csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    else:
      unknownArgumentExit()
  if not title:
    missingArgumentExit('title')
  updateFormRequestUpdateMasks(ubody)
  if 'name' not in body:
    body['name']  = title
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
    if not drive:
      continue
    if not _getDriveFileParentInfo(drive, user, i, count, body, parentParms):
      continue
    _, gform = buildGAPIServiceObject(API.FORMS, user, i, count)
    if not gform:
      continue
    try:
      result = callGAPI(drive.files(), 'create',
                        throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS, GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                    GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.CANNOT_ADD_PARENT,
                                                                    GAPI.FILE_NOT_FOUND, GAPI.UNKNOWN_ERROR, GAPI.STORAGE_QUOTA_EXCEEDED,
                                                                    GAPI.TEAMDRIVES_SHARING_RESTRICTION_NOT_ALLOWED,
                                                                    GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP],
                        body=body, fields='id,name', supportsAllDrives=True)
      formId = result['id']
      form = callGAPI(gform.forms(), 'batchUpdate',
                      throwReasons=[GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                      formId=formId, body=ubody)
      if returnIdOnly:
        writeStdout(f'{formId}\n')
      elif not csvPF:
        entityActionPerformed([Ent.USER, user, Ent.FORM, title,
                               Ent.DRIVE_FILE, f"{result['name']}({formId})"])
      else:
        csvPF.WriteRow({'User': user, 'formId': formId,
                        'name': form['form']['info']['documentTitle'],
                        'title': form['form']['info']['title'],
                        'responderUri': form['form']['responderUri']})
    except (GAPI.forbidden, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions,
            GAPI.invalid, GAPI.badRequest, GAPI.cannotAddParent,
            GAPI.fileNotFound, GAPI.unknownError, GAPI.storageQuotaExceeded, GAPI.teamDrivesSharingRestrictionNotAllowed,
            GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.FORM, title, Ent.DRIVE_FILE, body['name']], str(e), i, count)
    except GAPI.permissionDenied:
      SvcAcctAPIDisabledExit()
    except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
      userDriveServiceNotEnabledWarning(user, str(e), i, count)
  if csvPF:
    csvPF.writeCSVfile('Forms')

# gam <UserTypeEntity> update form <DriveFileEntity>
#	[title <String>] [description <String>] [isquiz [Boolean>]] [<JSONData>]
def updateForm(users):
  ubody = {'includeFormInResponse': False, 'requests': []}
  fileIdEntity = getDriveFileEntity()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'title':
      updateFormInfoRequest(myarg, getString(Cmd.OB_STRING), ubody)
    elif myarg == 'description':
      updateFormInfoRequest(myarg, getString(Cmd.OB_STRING, minLen=0), ubody)
    elif myarg == 'isquiz':
      updateFormSettingsRequest('isQuiz', getBoolean(), ubody)
    elif myarg == 'json':
      jsonData = getJSON([])
      ubody['requests'].extend(jsonData.get('requests', []))
    else:
      unknownArgumentExit()
  updateFormRequestUpdateMasks(ubody)
  if not ubody['requests']:
    return
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, _, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity, entityType=Ent.FORM)
    if jcount == 0:
      continue
    _, gform = buildGAPIServiceObject(API.FORMS, user, i, count)
    if not gform:
      continue
    Ind.Increment()
    j = 0
    for formId in fileIdEntity['list']:
      j += 1
      try:
        callGAPI(gform.forms(), 'batchUpdate',
                 throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
                 formId=formId, body=ubody)
        entityActionPerformed([Ent.USER, user, Ent.FORM, formId], j, jcount)
      except (GAPI.notFound, GAPI.invalidArgument) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.FORM, formId], str(e), j, jcount)
    Ind.Decrement()

# gam <UserTypeEntity> print forms <DriveFileEntity> [todrive <ToDriveAttribute>*]
#	(addcsvdata <FieldName> <String>)*
#	[formatjson [quotechar <Character>]]
# gam <UserTypeEntity> show forms <DriveFileEntity>
#	[formatjson]
def printShowForms(users):
  csvPF = CSVPrintFile(['User', 'formId', 'name', 'title', 'description'], 'sortall') if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  fileIdEntity = getDriveFileEntity()
  addCSVData = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif csvPF and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if csvPF and addCSVData:
    csvPF.AddTitles(sorted(addCSVData.keys()))
    if FJQC.formatJSON:
      csvPF.AddJSONTitles(sorted(addCSVData.keys()))
      csvPF.MoveJSONTitlesToEnd(['JSON'])
    csvPF.SetSortAllTitles()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, _, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity,
                                              entityType=[Ent.FORM, None][csvPF is not None or FJQC.formatJSON])
    if jcount == 0:
      continue
    _, gform = buildGAPIServiceObject(API.FORMS, user, i, count)
    if not gform:
      continue
    Ind.Increment()
    j = 0
    for formId in fileIdEntity['list']:
      j += 1
      try:
        result = callGAPI(gform.forms(), 'get',
                          throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED],
                          formId=formId)
        if not csvPF:
          if not FJQC.formatJSON:
            printEntity([Ent.FORM, result['formId']], j, jcount)
            Ind.Increment()
            showJSON(None, result)
            Ind.Decrement()
          else:
            printLine(json.dumps(cleanJSON(result), ensure_ascii=False, sort_keys=True))
        else:
          info = result.pop('info')
          baserow = {'User': user, 'formId': formId, 'name': info['documentTitle'],
                     'title': info.get('title', ''), 'description': info.get('description', '')}
          if addCSVData:
            baserow.update(addCSVData)
          row = flattenJSON(result, flattened=baserow.copy())
          if not FJQC.formatJSON:
            csvPF.WriteRowTitles(row)
          elif csvPF.CheckRowTitles(row):
            result['info'] = info
            baserow['JSON'] = json.dumps(cleanJSON(result), ensure_ascii=False, sort_keys=True)
            csvPF.WriteRowNoFilter(baserow)
      except GAPI.notFound as e:
        entityActionFailedWarning([Ent.USER, user, Ent.FORM, formId], str(e), j, jcount)
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('Forms')

FORM_RESPONSE_TIME_OBJECTS = {'createTime', 'lastSubmittedTime'}

# gam <UserTypeEntity> print formresponses <DriveFileEntity> [todrive <ToDriveAttribute>*]
#	[filtertime.* <Time>] [filter <String>]
#	(addcsvdata <FieldName> <String>)*
#	[countsonly|(formatjson [quotechar <Character>])]
# gam <UserTypeEntity> show formresponses <DriveFileEntity>
#	[filtertime.* <Time>] [filter <String>]
#	[countsonly|formatjson]
def printShowFormResponses(users):
  csvPF = CSVPrintFile(['User', 'formId', 'responseId', 'createTime', 'lastSubmittedTime', 'respondentEmail', 'totalScore'],
                       'sortall', indexedTitles=['answers']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  frfilter = None
  filterTimes = {}
  fileIdEntity = getDriveFileEntity()
  countsOnly = False
  addCSVData = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg.startswith('filtertime'):
      filterTimes[myarg] = getTimeOrDeltaFromNow()
    elif myarg in {'filter', 'filters'}:
      frfilter = getString(Cmd.OB_STRING)
    elif myarg == 'countsonly':
      countsOnly = True
    elif csvPF and myarg == 'addcsvdata':
      k = getString(Cmd.OB_STRING)
      addCSVData[k] = getString(Cmd.OB_STRING, minLen=0)
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if filterTimes and filter is not None:
    for filterTimeName, filterTimeValue in iter(filterTimes.items()):
      frfilter = frfilter.replace(f'#{filterTimeName}#', filterTimeValue)
  if csvPF:
    if countsOnly:
      csvPF.SetTitles(['User', 'formId', 'responses'])
    if addCSVData:
      csvPF.AddTitles(sorted(addCSVData.keys()))
      if FJQC.formatJSON:
        csvPF.AddJSONTitles(sorted(addCSVData.keys()))
        csvPF.MoveJSONTitlesToEnd(['JSON'])
    csvPF.SetSortAllTitles()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, _, jcount = _validateUserGetFileIDs(user, i, count, fileIdEntity,
                                              entityType=[Ent.FORM, None][csvPF is not None or FJQC.formatJSON])
    if jcount == 0:
      continue
    _, gform = buildGAPIServiceObject(API.FORMS, user, i, count)
    if not gform:
      continue
    Ind.Increment()
    j = 0
    for formId in fileIdEntity['list']:
      j += 1
      try:
        results = callGAPIpages(gform.forms().responses(), 'list', 'responses',
                                throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED],
                                filter=frfilter, formId=formId)
        kcount = len(results)
        if countsOnly:
          if not csvPF:
            printEntityKVList([Ent.FORM, formId], [Ent.Plural(Ent.FORM_RESPONSE), kcount], j, jcount)
          else:
            row = {'User': user, 'formId': formId, 'responses': kcount}
            if addCSVData:
              row.update(addCSVData)
            csvPF.WriteRowTitles(row)
          continue
        if not csvPF:
          if not FJQC.formatJSON:
            entityPerformActionNumItems([Ent.FORM, formId], kcount, Ent.FORM_RESPONSE, j, jcount)
          Ind.Increment()
          k = 0
          for response in results:
            k += 1
            if not FJQC.formatJSON:
              printEntity([Ent.FORM_RESPONSE, response['responseId']], k, kcount)
              Ind.Increment()
              showJSON(None, response, timeObjects=FORM_RESPONSE_TIME_OBJECTS)
              Ind.Decrement()
            else:
              printLine(json.dumps(cleanJSON(response, timeObjects=FORM_RESPONSE_TIME_OBJECTS),
                                   ensure_ascii=False, sort_keys=True))
          Ind.Decrement()
        else:
          baserow = {'User': user, 'formId': formId}
          if addCSVData:
            baserow.update(addCSVData)
          for response in results:
            row = flattenJSON(response, flattened=baserow.copy())
            if not FJQC.formatJSON:
              csvPF.WriteRowTitles(row)
            elif csvPF.CheckRowTitles(row):
              row = baserow.copy()
              row.update({'responseId': response['responseId'],
                          'createTime': response['createTime'],
                          'lastSubmittedTime': response['lastSubmittedTime'],
                          'respondentEmail': response.get('respondentEmail', ''),
                          'totalScore': response.get('totalScore', ''),
                          'JSON': json.dumps(cleanJSON(response, timeObjects=FORM_RESPONSE_TIME_OBJECTS),
                                             ensure_ascii=False, sort_keys=True)})
              csvPF.WriteRowNoFilter(row)
      except GAPI.notFound as e:
        entityActionFailedWarning([Ent.USER, user, Ent.FORM, formId], str(e), j, jcount)
    Ind.Decrement()
  if csvPF:
    csvPF.writeCSVfile('Form Responses')

EMAILSETTINGS_OLD_NEW_OLD_FORWARD_ACTION_MAP = {
  'ARCHIVE': 'archive',
  'DELETE': 'trash',
  'KEEP': 'leaveInInBox',
  'MARK_READ': 'markRead',
  }

def _showForward(user, i, count, result):
  if 'enabled' in result:
    enabled = result['enabled']
    kvList = [Ent.Singular(Ent.FORWARD_ENABLED), enabled]
    if enabled:
      kvList += [Ent.Singular(Ent.FORWARDING_ADDRESS), result['emailAddress']]
      kvList += [Ent.Singular(Ent.ACTION), result['disposition']]
  else:
    enabled = result['enable'] == 'true'
    kvList = [Ent.Singular(Ent.FORWARD_ENABLED), enabled]
    if enabled:
      kvList += [Ent.Singular(Ent.FORWARDING_ADDRESS), result['forwardTo']]
      kvList += [Ent.Singular(Ent.ACTION), EMAILSETTINGS_OLD_NEW_OLD_FORWARD_ACTION_MAP[result['action']]]
  printEntityKVList([Ent.USER, user], kvList, i, count)

EMAILSETTINGS_FORWARD_POP_ACTION_CHOICE_MAP = {
  'archive': 'archive',
  'delete': 'trash',
  'keep': 'leaveInInbox',
  'leaveininbox': 'leaveInInbox',
  'markread': 'markRead',
  'trash': 'trash',
  }

# gam <UserTypeEntity> forward <FalseValues>
# gam <UserTypeEntity> forward <TrueValues> keep|leaveininbox|archive|delete|trash|markread <EmailAddress>
def setForward(users):
  if checkArgumentPresent([Cmd.ARG_MESSAGE, Cmd.ARG_MESSAGES]):
    Act.Set(Act.FORWARD)
    forwardMessagesThreads(users, Ent.MESSAGE)
    return
  if checkArgumentPresent([Cmd.ARG_THREAD, Cmd.ARG_THREADS]):
    Act.Set(Act.FORWARD)
    forwardMessagesThreads(users, Ent.THREAD)
    return
  enable = getBoolean(None)
  body = {'enabled': enable}
  if enable:
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg in EMAILSETTINGS_FORWARD_POP_ACTION_CHOICE_MAP:
        body['disposition'] = EMAILSETTINGS_FORWARD_POP_ACTION_CHOICE_MAP[myarg]
      elif myarg == 'confirm':
        pass
      elif myarg.find('@') != -1 or (not Cmd.ArgumentsRemaining() and 'emailAddress' not in body):
        body['emailAddress'] = normalizeEmailAddressOrUID(Cmd.Previous())
      else:
        unknownArgumentExit()
    if not body.get('disposition'):
      missingChoiceExit(EMAILSETTINGS_FORWARD_POP_ACTION_CHOICE_MAP)
    if not body.get('emailAddress'):
      missingArgumentExit(Cmd.OB_EMAIL_ADDRESS)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    try:
      result = callGAPI(gmail.users().settings(), 'updateAutoForwarding',
                        throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.INVALID, GAPI.FAILED_PRECONDITION, GAPI.PERMISSION_DENIED],
                        userId='me', body=body)
      _showForward(user, i, count, result)
    except (GAPI.invalid, GAPI.failedPrecondition, GAPI.permissionDenied) as e:
      if enable:
        entityActionFailedWarning([Ent.USER, user, Ent.FORWARDING_ADDRESS, body['emailAddress']], str(e), i, count)
      else:
        entityActionFailedWarning([Ent.USER, user], str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)

# gam <UserTypeEntity> print forward [enabledonly] [todrive <ToDriveAttribute>*]
# gam <UserTypeEntity> show forward
def printShowForward(users):
  def _printForward(user, result, showDisabled):
    if 'enabled' in result:
      enabled = result['enabled']
      if not enabled and not showDisabled:
        return
      row = {'User': user, 'forwardEnabled': enabled}
      if enabled:
        row['forwardTo'] = result['emailAddress']
        row['disposition'] = result['disposition']
    else:
      enabled = result['enable'] == 'true'
      if not enabled and not showDisabled:
        return
      row = {'User': user, 'forwardEnabled': enabled}
      if enabled:
        row['forwardTo'] = result['forwardTo']
        row['disposition'] = EMAILSETTINGS_OLD_NEW_OLD_FORWARD_ACTION_MAP[result['action']]
    csvPF.WriteRow(row)

  csvPF = CSVPrintFile(['User', 'forwardEnabled', 'forwardTo', 'disposition']) if Act.csvFormat() else None
  showDisabled = True
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif csvPF and myarg == 'enabledonly':
      showDisabled = False
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    if csvPF:
      printGettingEntityItemForWhom(Ent.FORWARD_ENABLED, user, i, count)
    try:
      result = callGAPI(gmail.users().settings(), 'getAutoForwarding',
                        throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.FAILED_PRECONDITION],
                        userId='me')
      if not csvPF:
        _showForward(user, i, count, result)
      else:
        _printForward(user, result, showDisabled)
    except GAPI.failedPrecondition as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
  if csvPF:
    csvPF.writeCSVfile('Forward')

# Process ForwardingAddresses functions
def _showForwardingAddress(j, jcount, result):
  printEntityKVList([Ent.FORWARDING_ADDRESS, result['forwardingEmail']], ['Verification Status', result['verificationStatus']], j, jcount)

def _processForwardingAddress(user, i, count, emailAddress, j, jcount, gmail, function, **kwargs):
  userDefined = True
  try:
    result = callGAPI(gmail.users().settings().forwardingAddresses(), function,
                      throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.ALREADY_EXISTS, GAPI.DUPLICATE,
                                                             GAPI.INVALID_ARGUMENT, GAPI.FAILED_PRECONDITION, GAPI.PERMISSION_DENIED],
                      userId='me', **kwargs)
    if function == 'get':
      _showForwardingAddress(j, count, result)
    else:
      entityActionPerformed([Ent.USER, user, Ent.FORWARDING_ADDRESS, emailAddress], j, jcount)
  except (GAPI.notFound, GAPI.alreadyExists, GAPI.duplicate, GAPI.invalidArgument, GAPI.failedPrecondition, GAPI.permissionDenied) as e:
    entityActionFailedWarning([Ent.USER, user, Ent.FORWARDING_ADDRESS, emailAddress], str(e), j, jcount)
  except GAPI.serviceNotAvailable:
    userGmailServiceNotEnabledWarning(user, i, count)
    userDefined = False
  return userDefined

# gam <UserTypeEntity> create forwardingaddresses <EmailAddressEntity>
def createForwardingAddresses(users):
  emailAddressEntity = getUserObjectEntity(Cmd.OB_EMAIL_ADDRESS_ENTITY, Ent.FORWARDING_ADDRESS)
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail, emailAddresses, jcount = _validateUserGetObjectList(user, i, count, emailAddressEntity)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for emailAddress in emailAddresses:
      j += 1
      emailAddress = normalizeEmailAddressOrUID(emailAddress, noUid=True)
      body = {'forwardingEmail': emailAddress}
      if not _processForwardingAddress(user, i, count, emailAddress, j, jcount, gmail, 'create', body=body, fields=''):
        break
    Ind.Decrement()

def _deleteInfoForwardingAddreses(users, function):
  emailAddressEntity = getUserObjectEntity(Cmd.OB_EMAIL_ADDRESS_ENTITY, Ent.FORWARDING_ADDRESS)
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail, emailAddresses, jcount = _validateUserGetObjectList(user, i, count, emailAddressEntity)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for emailAddress in emailAddresses:
      j += 1
      emailAddress = normalizeEmailAddressOrUID(emailAddress, noUid=True)
      if not _processForwardingAddress(user, i, count, emailAddress, j, jcount, gmail, function, forwardingEmail=emailAddress):
        break
    Ind.Decrement()

# gam <UserTypeEntity> delete forwardingaddresses <EmailAddressEntity>
def deleteForwardingAddresses(users):
  _deleteInfoForwardingAddreses(users, 'delete')

# gam <UserTypeEntity> info forwardingaddresses <EmailAddressEntity>
def infoForwardingAddresses(users):
  _deleteInfoForwardingAddreses(users, 'get')

# gam <UserTypeEntity> print forwardingaddresses [todrive <ToDriveAttribute>*]
# gam <UserTypeEntity> show forwardingaddresses
def printShowForwardingAddresses(users):
  csvPF = CSVPrintFile(['User', 'forwardingEmail', 'verificationStatus']) if Act.csvFormat() else None
  getTodriveOnly(csvPF)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    if csvPF:
      printGettingEntityItemForWhom(Ent.FORWARDING_ADDRESS, user, i, count)
    try:
      results = callGAPIitems(gmail.users().settings().forwardingAddresses(), 'list', 'forwardingAddresses',
                              throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.FAILED_PRECONDITION],
                              userId='me')
      jcount = len(results)
      if not csvPF:
        entityPerformActionNumItems([Ent.USER, user], jcount, Ent.FORWARDING_ADDRESS, i, count)
        Ind.Increment()
        j = 0
        for forward in results:
          j += 1
          _showForwardingAddress(j, jcount, forward)
        Ind.Decrement()
      else:
        if results:
          for forward in results:
            csvPF.WriteRow({'User': user, 'forwardingEmail': forward['forwardingEmail'], 'verificationStatus': forward['verificationStatus']})
        elif GC.Values[GC.CSV_OUTPUT_USERS_AUDIT]:
          csvPF.WriteRowNoFilter({'User': user})
    except GAPI.failedPrecondition as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
  if csvPF:
    csvPF.writeCSVfile('Forwarding Addresses')

def _showImap(user, i, count, result):
  enabled = result['enabled']
  kvList = [Ent.Singular(Ent.IMAP_ENABLED), enabled]
  for item in result:
    if item != 'enabled':
      kvList += [item, result[item]]
  printEntityKVList([Ent.USER, user], kvList, i, count)
#
EMAILSETTINGS_IMAP_EXPUNGE_BEHAVIOR_CHOICE_MAP = {
  'archive': 'archive',
  'deleteforever': 'deleteForever',
  'trash': 'trash',
  }

EMAILSETTINGS_IMAP_MAX_FOLDER_SIZE_CHOICES = ['0', '1000', '2000', '5000', '10000']

def _imapDefaults(enable):
  return {'enabled': enable, 'autoExpunge': True, 'expungeBehavior': 'archive', 'maxFolderSize': 0}

def _setImap(user, body, i, count):
  user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
  if gmail:
    try:
      result = callGAPI(gmail.users().settings(), 'updateImap',
                        throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.PERMISSION_DENIED],
                        userId='me', body=body)
      _showImap(user, i, count, result)
    except GAPI.permissionDenied as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)

# gam <UserTypeEntity> imap|imap4 <Boolean> [noautoexpunge] [expungebehavior archive|deleteforever|trash] [maxfoldersize 0|1000|2000|5000|10000]
def setImap(users):
  body = _imapDefaults(getBoolean(None))
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'noautoexpunge':
      body['autoExpunge'] = False
    elif myarg == 'expungebehavior':
      body['expungeBehavior'] = getChoice(EMAILSETTINGS_IMAP_EXPUNGE_BEHAVIOR_CHOICE_MAP, mapChoice=True)
    elif myarg == 'maxfoldersize':
      body['maxFolderSize'] = int(getChoice(EMAILSETTINGS_IMAP_MAX_FOLDER_SIZE_CHOICES))
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    _setImap(user, body, i, count)

# gam <UserTypeEntity> print imap|imap4 [todrive <ToDriveAttribute>*]
# gam <UserTypeEntity> show imap|imap4
def printShowImap(users):
  csvPF = CSVPrintFile(['User', 'enabled']) if Act.csvFormat() else None
  getTodriveOnly(csvPF)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    try:
      result = callGAPI(gmail.users().settings(), 'getImap',
                        throwReasons=GAPI.GMAIL_THROW_REASONS,
                        userId='me')
      if not csvPF:
        _showImap(user, i, count, result)
      else:
        csvPF.WriteRowTitles(flattenJSON(result, flattened={'User': user}))
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
  if csvPF:
    csvPF.writeCSVfile('IMAP')

def _showPop(user, i, count, result):
  enabled = result['accessWindow'] != 'disabled'
  kvList = [Ent.Singular(Ent.POP_ENABLED), enabled]
  if enabled:
    kvList += ['For', result['accessWindow'], Ent.Singular(Ent.ACTION), result['disposition']]
  printEntityKVList([Ent.USER, user], kvList, i, count)
#
EMAILSETTINGS_POP_ENABLE_FOR_CHOICE_MAP = {
  'allmail': 'allMail',
  'fromnowon': 'fromNowOn',
  'mailfromnowon': 'fromNowOn',
  'newmail': 'fromNowOn',
  }

def _popDefaults(enable):
  return {'accessWindow': ['disabled', 'allMail'][enable], 'disposition': 'leaveInInbox'}

def _setPop(user, body, i, count):
  user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
  if gmail:
    try:
      result = callGAPI(gmail.users().settings(), 'updatePop',
                        throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.PERMISSION_DENIED],
                        userId='me', body=body)
      _showPop(user, i, count, result)
    except GAPI.permissionDenied as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)

# gam <UserTypeEntity> pop|pop3 <Boolean> [for allmail|newmail|mailfromnowon|fromnowown] [action keep|leaveininbox|archive|delete|trash|markread]
def setPop(users):
  body = _popDefaults(getBoolean(None))
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'for':
      body['accessWindow'] = getChoice(EMAILSETTINGS_POP_ENABLE_FOR_CHOICE_MAP, mapChoice=True)
    elif myarg == 'action':
      body['disposition'] = getChoice(EMAILSETTINGS_FORWARD_POP_ACTION_CHOICE_MAP, mapChoice=True)
    elif myarg == 'confirm':
      pass
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    _setPop(user, body, i, count)

# gam <UserTypeEntity> print pop|pop3 [todrive <ToDriveAttribute>*]
# gam <UserTypeEntity> show pop|pop3
def printShowPop(users):
  csvPF = CSVPrintFile(['User', 'enabled']) if Act.csvFormat() else None
  getTodriveOnly(csvPF)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    try:
      result = callGAPI(gmail.users().settings(), 'getPop',
                        throwReasons=GAPI.GMAIL_THROW_REASONS,
                        userId='me')
      if not csvPF:
        _showPop(user, i, count, result)
      else:
        csvPF.WriteRowTitles(flattenJSON(result, flattened={'User': user, 'enabled': result['accessWindow'] != 'disabled'}))
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
  if csvPF:
    csvPF.writeCSVfile('POP')

# gam <UserTypeEntity> language <Language>
def setLanguage(users):
  language = getLanguageCode(LANGUAGE_CODES_MAP)
  checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    try:
      result = callGAPI(gmail.users().settings(), 'updateLanguage',
                        throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.PERMISSION_DENIED],
                        userId='me', body={'displayLanguage': language})
      entityActionPerformed([Ent.USER, user, Ent.LANGUAGE, result['displayLanguage']], i, count)
    except GAPI.permissionDenied as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)

# gam <UserTypeEntity> print language [todrive <ToDriveAttribute>*]
# gam <UserTypeEntity> show language
def printShowLanguage(users):
  csvPF = CSVPrintFile(['User', 'displayLanguage']) if Act.csvFormat() else None
  getTodriveOnly(csvPF)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    try:
      result = callGAPI(gmail.users().settings(), 'getLanguage',
                        throwReasons=GAPI.GMAIL_THROW_REASONS,
                        userId='me')
      if not csvPF:
        printEntity([Ent.USER, user, Ent.LANGUAGE, result['displayLanguage']], i, count)
      else:
        csvPF.WriteRowTitles(flattenJSON(result, flattened={'User': user}))
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
  if csvPF:
    csvPF.writeCSVfile('Language')

SIG_REPLY_HTML = 0
SIG_REPLY_COMPACT = 1
SIG_REPLY_FORMAT = 2
SIG_REPLY_OPTIONS_MAP = {'html': SIG_REPLY_HTML, 'compact': SIG_REPLY_COMPACT, 'format': SIG_REPLY_FORMAT}
SMTPMSA_DISPLAY_FIELDS = ['host', 'port', 'securityMode']

def _showSendAs(result, j, jcount, sigReplyFormat, verifyOnly=False):
  if result['displayName']:
    printEntity([Ent.SENDAS_ADDRESS, f'{result["displayName"]} <{result["sendAsEmail"]}>'], j, jcount)
  else:
    printEntity([Ent.SENDAS_ADDRESS, f'<{result["sendAsEmail"]}>'], j, jcount)
  Ind.Increment()
  if result.get('replyToAddress'):
    printKeyValueList(['ReplyTo', result['replyToAddress']])
  printKeyValueList(['IsPrimary', result.get('isPrimary', False)])
  printKeyValueList(['Default', result.get('isDefault', False)])
  if not result.get('isPrimary', False):
    printKeyValueList(['TreatAsAlias', result.get('treatAsAlias', False)])
  if 'smtpMsa' in result:
    for field in SMTPMSA_DISPLAY_FIELDS:
      if field in result['smtpMsa']:
        printKeyValueList([f'smtpMsa.{field}', result['smtpMsa'][field]])
  if 'verificationStatus' in result:
    printKeyValueList(['Verification Status', result['verificationStatus']])
  signature = result.get('signature')
  if verifyOnly:
    printKeyValueList(['Signature', bool(signature)])
  else:
    if not signature:
      signature = 'None'
    if sigReplyFormat == SIG_REPLY_HTML:
      printKeyValueList(['Signature', None])
      Ind.Increment()
      printKeyValueList([Ind.MultiLineText(signature)])
      Ind.Decrement()
    elif sigReplyFormat == SIG_REPLY_FORMAT:
      printKeyValueList(['Signature', None])
      Ind.Increment()
      printKeyValueList([Ind.MultiLineText(dehtml(signature))])
      Ind.Decrement()
    else: # SIG_REPLY_COMPACT
      printKeyValueList(['Signature', escapeCRsNLs(signature)])
  Ind.Decrement()

def _processSignature(tagReplacements, signature, html):
  if signature:
    signature = signature.replace('\r', '').replace('\\n', '<br/>')
    if tagReplacements['tags']:
      signature = _processTagReplacements(tagReplacements, signature)
    if not html:
      signature = signature.replace('\n', '<br/>')
  return signature

# Process SendAs functions
def _processSendAs(user, i, count, entityType, emailAddress, j, jcount, gmail, function,
                   sigReplyFormat, verifyOnly=False, **kwargs):
  userDefined = True
  try:
    result = callGAPI(gmail.users().settings().sendAs(), function,
                      throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.NOT_FOUND, GAPI.ALREADY_EXISTS, GAPI.DUPLICATE,
                                                             GAPI.CANNOT_DELETE_PRIMARY_SENDAS, GAPI.INVALID_ARGUMENT,
                                                             GAPI.FAILED_PRECONDITION, GAPI.PERMISSION_DENIED,
                                                             GAPI.INSUFFICIENT_PERMISSIONS],
                      userId='me', **kwargs)
    if function == 'get':
      _showSendAs(result, j, jcount, sigReplyFormat, verifyOnly)
    else:
      entityActionPerformed([Ent.USER, user, entityType, emailAddress], j, jcount)
  except (GAPI.notFound, GAPI.alreadyExists, GAPI.duplicate,
          GAPI.cannotDeletePrimarySendAs, GAPI.invalidArgument,
          GAPI.failedPrecondition, GAPI.permissionDenied, GAPI.insufficientPermissions) as e:
    entityActionFailedWarning([Ent.USER, user, entityType, emailAddress], str(e), j, jcount)
  except GAPI.serviceNotAvailable:
    userGmailServiceNotEnabledWarning(user, i, count)
    userDefined = False
  return userDefined

def getSendAsAttributes(myarg, body, tagReplacements):
  if _getTagReplacement(myarg, tagReplacements, True):
    pass
  elif myarg == 'name':
    body['displayName'] = getString(Cmd.OB_NAME, minLen=0)
  elif myarg == 'replyto':
    body['replyToAddress'] = getString(Cmd.OB_EMAIL_ADDRESS, minLen=0)
    if len(body['replyToAddress']) > 0:
      body['replyToAddress'] = normalizeEmailAddressOrUID(body['replyToAddress'], noUid=True, noLower=True)
  elif myarg == 'default':
    body['isDefault'] = True
  elif myarg == 'treatasalias':
    body['treatAsAlias'] = getBoolean()
  else:
    unknownArgumentExit()

SMTPMSA_PORTS = ['25', '465', '587']
SMTPMSA_SECURITY_MODES = ['none', 'ssl', 'starttls']
SMTPMSA_REQUIRED_FIELDS = ['host', 'port', 'username', 'password']

# gam <UserTypeEntity> [create] sendas <EmailAddress> [name] <String>
#	[<SendAsContent>
#	    (replace <Tag> <UserReplacement>)*
#	    (replaceregex <REMatchPattern> <RESubstitution> <Tag> <UserReplacement>)*]
#	[html [<Boolean>]] [replyto <EmailAddress>] [default] [treatasalias <Boolean>]
#	[smtpmsa.host <SMTPHostName> smtpmsa.port 25|465|587
#	 smtpmsa.username <UserName> smtpmsa.password <Password>
#	 [smtpmsa.securitymode none|ssl|starttls]]
# gam <UserTypeEntity> update sendas <EmailAddress> [name <String>]
#	[<SendAsContent>
#	    (replace <Tag> <UserReplacement>)*
#	    (replaceregex <REMatchPattern> <RESubstitution> <Tag> <UserReplacement>)*]
#	[html [<Boolean>]] [replyto <EmailAddress>] [default] [treatasalias <Boolean>]
def createUpdateSendAs(users):
  updateCmd = Act.Get() == Act.UPDATE
  emailAddress = normalizeEmailAddressOrUID(getString(Cmd.OB_EMAIL_ADDRESS), noUid=True, noLower=True)
  if not updateCmd:
    body = {'sendAsEmail': emailAddress}
    checkArgumentPresent(['name'])
    body['displayName'] = getString(Cmd.OB_NAME)
  else:
    body = {}
  signature = None
  smtpMsa = {}
  tagReplacements = _initTagReplacements()
  html = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in SORF_SIG_FILE_ARGUMENTS:
      signature, _, html = getStringOrFile(myarg)
    elif myarg == 'html':
      html = getBoolean()
    elif not updateCmd and myarg.startswith('smtpmsa.'):
      if myarg == 'smtpmsa.host':
        smtpMsa['host'] = getString(Cmd.OB_SMTP_HOST_NAME)
      elif myarg == 'smtpmsa.port':
        smtpMsa['port'] = int(getChoice(SMTPMSA_PORTS))
      elif myarg == 'smtpmsa.username':
        smtpMsa['username'] = getString(Cmd.OB_USER_NAME)
      elif myarg == 'smtpmsa.password':
        smtpMsa['password'] = getString(Cmd.OB_PASSWORD)
      elif myarg == 'smtpmsa.securitymode':
        smtpMsa['securityMode'] = getChoice(SMTPMSA_SECURITY_MODES)
      else:
        unknownArgumentExit()
    else:
      getSendAsAttributes(myarg, body, tagReplacements)
  if signature is not None and not tagReplacements['subs']:
    body['signature'] = _processSignature(tagReplacements, signature, html)
  if smtpMsa:
    for field in SMTPMSA_REQUIRED_FIELDS:
      if field not in smtpMsa:
        missingArgumentExit(f'smtpmsa.{field}')
    body['smtpMsa'] = smtpMsa
  kwargs = {'body': body, 'fields': ''}
  if updateCmd:
    kwargs['sendAsEmail'] = emailAddress
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    if signature is not None and tagReplacements['subs']:
      _getTagReplacementFieldValues(user, i, count, tagReplacements)
      kwargs['body']['signature'] = _processSignature(tagReplacements, signature, html)
    _processSendAs(user, i, count, Ent.SENDAS_ADDRESS, emailAddress, i, count, gmail, ['create', 'patch'][updateCmd], False, **kwargs)

# gam <UserTypeEntity> delete sendas <EmailAddressEntity>
# gam <UserTypeEntity> info sendas <EmailAddressEntity> [compact|format|html]
def deleteInfoSendAs(users):
  function = 'delete' if Act.Get() == Act.DELETE else 'get'
  emailAddressEntity = getUserObjectEntity(Cmd.OB_EMAIL_ADDRESS_ENTITY, Ent.SENDAS_ADDRESS)
  sigReplyFormat = SIG_REPLY_HTML
  if function == 'get':
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if myarg in SIG_REPLY_OPTIONS_MAP:
        sigReplyFormat = SIG_REPLY_OPTIONS_MAP[myarg]
      else:
        unknownArgumentExit()
  else:
    checkForExtraneousArguments()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail, emailAddresses, jcount = _validateUserGetObjectList(user, i, count, emailAddressEntity)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for emailAddress in emailAddresses:
      j += 1
      emailAddress = normalizeEmailAddressOrUID(emailAddress, noUid=True)
      if not _processSendAs(user, i, count, Ent.SENDAS_ADDRESS, emailAddress, j, jcount, gmail, function, sigReplyFormat, sendAsEmail=emailAddress):
        break
    Ind.Decrement()

# gam <UserTypeEntity> print sendas [compact]
#	[primary|default] [verifyonly] [todrive <ToDriveAttribute>*]
# gam <UserTypeEntity> show sendas [compact|format|html]
#	[primary|default] [verifyonly]
def printShowSendAs(users):
  csvPF = CSVPrintFile(['User', 'displayName', 'sendAsEmail', 'replyToAddress',
                        'isPrimary', 'isDefault', 'treatAsAlias', 'verificationStatus'],
                       'sortall') if Act.csvFormat() else None
  sigReplyFormat = SIG_REPLY_HTML
  selection=None
  verifyOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'primary':
      selection = 'isPrimary'
    elif myarg == 'default':
      selection = 'isDefault'
    elif myarg == 'verifyonly':
      verifyOnly = True
    elif (not csvPF and myarg in SIG_REPLY_OPTIONS_MAP) or (csvPF and myarg == 'compact'):
      sigReplyFormat = SIG_REPLY_OPTIONS_MAP[myarg]
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    if csvPF:
      printGettingEntityItemForWhom(Ent.SENDAS_ADDRESS, user, i, count)
    try:
      results = callGAPIitems(gmail.users().settings().sendAs(), 'list', 'sendAs',
                              throwReasons=GAPI.GMAIL_THROW_REASONS,
                              userId='me')
      jcount = len(results)
      if not csvPF:
        entityPerformActionNumItems([Ent.USER, user], jcount if selection is None else 1, Ent.SENDAS_ADDRESS, i, count)
        Ind.Increment()
        j = 0
        for sendas in results:
          j += 1
          if (selection is None) or (sendas.get(selection, False)):
            _showSendAs(sendas, j, jcount, sigReplyFormat, verifyOnly)
        Ind.Decrement()
      else:
        if results:
          for sendas in results:
            if (selection is None) or (sendas.get(selection, False)):
              row = {'User': user, 'isPrimary': False}
              for item in sendas:
                if item != 'smtpMsa':
                  if item == 'signature':
                    if verifyOnly:
                      row[item] = bool(sendas[item])
                    elif sigReplyFormat != SIG_REPLY_COMPACT:
                      row[item] = sendas[item]
                    else:
                      row[item] = sendas[item].replace('\r', '').replace('\n', '')
                  else:
                    row[item] = sendas[item]
                else:
                  for field in SMTPMSA_DISPLAY_FIELDS:
                    if field in sendas[item]:
                      row[f'smtpMsa{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{field}'] = sendas[item][field]
              csvPF.WriteRowTitles(row)
        elif GC.Values[GC.CSV_OUTPUT_USERS_AUDIT]:
          csvPF.WriteRowNoFilter({'User': user})
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
  if csvPF:
    csvPF.writeCSVfile('SendAs')

# gam <UserTypeEntity> print signature [compact]
#	[primary] [verifyonly] [todrive <ToDriveAttribute>*]
# gam <UserTypeEntity> show signature|sig [compact|format|html]
#	[primary] [verifyonly]
def printShowSignature(users):
  csvPF = CSVPrintFile(['User', 'displayName', 'sendAsEmail', 'replyToAddress',
                        'isPrimary', 'isDefault', 'treatAsAlias', 'verificationStatus'],
                       'sortall') if Act.csvFormat() else None
  sigReplyFormat = SIG_REPLY_HTML
  selection = None
  verifyOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'primary':
      selection = 'isPrimary'
    elif myarg == 'default':
      selection = 'isDefault'
    elif myarg == 'verifyonly':
      verifyOnly = True
    elif (not csvPF and myarg in SIG_REPLY_OPTIONS_MAP) or (csvPF and myarg == 'compact'):
      sigReplyFormat = SIG_REPLY_OPTIONS_MAP[myarg]
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    if csvPF:
      printGettingEntityItemForWhom(Ent.SIGNATURE, user, i, count)
    try:
      if selection is None:
        sendas = callGAPI(gmail.users().settings().sendAs(), 'get',
                          throwReasons=GAPI.GMAIL_THROW_REASONS,
                          userId='me', sendAsEmail=user)
      else:
        results = callGAPIitems(gmail.users().settings().sendAs(), 'list', 'sendAs',
                                throwReasons=GAPI.GMAIL_THROW_REASONS,
                                userId='me')
        for sendas in results:
          if sendas.get(selection, False):
            break
      if not csvPF:
        _showSendAs(sendas, 0, 0, sigReplyFormat, verifyOnly)
      else:
        row = {'User': user, 'isPrimary': False}
        for item in sendas:
          if item != 'smtpMsa':
            if item == 'signature':
              if verifyOnly:
                row[item] = bool(sendas[item])
              elif sigReplyFormat != SIG_REPLY_COMPACT:
                row[item] = sendas[item]
              else:
                row[item] = sendas[item].replace('\r', '').replace('\n', '')
            else:
              row[item] = sendas[item]
          else:
            for field in SMTPMSA_DISPLAY_FIELDS:
              if field in sendas[item]:
                row[f'smtpMsa{GC.Values[GC.CSV_OUTPUT_SUBFIELD_DELIMITER]}{field}'] = sendas[item][field]
        csvPF.WriteRowTitles(row)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
  if csvPF:
    csvPF.writeCSVfile('Signature')

# gam <UserTypeEntity> create smime file <FileName> [password <Password>]
#	[sendas|sendasemail <EmailAddress>] [default]
def createSmime(users):
  sendAsEmailBase = None
  setDefault = False
  body = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'file':
      smimefile = getString(Cmd.OB_FILE_NAME)
      smimeData = readFile(smimefile, mode='rb')
      body['pkcs12'] = base64.urlsafe_b64encode(smimeData).decode(UTF8)
    elif myarg == 'password':
      body['encryptedKeyPassword'] = getString(Cmd.OB_PASSWORD)
    elif myarg == 'default':
      setDefault = True
    elif myarg in {'sendas', 'sendasemail'}:
      sendAsEmailBase = getEmailAddress(noUid=True)
    else:
      unknownArgumentExit()
  if 'pkcs12' not in body:
    missingArgumentExit('file')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    sendAsEmail = sendAsEmailBase if sendAsEmailBase else user
    try:
      Act.Set(Act.CREATE)
      result = callGAPI(gmail.users().settings().sendAs().smimeInfo(), 'insert',
                        throwReasons=GAPI.GMAIL_SMIME_THROW_REASONS,
                        userId='me', sendAsEmail=sendAsEmail, body=body, fields='id,issuerCn')
      entityModifierNewValueActionPerformed([Ent.USER, user, Ent.SENDAS_ADDRESS, sendAsEmail, Ent.SMIME_ID, result['id']],
                                            Act.MODIFIER_FROM, f'{Ent.Singular(Ent.ISSUER_CN)}: {result["issuerCn"]}', i, count)
      if setDefault:
        Act.Set(Act.UPDATE)
        smimeId = result['id']
        callGAPI(gmail.users().settings().sendAs().smimeInfo(), 'setDefault',
                 throwReasons=GAPI.GMAIL_SMIME_THROW_REASONS,
                 userId='me', sendAsEmail=sendAsEmail, id=smimeId)
        entityActionPerformedMessage([Ent.USER, user, Ent.SENDAS_ADDRESS, sendAsEmail, Ent.SMIME_ID, smimeId], Msg.DEFAULT_SMIME, i, count)
    except (GAPI.forbidden, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)

def _getSmimeIds(gmail, user, i, count, sendAsEmail, function):
  try:
    result = callGAPI(gmail.users().settings().sendAs().smimeInfo(), 'list',
                      throwReasons=GAPI.GMAIL_SMIME_THROW_REASONS,
                      userId='me', sendAsEmail=sendAsEmail, fields='smimeInfo(id)')
    smimes = result.get('smimeInfo', [])
    jcount = len(smimes)
    if jcount == 0:
      entityActionNotPerformedWarning([Ent.USER, user, Ent.SENDAS_ADDRESS, sendAsEmail, Ent.SMIME_ID, None],
                                      Msg.NO_ENTITIES_FOUND.format(Ent.Plural(Ent.SMIME_ID)), i, count)
      setSysExitRC(NO_ENTITIES_FOUND_RC)
    elif jcount > 1:
      entityActionNotPerformedWarning([Ent.USER, user, Ent.SENDAS_ADDRESS, sendAsEmail, Ent.SMIME_ID, None],
                                      Msg.PLEASE_SELECT_ENTITY_TO_PROCESS.format(jcount, Ent.Plural(Ent.SMIME_ID), function, 'id <S/MIMEID>'),
                                      i, count)
      Ind.Increment()
      j = 0
      for smime in smimes:
        j += 1
        printEntityKVList([Ent.SMIME_ID, smime['id'], Ent.ISSUER_CN, smime['issuerCn']], ['Default', smime.get('isDefault', False)], j, jcount)
      Ind.Decrement()
    else:
      return smimes[0]['id']
  except GAPI.forbidden as e:
    entityActionFailedWarning([Ent.USER, user], str(e), i, count)
  except GAPI.serviceNotAvailable:
    userGmailServiceNotEnabledWarning(user, i, count)
  return None

# gam <UserTypeEntity> update smime default
#	[id <SmimeID>] [sendas|sendasemail <EmailAddress>]
def updateSmime(users):
  smimeIdBase = None
  sendAsEmailBase = None
  setDefault = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'id':
      smimeIdBase = getString(Cmd.OB_SMIME_ID)
    elif myarg in {'sendas', 'sendasemail'}:
      sendAsEmailBase = getEmailAddress(noUid=True)
    elif myarg == 'default':
      setDefault = True
    else:
      unknownArgumentExit()
  if not setDefault:
    missingArgumentExit('default')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    sendAsEmail = sendAsEmailBase if sendAsEmailBase else user
    if not smimeIdBase:
      smimeId = _getSmimeIds(gmail, user, i, count, sendAsEmail, 'update')
      if not smimeId:
        continue
    else:
      smimeId = smimeIdBase
    try:
      callGAPI(gmail.users().settings().sendAs().smimeInfo(), 'setDefault',
               throwReasons=GAPI.GMAIL_SMIME_THROW_REASONS,
               userId='me', sendAsEmail=sendAsEmail, id=smimeId)
      entityActionPerformedMessage([Ent.USER, user, Ent.SENDAS_ADDRESS, sendAsEmail, Ent.SMIME_ID, smimeId], Msg.DEFAULT_SMIME, i, count)
    except GAPI.notFound as e:
      entityActionFailedWarning([Ent.USER, user, Ent.SMIME_ID, smimeId], str(e), i, count)
    except (GAPI.forbidden, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)

# gam <UserTypeEntity> delete smime
#	[id <SmimeID>] [sendas|sendasemail <EmailAddress>]
def deleteSmime(users):
  smimeIdBase = None
  sendAsEmailBase = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'id':
      smimeIdBase = getString(Cmd.OB_SMIME_ID)
    elif myarg in {'sendas', 'sendasemail'}:
      sendAsEmailBase = getEmailAddress(noUid=True)
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    sendAsEmail = sendAsEmailBase if sendAsEmailBase else user
    if not smimeIdBase:
      smimeId = _getSmimeIds(gmail, user, i, count, sendAsEmail, 'delete')
      if not smimeId:
        continue
    else:
      smimeId = smimeIdBase
    try:
      callGAPI(gmail.users().settings().sendAs().smimeInfo(), 'delete',
               throwReasons=GAPI.GMAIL_SMIME_THROW_REASONS,
               userId='me', sendAsEmail=sendAsEmail, id=smimeId)
      entityActionPerformed([Ent.USER, user, Ent.SENDAS_ADDRESS, sendAsEmail, Ent.SMIME_ID, smimeId], i, count)
    except GAPI.notFound as e:
      entityActionFailedWarning([Ent.USER, user, Ent.SMIME_ID, smimeId], str(e), i, count)
    except (GAPI.forbidden, GAPI.invalidArgument, GAPI.permissionDenied) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)

# gam <UserTypeEntity> print smimes [todrive <ToDriveAttribute>*]
#	[primary|default|(sendas|sendasemail <EmailAddress>)]
# gam <UserTypeEntity> show smimes
#	[primary|default|(sendas|sendasemail <EmailAddress>)]
def printShowSmimes(users):
  csvPF = CSVPrintFile(['User', 'id', 'isDefault', 'issuerCn', 'expiration', 'encryptedKeyPassword', 'pem']) if Act.csvFormat() else None
  selectField = 'sendAsEmail'
  sendAsEmailBase = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'default', 'defaultonly'}:
      selectField = 'isDefault'
    elif myarg in {'primary', 'primaryonly'}:
      selectField = 'isPrimary'
    elif myarg in {'sendas', 'sendasemail'}:
      sendAsEmailBase = getEmailAddress(noUid=True)
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    try:
      if sendAsEmailBase:
        sendAsEmails = [sendAsEmailBase]
      else:
        results = callGAPIitems(gmail.users().settings().sendAs(), 'list', 'sendAs',
                                throwReasons=GAPI.GMAIL_THROW_REASONS,
                                userId='me', fields='sendAs(isDefault,isPrimary,sendAsEmail)')
        sendAsEmails = [sendAs['sendAsEmail'] for sendAs in results if sendAs.get(selectField, False)]
      jcount = len(sendAsEmails)
      if not csvPF:
        entityPerformActionSubItemModifierNumItems([Ent.USER, user], Ent.SMIME_ID, Act.MODIFIER_FROM, jcount, Ent.SENDAS_ADDRESS, i, count)
      else:
        printGettingEntityItemForWhom(Ent.SENDAS_ADDRESS, user, i, count)
      if sendAsEmails:
        j = 0
        for sendAsEmail in sendAsEmails:
          j += 1
          smimes = callGAPIitems(gmail.users().settings().sendAs().smimeInfo(), 'list', 'smimeInfo',
                                 throwReasons=GAPI.GMAIL_SMIME_THROW_REASONS,
                                 userId='me', sendAsEmail=sendAsEmail)
          kcount = len(smimes)
          if not csvPF:
            Ind.Increment()
            printEntity([Ent.SENDAS_ADDRESS, sendAsEmail], j, jcount)
            Ind.Increment()
            k = 0
            for smime in smimes:
              k += 1
              printEntity([Ent.SMIME_ID, smime['id']], k, kcount)
              Ind.Increment()
              printKeyValueList(['Default', smime.get('isDefault', False)])
              printKeyValueList(['Issuer CN', smime['issuerCn']])
              printKeyValueList(['Expiration', formatLocalTimestamp(smime['expiration'])])
              printKeyValueList(['Password', smime.get('encryptedKeyPassword', '')])
              printKeyValueList(['PEM', None])
              Ind.Increment()
              printKeyValueList([Ind.MultiLineText(smime['pem'])])
              Ind.Decrement()
              Ind.Decrement()
            Ind.Decrement()
            Ind.Decrement()
          elif smimes:
            for smime in smimes:
              smime['expiration'] = formatLocalTimestamp(smime['expiration'])
              csvPF.WriteRowTitles(flattenJSON(smime, flattened={'User': user}))
          elif GC.Values[GC.CSV_OUTPUT_USERS_AUDIT]:
            csvPF.WriteRowNoFilter({'User': user})
      elif csvPF and GC.Values[GC.CSV_OUTPUT_USERS_AUDIT]:
        csvPF.WriteRowNoFilter({'User': user})
    except (GAPI.forbidden, GAPI.invalidArgument) as e:
      entityActionFailedWarning([Ent.USER, user], str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
  if csvPF:
    csvPF.writeCSVfile('S/MIME')

def _showCSEItem(result, entityType, keyField, timeObjects, i, count, FJQC):
  if FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(result, timeObjects=timeObjects),
                         ensure_ascii=False, sort_keys=True))
    return
  Ind.Increment()
  printEntity([entityType, result[keyField]], i, count)
  Ind.Increment()
  showJSON(None, result, timeObjects=timeObjects)
  Ind.Decrement()
  Ind.Decrement()

def _initCSEKeyPairSkipObjects():
  return {'pem', 'kaclsData'}

def _resetCSEKeyPairSkipObjects(myarg, skipObjects):
  if myarg == 'showpem':
    skipObjects.discard('pem')
  elif myarg == 'showkaclsdata':
    skipObjects.discard('kaclsData')
  else:
    return False
  return True

def _stripCSEKeyPairSkipObjects(result, skipObjects):
  if 'pem' in skipObjects:
    result.pop('pem', None)
  if 'kaclsData' in skipObjects:
    for privateKeyMetadata in result.get('privateKeyMetadata', []):
      if 'kaclsKeyMetadata' in privateKeyMetadata:
        privateKeyMetadata['kaclsKeyMetadata'].pop('kaclsData', None)

CSE_IDENTITY_TIME_OBJECTS = {}
CSE_KEYPAIR_TIME_OBJECTS = {'disableTime'}

def _printShowCSEItems(users, entityType, keyField, timeObjects):
  csvPF = CSVPrintFile(['User', keyField]) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  skipObjects = _initCSEKeyPairSkipObjects() if entityType == Ent.CSE_KEYPAIR else set()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif entityType == Ent.CSE_KEYPAIR and _resetCSEKeyPairSkipObjects(myarg, skipObjects):
      pass
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    printGettingAllEntityItemsForWhom(entityType, user, i, count)
    kvList = [Ent.USER, user, entityType, None]
    try:
      if entityType == Ent.CSE_IDENTITY:
        results = callGAPIpages(gmail.users().settings().cse().identities(), 'list', 'cseIdentities',
                                throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.PERMISSION_DENIED],
                                userId='me', fields='nextPageToken,cseIdentities')
      else:
        results = callGAPIpages(gmail.users().settings().cse().keypairs(), 'list', 'cseKeyPairs',
                                throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.PERMISSION_DENIED],
                                userId='me', fields='nextPageToken,cseKeyPairs')

    except GAPI.permissionDenied as e:
      entityActionFailedWarning(kvList, str(e), i, count)
      continue
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
      continue
    jcount = len(results)
    if not csvPF:
      if not  FJQC.formatJSON:
        entityPerformActionNumItems([Ent.USER, user], jcount, entityType, i, count)
      j = 0
      for result in results:
        j += 1
        if entityType == Ent.CSE_KEYPAIR:
          _stripCSEKeyPairSkipObjects(result, skipObjects)
        _showCSEItem(result, entityType, keyField, timeObjects, j, jcount, FJQC)
    else:
      for result in results:
        row = flattenJSON(result, flattened={'User': user})
        if not FJQC.formatJSON:
          csvPF.WriteRowTitles(row)
        elif csvPF.CheckRowTitles(row):
          csvPF.WriteRowNoFilter({'User': user, keyField: result[keyField],
                                  'JSON':  json.dumps(cleanJSON(result, skipObjects=skipObjects, timeObjects=timeObjects),
                                                      ensure_ascii=False, sort_keys=True)})
  if csvPF:
    csvPF.writeCSVfile(Ent.Plural(entityType))

CSE_IDENTITY_ACTION_FUNCTION_MAP = {
  Act.CREATE: 'create',
  Act.UPDATE: 'patch',
  Act.DELETE: 'delete',
  Act.INFO: 'get',
  }

# gam <UserTypeEntity> create cseidentity
#	(primarykeypairid <KeyPairID>) | (signingkeypairid <KeyPairID> encryptionkeypairid <KeyPairID>)
#	[kpemail <EmailAddress>]
#	[formatjson]
# gam <UserTypeEntity> update cseidentity
#	(primarykeypairid <KeyPairID>) | (signingkeypairid <KeyPairID> encryptionkeypairid <KeyPairID>)
#	[kpemail <EmailAddress>]
#	[formatjson]
def createUpdateCSEIdentity(users):
  function = CSE_IDENTITY_ACTION_FUNCTION_MAP[Act.Get()]
  primaryKeyPairId = signingKeyPairId = encryptionKeyPairId = None
  FJQC = FormatJSONQuoteChar()
  kpEmail = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'primarykeypairid':
      primaryKeyPairId = getString(Cmd.OB_CSE_KEYPAIR_ID)
    elif myarg == 'signingkeypairid':
      signingKeyPairId = getString(Cmd.OB_CSE_KEYPAIR_ID)
    elif myarg == 'encryptionkeypairid':
      encryptionKeyPairId = getString(Cmd.OB_CSE_KEYPAIR_ID)
    elif myarg == 'kpemail':
      kpEmail = getEmailAddress(noUid=True)
    else:
      FJQC.GetFormatJSON(myarg)
  if primaryKeyPairId:
    if signingKeyPairId or encryptionKeyPairId:
      usageErrorExit(Msg.ARE_MUTUALLY_EXCLUSIVE.format('primarykeypairid', 'signingkeypairid/encryptionkeypairid'))
    identity = {'primaryKeyPairId': primaryKeyPairId, 'emailAddress': None}
    keyPairId = primaryKeyPairId
  elif signingKeyPairId or encryptionKeyPairId:
    if not signingKeyPairId or not encryptionKeyPairId:
      usageErrorExit(Msg.ARE_BOTH_REQUIRED.format('signingkeypairid', 'encryptionkeypairid'))
    identity = {'signAndEncryptKeyPairs': {'signingKeyPairId': signingKeyPairId, 'encryptionKeyPairId': encryptionKeyPairId},
                'emailAddress': None}
    keyPairId = f'{signingKeyPairId}/{encryptionKeyPairId}'
  else:
    missingArgumentExit('primarykeypairid|(signingkeypairid & encryptionkeypairid)')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    identity['emailAddress'] = user if not kpEmail else kpEmail
    kwargs = {'body': identity}
    if function == 'patch':
      kwargs['emailAddress'] = identity['emailAddress']
    kvList = [Ent.USER, user, Ent.CSE_IDENTITY, identity['emailAddress'], Ent.CSE_KEYPAIR, keyPairId]
    try:
      result = callGAPI(gmail.users().settings().cse().identities(), function,
                        throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.PERMISSION_DENIED, GAPI.INVALID_ARGUMENT, GAPI.NOT_FOUND, GAPI.ALREADY_EXISTS],
                        userId='me', **kwargs)
      if not  FJQC.formatJSON:
        entityActionPerformed(kvList, i, count)
      _showCSEItem(result, Ent.CSE_IDENTITY, 'emailAddress', CSE_IDENTITY_TIME_OBJECTS, i, count, FJQC)
    except (GAPI.permissionDenied, GAPI.invalidArgument, GAPI.notFound, GAPI.alreadyExists) as e:
      entityActionFailedWarning(kvList, str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)

# gam <UserTypeEntity> delete cseidentity [kpemail <EmailAddress>]
# gam <UserTypeEntity> info cseidentity [kpemail <EmailAddress>]
#	[formatjson]
def processCSEIdentity(users):
  function = CSE_IDENTITY_ACTION_FUNCTION_MAP[Act.Get()]
  FJQC = FormatJSONQuoteChar()
  kpEmail = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'kpemail':
      kpEmail = getEmailAddress(noUid=True)
    else:
      FJQC.GetFormatJSON(myarg)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    kwargs = {'cseEmailAddress': user if not kpEmail else kpEmail}
    kvList = [Ent.USER, user, Ent.CSE_IDENTITY, kwargs['cseEmailAddress']]
    try:
      result = callGAPI(gmail.users().settings().cse().identities(), function,
                        throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.PERMISSION_DENIED, GAPI.NOT_FOUND],
                        userId='me', **kwargs)
      if not FJQC.formatJSON:
        entityActionPerformed(kvList, i, count)
      if function != 'delete':
        _showCSEItem(result, Ent.CSE_IDENTITY, 'emailAddress', CSE_IDENTITY_TIME_OBJECTS, i, count, FJQC)
    except (GAPI.permissionDenied, GAPI.notFound) as e:
      entityActionFailedWarning(kvList, str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)

# gam <UserTypeEntity> show cseidentities
#	[formatjson]
# gam <UserTypeEntity> print cseidentities [todrive <ToDriveAttribute>*]
#	[formatjson [quotechar <Character>]]
def printShowCSEIdentities(users):
  _printShowCSEItems(users, Ent.CSE_IDENTITY, 'emailAddress', CSE_IDENTITY_TIME_OBJECTS)

# gam <UserTypeEntity> create csekeypair [incertdir <FilePath>] [inkeydir <FilePath>]
#	[addidentity [<Boolean>]] [kpemail <EmailAddress>]
#	[showpem] [showkaclsdata] [formatjson|returnidonly]
def createCSEKeyPair(users):
  def _getFolderPath(myarg):
    filepath = os.path.expanduser(getString(Cmd.OB_FILE_PATH))
    if not os.path.isdir(filepath):
      entityDoesNotExistExit(Ent.DIRECTORY, f'{myarg} {filepath}')
    return filepath

  FJQC = FormatJSONQuoteChar()
  incertdir = GC.Values[GC.GMAIL_CSE_INCERT_DIR]
  inkeydir = GC.Values[GC.GMAIL_CSE_INKEY_DIR]
  addIdentity = returnIdOnly = False
  kpEmail = None
  skipObjects = _initCSEKeyPairSkipObjects()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'incertdir':
      incertdir = _getFolderPath(myarg)
    elif myarg == 'inkeydir':
      inkeydir = _getFolderPath(myarg)
    elif myarg == 'addidentity':
      addIdentity = getBoolean()
    elif myarg == 'kpemail':
      kpEmail = getEmailAddress(noUid=True)
    elif myarg == 'returnidonly':
      returnIdOnly = True
    elif _resetCSEKeyPairSkipObjects(myarg, skipObjects):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  if not incertdir:
    missingArgumentExit('incertdir <FilePath>')
  if not inkeydir:
    missingArgumentExit('inkeydir <FilePath>')
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    kvList = [Ent.USER, user, Ent.CSE_KEYPAIR, None]
    smimeFilename = os.path.join(incertdir, user+'.p7pem')
    if not os.path.isfile(smimeFilename):
      entityActionNotPerformedWarning(kvList, Msg.FILE_NOT_FOUND.format(smimeFilename), i, count)
      continue
    smimeData = readFile(smimeFilename, mode='rb', continueOnError=True)
    if smimeData is None:
      continue
    kaclFilename = os.path.join(inkeydir, user+'.wrap')
    if not os.path.isfile(kaclFilename):
      entityActionNotPerformedWarning(kvList, Msg.FILE_NOT_FOUND.format(kaclFilename), i, count)
      continue
    jsonData = readFile(kaclFilename, mode='r', encoding=UTF8, continueOnError=True)
    if jsonData is None:
      continue
    try:
      keyData = json.loads(jsonData)
      key = 'kacls_url'
      kaclsUri = keyData[key]
      key = 'wrapped_private_key'
      kaclsData = keyData[key]
      cseKeyPair = {
        'pkcs7': smimeData.decode(UTF8),
        'privateKeyMetadata': [{'kaclsKeyMetadata': {'kaclsUri': kaclsUri, 'kaclsData': kaclsData}}]
      }
    except KeyError:
      entityActionNotPerformedWarning(kvList, Msg.JSON_KEY_NOT_FOUND.format(key, kaclFilename), i, count)
      continue
    except (IndexError, SyntaxError, TypeError, ValueError) as e:
      entityActionNotPerformedWarning(kvList, Msg.JSON_ERROR.format(str(e), kaclFilename) , i, count)
      continue
    try:
      result = callGAPI(gmail.users().settings().cse().keypairs(), 'create',
                        throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.PERMISSION_DENIED, GAPI.ALREADY_EXISTS],
                        userId='me', body=cseKeyPair)

      keyPairId = result['keyPairId']
      if not returnIdOnly:
        kvList[-1] = keyPairId
        if not  FJQC.formatJSON:
          entityActionPerformed(kvList, i, count)
        _stripCSEKeyPairSkipObjects(result, skipObjects)
        _showCSEItem(result, Ent.CSE_KEYPAIR, 'keyPairId', CSE_KEYPAIR_TIME_OBJECTS, i, count, FJQC)
      elif not addIdentity:
        writeStdout(f'{keyPairId}\n')
      if addIdentity:
        identity = {'keyPairId': keyPairId, 'emailAddress': user if not kpEmail else kpEmail}
        kvList = [Ent.USER, user, Ent.CSE_KEYPAIR, keyPairId, Ent.CSE_IDENTITY, identity['emailAddress']]
        result = callGAPI(gmail.users().settings().cse().identities(), 'create',
                          throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.PERMISSION_DENIED, GAPI.ALREADY_EXISTS],
                          userId='me', body=identity)
        if not returnIdOnly:
          if not  FJQC.formatJSON:
            entityActionPerformed(kvList, i, count)
          _showCSEItem(result, Ent.CSE_IDENTITY, 'emailAddress', CSE_IDENTITY_TIME_OBJECTS, i, count, FJQC)
        else:
          writeStdout(f'{keyPairId}-{user}\n')
    except (GAPI.permissionDenied, GAPI.alreadyExists) as e:
      entityActionFailedWarning(kvList, str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)

CSE_KEYPAIR_ACTION_FUNCTION_MAP = {
  Act.DISABLE: 'disable',
  Act.ENABLE: 'enable',
  Act.OBLITERATE: 'obliterate',
  Act.INFO: 'get',
  }

# gam <UserTypeEntity> disable csekeypair <KeyPairID>
#	[showpem] [showkaclsdata] [formatjson]
# gam <UserTypeEntity> enable csekeypair <KeyPairID>
#	[showpem] [showkaclsdata] [formatjson]
# gam <UserTypeEntity> obliterate csekeypair <KeyPairID>
# gam <UserTypeEntity> info csekey3pair <KeyPairID>
#	[showpem] [showkaclsdata] [formatjson]
def processCSEKeyPair(users):
  function = CSE_KEYPAIR_ACTION_FUNCTION_MAP[Act.Get()]
  keyPairId = getString(Cmd.OB_CSE_KEYPAIR_ID)
  FJQC = FormatJSONQuoteChar()
  skipObjects = _initCSEKeyPairSkipObjects()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if _resetCSEKeyPairSkipObjects(myarg, skipObjects):
      pass
    else:
      FJQC.GetFormatJSON(myarg)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    kvList = [Ent.USER, user, Ent.CSE_KEYPAIR, keyPairId]
    try:
      result = callGAPI(gmail.users().settings().cse().keypairs(), function,
                        throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.PERMISSION_DENIED, GAPI.INVALID_ARGUMENT,
                                                               GAPI.NOT_FOUND, GAPI.FAILED_PRECONDITION, GAPI.ALREADY_EXISTS],
                        userId='me', keyPairId=keyPairId)
      if function != 'obliterate':
        if not FJQC.formatJSON:
          entityActionPerformed(kvList, i, count)
        _stripCSEKeyPairSkipObjects(result, skipObjects)
        _showCSEItem(result, Ent.CSE_KEYPAIR, 'keyPairId', CSE_KEYPAIR_TIME_OBJECTS, i, count, FJQC)
      else:
        entityActionPerformed(kvList, i, count)
    except (GAPI.permissionDenied, GAPI.invalidArgument, GAPI.notFound, GAPI.failedPrecondition, GAPI.alreadyExists) as e:
      entityActionFailedWarning(kvList, str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)

# gam <UserTypeEntity> show csekeypairs
#	[showpem] [showkaclsdata] [formatjson]
# gam <UserTypeEntity> print csekeypairs [todrive <ToDriveAttribute>*]
#	[showpem] [showkaclsdata] [formatjson [quotechar <Character>]]
def printShowCSEKeyPairs(users):
  _printShowCSEItems(users, Ent.CSE_KEYPAIR, 'keyPairId', CSE_KEYPAIR_TIME_OBJECTS)

# gam <UserTypeEntity> signature|sig
#	<SignatureContent>
#	(replace <Tag> <String>)*
#	(replaceregex <REMatchPattern> <RESubstitution> <Tag> <String>)*
#	[html [<Boolean>]] [replyto <EmailAddress>] [default] [treatasalias <Boolean>]
#	[name <String>]
#	[primary]
def setSignature(users):
  tagReplacements = _initTagReplacements()
  signature, _, html = getStringOrFile('sig')
  body = {}
  primary = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'primary':
      primary = True
    elif myarg == 'html':
      html = getBoolean()
    else:
      getSendAsAttributes(myarg, body, tagReplacements)
  if not tagReplacements['subs']:
    body['signature'] = _processSignature(tagReplacements, signature, html)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    if tagReplacements['subs']:
      _getTagReplacementFieldValues(user, i, count, tagReplacements)
      body['signature'] = _processSignature(tagReplacements, signature, html)
    if primary:
      try:
        result = callGAPI(gmail.users().settings().sendAs(), 'list',
                          throwReasons=GAPI.GMAIL_THROW_REASONS,
                          userId='me')
        for sendas in result['sendAs']:
          if sendas.get('isPrimary', False):
            emailAddress = sendas['sendAsEmail']
            _processSendAs(user, i, count, Ent.SIGNATURE, emailAddress, i, count, gmail, 'patch', False, body=body, sendAsEmail=emailAddress, fields='')
            break
      except GAPI.serviceNotAvailable:
        userGmailServiceNotEnabledWarning(user, i, count)
    else:
      _processSendAs(user, i, count, Ent.SIGNATURE, user, i, count, gmail, 'patch', False, body=body, sendAsEmail=user, fields='')

VACATION_START_STARTED = 'Started'
VACATION_END_NOT_SPECIFIED = 'NotSpecified'

def _showVacation(user, i, count, result, showDisabled, sigReplyFormat):
  enabled = result['enableAutoReply']
  if not enabled and not showDisabled:
    return
  printEntity([Ent.USER, user, Ent.VACATION, None], i, count)
  Ind.Increment()
  printKeyValueList(['Enabled', enabled])
  printKeyValueList(['Contacts Only', result['restrictToContacts']])
  printKeyValueList(['Domain Only', result['restrictToDomain']])
  if 'startTime' in result:
    printKeyValueList(['Start Date', formatLocalDatestamp(result['startTime'])])
  elif enabled:
    printKeyValueList(['Start Date', VACATION_START_STARTED])
  if 'endTime' in result:
    printKeyValueList(['End Date', formatLocalDatestamp(result['endTime'])])
  elif enabled:
    printKeyValueList(['End Date', VACATION_END_NOT_SPECIFIED])
  printKeyValueList(['Subject', result.get('responseSubject', 'None')])
  if sigReplyFormat == SIG_REPLY_HTML:
    printKeyValueList(['Message', None])
    Ind.Increment()
    if result.get('responseBodyPlainText'):
      printKeyValueList([Ind.MultiLineText(result['responseBodyPlainText'])])
    elif result.get('responseBodyHtml'):
      printKeyValueList([Ind.MultiLineText(result['responseBodyHtml'])])
    else:
      printKeyValueList(['None'])
    Ind.Decrement()
  elif sigReplyFormat == SIG_REPLY_FORMAT:
    printKeyValueList(['Message', None])
    Ind.Increment()
    if result.get('responseBodyPlainText'):
      printKeyValueList([Ind.MultiLineText(result['responseBodyPlainText'])])
    elif result.get('responseBodyHtml'):
      printKeyValueList([Ind.MultiLineText(dehtml(result['responseBodyHtml']))])
    else:
      printKeyValueList(['None'])
    Ind.Decrement()
  else: # SIG_REPLY_COMPACT
    if result.get('responseBodyPlainText'):
      printKeyValueList(['Message', escapeCRsNLs(result['responseBodyPlainText'])])
    elif result.get('responseBodyHtml'):
      printKeyValueList(['Message', escapeCRsNLs(result['responseBodyHtml'])])
    else:
      printKeyValueList(['Message', 'None'])
  Ind.Decrement()

# gam <UserTypeEntity> vacation [<Boolean>] [subject <String>]
#	[<VacationMessageContent>
#	    (replace <Tag> <UserReplacement>)*
#	    (replaceregex <REMatchPattern> <RESubstitution> <Tag> <UserReplacement>)*]
#	[html [<Boolean>]] [contactsonly [<Boolean>]] [domainonly [<Boolean>]]
#	[start|startdate <Date>|Started] [end|enddate <Date>|NotSpecified]
def setVacation(users):
  body = {}
  if Cmd.PeekArgumentPresent(TRUE_VALUES) or Cmd.PeekArgumentPresent(FALSE_VALUES):
    body['enableAutoReply'] = getBoolean(None)
  responseBodyType = 'responseBodyPlainText'
  message = subject = None
  tagReplacements = _initTagReplacements()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'subject':
      subject = getString(Cmd.OB_STRING, minLen=0)
    elif myarg in SORF_MSG_FILE_ARGUMENTS:
      message, _, html = getStringOrFile(myarg)
      if html:
        responseBodyType = 'responseBodyHtml'
    elif _getTagReplacement(myarg, tagReplacements, True):
      pass
    elif myarg == 'html':
      if getBoolean():
        responseBodyType = 'responseBodyHtml'
    elif myarg == 'contactsonly':
      body['restrictToContacts'] = getBoolean()
    elif myarg == 'domainonly':
      body['restrictToDomain'] = getBoolean()
    elif myarg in {'start', 'startdate'}:
      body['startTime'] = getYYYYMMDD(returnTimeStamp=True, alternateValue=VACATION_START_STARTED)
    elif myarg in {'end', 'enddate'}:
      body['endTime'] = getYYYYMMDD(returnTimeStamp=True, alternateValue=VACATION_END_NOT_SPECIFIED)
    else:
      unknownArgumentExit()
  if message:
    if responseBodyType == 'responseBodyHtml':
      message = message.replace('\r', '').replace('\\n', '<br/>')
    else:
      message = message.replace('\r', '').replace('\\n', '\n')
    if tagReplacements['tags'] and not tagReplacements['subs']:
      message = _processTagReplacements(tagReplacements, message)
    body[responseBodyType] = message
  if subject:
    if tagReplacements['tags'] and not tagReplacements['subs']:
      subject = _processTagReplacements(tagReplacements, subject)
    body['responseSubject'] = subject
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    if (message or subject) and tagReplacements['subs']:
      _getTagReplacementFieldValues(user, i, count, tagReplacements)
      if message:
        body[responseBodyType] = _processTagReplacements(tagReplacements, message)
      if subject:
        body['responseSubject'] = _processTagReplacements(tagReplacements, subject)
    try:
      oldBody = callGAPI(gmail.users().settings(), 'getVacation',
                         throwReasons=GAPI.GMAIL_THROW_REASONS,
                         userId='me')
      if body.get(responseBodyType):
        oldBody.pop('responseBodyPlainText', None)
        oldBody.pop('responseBodyHtml', None)
      oldBody.update(body)
      result = callGAPI(gmail.users().settings(), 'updateVacation',
                        throwReasons=GAPI.GMAIL_THROW_REASONS+[GAPI.INVALID_ARGUMENT,
                                                               GAPI.FAILED_PRECONDITION, GAPI.PERMISSION_DENIED],
                        userId='me', body=oldBody)
      printEntity([Ent.USER, user, Ent.VACATION_ENABLED, result['enableAutoReply']], i, count)
    except (GAPI.invalidArgument, GAPI.failedPrecondition, GAPI.permissionDenied) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.VACATION_ENABLED, oldBody['enableAutoReply']], str(e), i, count)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)

# gam <UserTypeEntity> print vacation [compact] [enabledonly] [todrive <ToDriveAttribute>*]
# gam <UserTypeEntity> show vacation [compact|format|html] [enabledonly]
def printShowVacation(users):
  def _printVacation(user, result, showDisabled):
    enabled = result['enableAutoReply']
    if not enabled and not showDisabled:
      return
    row = {'User': user, 'enabled': enabled}
    row['contactsonly'] = result['restrictToContacts']
    row['domainonly'] = result['restrictToDomain']
    if 'startTime' in result:
      row['startdate'] = formatLocalDatestamp(result['startTime'])
    elif enabled:
      row['startdate'] = VACATION_START_STARTED
    if 'endTime' in result:
      row['enddate'] = formatLocalDatestamp(result['endTime'])
    elif enabled:
      row['enddate'] = VACATION_END_NOT_SPECIFIED
    row['subject'] = result.get('responseSubject', 'None')
    if result.get('responseBodyPlainText'):
      row['html'] = False
      row['message'] = escapeCRsNLs(result['responseBodyPlainText'])
    elif result.get('responseBodyHtml'):
      row['html'] = True
      if sigReplyFormat == SIG_REPLY_HTML:
        row['message'] = escapeCRsNLs(result['responseBodyHtml'])
      else:
        row['message'] = result['responseBodyHtml'].replace('\r', '').replace('\n', '')
    else:
      row['html'] = False
      row['message'] = 'None'
    csvPF.WriteRow(row)

  csvPF = CSVPrintFile(['User', 'enabled', 'contactsonly', 'domainonly',
                        'startdate', 'enddate', 'subject', 'html', 'message']) if Act.csvFormat() else None
  showDisabled = True
  sigReplyFormat = SIG_REPLY_HTML
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif (not csvPF and myarg in SIG_REPLY_OPTIONS_MAP) or (csvPF and myarg == 'compact'):
      sigReplyFormat = SIG_REPLY_OPTIONS_MAP[myarg]
    elif myarg == 'enabledonly':
      showDisabled = False
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, gmail = buildGAPIServiceObject(API.GMAIL, user, i, count)
    if not gmail:
      continue
    if csvPF:
      printGettingEntityItemForWhom(Ent.VACATION, user, i, count)
    try:
      result = callGAPI(gmail.users().settings(), 'getVacation',
                        throwReasons=GAPI.GMAIL_THROW_REASONS,
                        userId='me')
      if not csvPF:
        _showVacation(user, i, count, result, showDisabled, sigReplyFormat)
      else:
        _printVacation(user, result, showDisabled)
    except GAPI.serviceNotAvailable:
      userGmailServiceNotEnabledWarning(user, i, count)
  if csvPF:
    csvPF.writeCSVfile('Vacation')

def normalizeNoteName(noteName):
  if noteName.startswith('notes/'):
    return noteName
  return f'notes/{noteName}'

def _assignNoteOwner(note, user):
  for permission in note.get('permissions', []):
    if permission['role'] == 'OWNER':
      noteOwner = permission.get('user', {}).get('email', '').lower()
      note['owner'] = noteOwner
      note['ownedByMe'] = noteOwner == user
      break

def _checkNoteUserRole(note, user, role):
  for permission in note['permissions']:
    if permission['role'] == role and permission.get('user', {}).get('email', '').lower() == user:
      return True
  return False

def _showNoteListItems(listItems):
  printKeyValueList(['list', ''])
  Ind.Increment()
  kcount = len(listItems)
  k = 0
  for listItem in listItems:
    k += 1
    printKeyValueListWithCount(['item', ''], k, kcount)
    Ind.Increment()
    if 'text' in listItem and 'text' in listItem['text']:
      printKeyValueList(['text', listItem['text']['text']])
    if 'checked' in listItem:
      printKeyValueList(['checked', listItem['checked']])
    if 'childListItems' in listItem:
      _showNoteListItems(listItem['childListItems'])
    Ind.Decrement()
  Ind.Decrement()

def _showNotePermissions(permissions):
  printKeyValueList(['permissions', ''])
  Ind.Increment()
  kcount = len(permissions)
  k = 0
  for permission in permissions:
    k += 1
    printKeyValueListWithCount(['name', permission['name']], k, kcount)
    Ind.Increment()
    for field in ['role', 'deleted']:
      if field in permission:
        printKeyValueList([field, permission[field]])
    for field in ['user', 'group']:
      if field in permission:
        printKeyValueList([field, permission[field]['email']])
        break
    else:
      if 'email' in permission:
        printKeyValueList(['email', permission['email']])
    if 'family' in permission:
      family = permission['family']
      if 'text' in family:
        printKeyValueList(['family', family['text']['text']])
      elif 'list' in family:
        _showNoteListItems(family['list']['listItems'])
    Ind.Decrement()
  Ind.Decrement()

def _showNoteAttachments(attachments):
  printKeyValueList(['attachments', ''])
  Ind.Increment()
  kcount = len(attachments)
  k = 0
  for attachment in attachments:
    k += 1
    printKeyValueListWithCount(['name', attachment['name']], k, kcount)
    if 'mimeType' in attachment:
      Ind.Increment()
      printKeyValueList(['mimeType', ','.join(attachment['mimeType'])])
      Ind.Decrement()
  Ind.Decrement()

NOTES_TIME_OBJECTS = {'createTime', 'updateTime', 'trashTime'}

def _showNote(note, j=0, jcount=0, FJQC=None, compact=False):
  if FJQC is not None and FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(note, timeObjects=NOTES_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.NOTE, note['name']], j, jcount)
  Ind.Increment()
  printKeyValueList(['title', note.get('title', None)])
  for field in NOTES_TIME_OBJECTS:
    if field in note:
      printKeyValueList([field, formatLocalTime(note[field])])
  if 'trashed' in note:
    printKeyValueList(['trashed', note['trashed']])
  for field in ['owner', 'ownedByMe']:
    if field in note:
      printKeyValueList([field, note[field]])
  if 'permissions' in note:
    _showNotePermissions(note['permissions'])
  if 'attachments' in note:
    _showNoteAttachments(note['attachments'])
  body = note.get('body', {})
  if 'text' in body and 'text' in body['text']:
    if not compact:
      printKeyValueList(['text', None])
      Ind.Increment()
      printKeyValueList([Ind.MultiLineText(body['text']['text'])])
      Ind.Decrement()
    else:
      printKeyValueList(['text', escapeCRsNLs(body['text']['text'])])
  elif 'list' in body and 'listItems' in body['list']:
    _showNoteListItems(body['list']['listItems'])
  Ind.Decrement()

# gam <UserTypeEntity> create note [title <String>]
#	<NoteContent>
#       [missingtextvalue <String>]
#	[copyacls [copyowneraswriter]
#	[compact|formatjson|nodetails]
def createNote(users):
  def fixTextItem(item):
    if 'text' in item:
      if item['text']:
        item['text'] = unescapeCRsNLs(item['text'])
        return
    if missingTextValue:
      item['text'] = missingTextValue

  def fixListItem(item):
    if 'listItems' in item:
      if item['listItems']:
        for listItem in item['listItems']:
          fixTextItem(listItem['text'])
        return
    if missingTextValue:
      item['listItems'] = [{'checked': False, 'text': {'text': missingTextValue}}]

  FJQC = FormatJSONQuoteChar()
  compact = False
  showDetails = True
  missingTextValue = ''
  copyACLs = copyOwnerAsWriter = False
  body = {'title': '', 'body': {}}
  copyUsers = []
  copyGroups = []
  noteOwner = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'title':
      body['title'] = getString(Cmd.OB_STRING, minLen=1, maxLen=1000)
    elif myarg in SORF_TEXT_ARGUMENTS:
      msgText, _, _ = getStringOrFile(myarg, minLen=1, unescapeCRLF=True)
      body['body']['text'] = {'text': msgText}
    elif myarg == 'missingtextvalue':
      missingTextValue = getString(Cmd.OB_STRING, minLen=1)
    elif myarg == 'json':
      jsonData = getJSON([])
      if not body['title']:
        body['title'] = jsonData.get('title', missingTextValue)
      body['body'] = jsonData.get('body', {})
      if 'text' in body['body']:
        fixTextItem(body['body']['text'])
      elif 'list' in body['body']:
        fixListItem(body['body']['list'])
      for permission in jsonData.get('permissions', []):
        if permission['role'] == 'WRITER':
          if 'user' in permission:
            copyUsers.append(permission['user']['email'].lower())
          elif 'group' in permission:
            copyGroups.append(permission['group']['email'].lower())
        elif permission['role'] == 'OWNER':
          if 'user' in permission:
            noteOwner = permission['user']['email'].lower()
    elif myarg == 'copyacls':
      copyACLs = True
    elif myarg == 'copyowneraswriter':
      copyOwnerAsWriter = True
    elif myarg == 'compact':
      compact = True
    elif myarg == 'nodetails':
      showDetails = False
    else:
      FJQC.GetFormatJSON(myarg)
  if not body['body']:
    choices = list(SORF_TEXT_ARGUMENTS)
    choices.append('json')
    missingArgumentExit('|'.join(choices))
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, keep = buildGAPIServiceObject(API.KEEP, user, i, count)
    if not keep:
      continue
    if copyACLs:
      rbody = {'requests': []}
      for addr in copyUsers:
        if addr != user:
          rbody['requests'].append({'parent': None,
                                    'permission': {'role': 'WRITER', 'user': {'email': addr}}})
      for addr in copyGroups:
        rbody['requests'].append({'parent': None,
                                  'permission': {'role': 'WRITER', 'group': {'email': addr}}})
      if copyOwnerAsWriter and noteOwner and noteOwner != user:
        rbody['requests'].append({'parent': None,
                                  'permission': {'role': 'WRITER', 'user': {'email': noteOwner}}})
      kcount = len(rbody['requests'])
    try:
      note = callGAPI(keep.notes(), 'create',
                      throwReasons=GAPI.KEEP_THROW_REASONS,
                      body=body)
      name = note['name']
      entityKVList = [Ent.USER, user, Ent.NOTE, name]
      _assignNoteOwner(note, user)
      if copyACLs and kcount > 0:
        for request in rbody['requests']:
          request['parent'] = name
        try:
          callGAPI(keep.notes().permissions(), 'batchCreate',
                   throwReasons=GAPI.KEEP_THROW_REASONS,
                   parent=name, body=rbody)
          note = callGAPI(keep.notes(), 'get',
                          throwReasons=GAPI.KEEP_THROW_REASONS,
                          name=name)
        except (GAPI.badRequest, GAPI.permissionDenied, GAPI.invalidArgument, GAPI.notFound):
          pass
        except GAPI.serviceNotAvailable:
          pass
      if showDetails:
        _showNote(note, FJQC=FJQC, compact=compact)
      else:
        entityActionPerformed(entityKVList, i, count)
    except (GAPI.badRequest, GAPI.invalidArgument, GAPI.notFound) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.NOTE, body['title']], str(e), i, count)
    except GAPI.authError:
      userKeepServiceNotEnabledWarning(user, i, count)

NOTES_FIELDS_CHOICE_MAP = {
  'attachments': 'attachments',
  'body': 'body',
  'createtime': 'createTime',
  'name': 'name',
  'permissions': 'permissions',
  'title': 'title',
  'trashed': 'trashed',
  'trashtime': 'trashTime',
  'updatetime': 'updateTime',
  }

# gam <UserTypeEntity> info note <NotesNameEntity>
#	[fields <NotesFieldList>]
#	[compact|formatjson]
# gam <UserTypeEntity> delete note <NotesNameEntity>
def deleteInfoNotes(users):
  function = 'delete' if Act.Get() == Act.DELETE else 'get'
  fieldsList = []
  noteNameEntity = getUserObjectEntity(Cmd.OB_NAME, Ent.NOTE)
  showPermissions = True
  if function == 'get':
    FJQC = FormatJSONQuoteChar()
    compact = False
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if getFieldsList(myarg, NOTES_FIELDS_CHOICE_MAP, fieldsList, initialField='name'):
        pass
      elif myarg == 'compact':
        compact = True
      else:
        FJQC.GetFormatJSON(myarg)
    fields = getFieldsFromFieldsList(fieldsList)
  else:
    FJQC = None
    checkForExtraneousArguments()
  if fieldsList and 'permissions' not in fieldsList:
    showPermissions = False
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, keep, noteNames, jcount = _validateUserGetObjectList(user, i, count, noteNameEntity,
                                                               api=API.KEEP, showAction=FJQC is None or not FJQC.formatJSON)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for name in noteNames:
      j += 1
      name = normalizeNoteName(name)
      try:
        if function == 'get':
          note = callGAPI(keep.notes(), function,
                          throwReasons=GAPI.KEEP_THROW_REASONS,
                          name=name, fields=fields)
          if showPermissions:
            _assignNoteOwner(note, user)
          _showNote(note, j, jcount, FJQC, compact)
        else:
          callGAPI(keep.notes(), function,
                   throwReasons=GAPI.KEEP_THROW_REASONS,
                   name=name)
          entityActionPerformed([Ent.USER, user, Ent.NOTE, name], j, jcount)
      except (GAPI.badRequest, GAPI.invalidArgument, GAPI.notFound) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.NOTE, name], str(e), j, jcount)
      except GAPI.authError:
        userKeepServiceNotEnabledWarning(user, i, count)
        break
    Ind.Decrement()

NOTES_ROLE_CHOICE_MAP = {
  'owner': 'OWNER',
  'writer': 'WRITER',
  }

NOTES_COUNTS_MAP = {
  'OWNER': 'noteOwner',
  'WRITER': 'noteWriter',
  }

# gam <UserTypeEntity> show notes
#	[fields <NotesFieldList>] [filter <String>]
#	[role owner|writer]
#	[countsonly]
#	[compact] [formatjson]
# gam <UserTypeEntity> print notes [todrive <ToDriveAttribute>*]
#	[fields <NotesFieldList>] [filter <String>]
#	[role owner|writer]
#	[countsonly]
#	[formatjson [quotechar <Character>]]
def printShowNotes(users):
  csvPF = CSVPrintFile(['User', 'name', 'title', 'owner', 'ownedByMe']) if Act.csvFormat() else None
  if csvPF:
    csvPF.SetNoEscapeChar(True)
  FJQC = FormatJSONQuoteChar(csvPF)
  compact = countsOnly = False
  fieldsList = []
  noteFilter = None
  role = None
  showPermissions = True
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif getFieldsList(myarg, NOTES_FIELDS_CHOICE_MAP, fieldsList, initialField=['name', 'title']):
      pass
    elif myarg == 'filter':
      noteFilter = getString(Cmd.OB_STRING)
    elif myarg == 'role':
      role = getChoice(NOTES_ROLE_CHOICE_MAP, mapChoice=True)
    elif not csvPF and myarg == 'compact':
      compact = True
    elif myarg == 'countsonly':
      countsOnly = True
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  if FJQC.formatJSON:
    GC.Values[GC.CSV_OUTPUT_CONVERT_CR_NL] = False
  if fieldsList and 'permissions' not in fieldsList:
    showPermissions = False
    fieldsList.append('permissions')
    if csvPF:
      if not countsOnly:
        if not FJQC.formatJSON:
          csvPF.RemoveTitles(['owner', 'ownedByMe'])
        else:
          csvPF.RemoveJSONTitles(['owner', 'ownedByMe'])
  if countsOnly and csvPF:
    if not FJQC.formatJSON:
      csvPF.SetTitles(['User', 'noteOwner', 'noteWriter', 'totalNotes'])
    else:
      csvPF.SetJSONTitles(['User', 'JSON'])
  fields = getItemFieldsFromFieldsList('notes', fieldsList, returnItemIfNoneList=False)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, keep = buildGAPIServiceObject(API.KEEP, user, i, count)
    if not keep:
      continue
    if csvPF:
      printGettingEntityItemForWhom(Ent.NOTE, user, i, count)
      pageMessage = getPageMessageForWhom()
    else:
      pageMessage = None
    try:
      notes = callGAPIpages(keep.notes(), 'list', 'notes',
                            pageMessage=pageMessage,
                            throwReasons=GAPI.KEEP_THROW_REASONS,
                            filter=noteFilter, fields=fields)
      if countsOnly:
        noteCounts = {'User': user, 'noteOwner': 0, 'noteWriter': 0, 'totalNotes': 0}
        for note in notes:
          noteCounts['totalNotes'] += 1
          for permission in note['permissions']:
            if permission.get('user', {}).get('email', '').lower() == user:
              noteCounts[NOTES_COUNTS_MAP[permission['role']]] += 1
              break
        if not csvPF:
          if not FJQC.formatJSON:
            printEntityKVList([Ent.USER, user], ['noteOwner', noteCounts['noteOwner'],
                                                 'noteWriter', noteCounts['noteWriter'],
                                                 'totalNotes', noteCounts['totalNotes']], i, count)
          else:
            printLine(json.dumps(cleanJSON(noteCounts), ensure_ascii=False, sort_keys=True))
        else:
          row = {'User': user, 'noteOwner': noteCounts['noteOwner'], 'noteWriter': noteCounts['noteWriter'],
                 'totalNotes': noteCounts['totalNotes']}
          if not FJQC.formatJSON:
            csvPF.WriteRowTitles(row)
          elif csvPF.CheckRowTitles(row):
            row = {'User': noteCounts.pop('User')}
            row['JSON'] = json.dumps(cleanJSON(noteCounts), ensure_ascii=False, sort_keys=True)
            csvPF.WriteRowNoFilter(row)
      elif not csvPF:
        jcount = len(notes)
        if not FJQC.formatJSON:
          entityPerformActionNumItems([Ent.USER, user], jcount, Ent.NOTE, i, count)
        Ind.Increment()
        j = 0
        for note in notes:
          j += 1
          if showPermissions:
            _assignNoteOwner(note, user)
          if role is None or _checkNoteUserRole(note, user, role):
            if not showPermissions:
              note.pop('permissions', None)
            _showNote(note, j, jcount, FJQC, compact)
        Ind.Decrement()
      else:
        for note in notes:
          if showPermissions:
            _assignNoteOwner(note, user)
          if role is None or _checkNoteUserRole(note, user, role):
            if not showPermissions:
              note.pop('permissions', None)
            row = flattenJSON(note, flattened={'User': user}, timeObjects=NOTES_TIME_OBJECTS)
            if not FJQC.formatJSON:
              csvPF.WriteRowTitles(row)
            elif csvPF.CheckRowTitles(row):
              row = {'User': user, 'name': note['name'], 'title': note.get('title', '')}
              if showPermissions:
                row.update({'owner': note['owner'], 'ownedByMe': note['ownedByMe']})
              row['JSON'] = json.dumps(cleanJSON(note, timeObjects=NOTES_TIME_OBJECTS),
                                       ensure_ascii=False, sort_keys=True)
              csvPF.WriteRowNoFilter(row)
    except (GAPI.badRequest, GAPI.invalidArgument, GAPI.notFound) as e:
      entityActionFailedWarning([Ent.USER, user, Ent.NOTE, None], str(e), i, count)
    except GAPI.authError:
      userKeepServiceNotEnabledWarning(user, i, count)
  if csvPF:
    csvPF.writeCSVfile('Notes')

GET_NOTE_HTTP_ERROR_PATTERN = re.compile(r'^.*\'description\': \'(.*)\'')

# gam <UserTypeEntity> get noteattachments <NotesNameEntity>
#	[targetfolder <FilePath>] [targetname <FileName>] [overwrite [<Boolean>]]
#	[<DriveFileParentAttribute>]
def getNoteAttachments(users):
  noteNameEntity = getUserObjectEntity(Cmd.OB_NAME, Ent.NOTE)
  targetFolderPattern = GC.Values[GC.DRIVE_DIR]
  targetNamePattern = None
  overwrite = False
  body = {}
  parentParms = initDriveFileAttributes()
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'targetfolder':
      targetFolderPattern = os.path.expanduser(getString(Cmd.OB_FILE_PATH))
    elif myarg == 'targetname':
      targetNamePattern = getString(Cmd.OB_FILE_NAME)
    elif myarg == 'overwrite':
      overwrite = getBoolean()
    elif getDriveFileParentAttribute(myarg, parentParms):
      pass
    else:
      unknownArgumentExit()
  parentSpecified = _driveFileParentSpecified(parentParms)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, keep, noteNames, jcount = _validateUserGetObjectList(user, i, count, noteNameEntity,
                                                               api=API.KEEP, showAction=True)
    if jcount == 0:
      continue
    if parentSpecified:
      _ , drive = buildGAPIServiceObject(API.DRIVE3, user, i, count)
      if not drive:
        continue
      if not _getDriveFileParentInfo(drive, user, i, count, body, parentParms):
        continue
    _, userName, _ = splitEmailAddressOrUID(user)
    targetFolder = _substituteForUser(targetFolderPattern, user, userName)
    if not os.path.isdir(targetFolder):
      os.makedirs(targetFolder)
    targetName = _substituteForUser(targetNamePattern, user, userName) if targetNamePattern else None
    Ind.Increment()
    j = 0
    for name in noteNames:
      j += 1
      name = normalizeNoteName(name)
      try:
        result = callGAPI(keep.notes(), 'get',
                          throwReasons=GAPI.KEEP_THROW_REASONS,
                          name=name, fields='title,attachments')
        title = result.get('title', 'attachment')
        kcount = len(result['attachments'])
        entityPerformActionNumItems([Ent.NOTE, name], kcount, Ent.ATTACHMENT, j, jcount)
        Ind.Increment()
        k = 0
        for attachment in result['attachments']:
          k += 1
          attachmentName = attachment['name'][attachment['name'].find('attachments'):]
          entityValueList = [Ent.ATTACHMENT, attachmentName]
          mimeTypes = attachment.get('mimeType', [])
          if not mimeTypes:
            entityActionNotPerformedWarning(entityValueList, Msg.MIMETYPE_NOT_PRESENT_IN_ATTACHMENT, k, kcount)
            continue
          mimeType = mimeTypes[0]
          localFilename, filename = uniqueFilename(targetFolder, f"{targetName or cleanFilename(title)}-{k}{MIMETYPE_EXTENSION_MAP.get(mimeType, '')}", overwrite)
          request = keep.media().download(name=attachment['name'], mimeType=mimeType)
          f = openFile(localFilename, 'wb', continueOnError=True)
          if f is None:
            continue
          downloader = googleapiclient.http.MediaIoBaseDownload(f, request)
          done = False
          downloadOK = False
          try:
            while not done:
              status, done = downloader.next_chunk()
              if status.progress() < 1.0:
                entityActionPerformedMessage(entityValueList, f'{status.progress():>7.2%}', k, kcount)
            entityModifierNewValueActionPerformed(entityValueList, Act.MODIFIER_TO, localFilename, k, kcount)
            downloadOK = True
          except (IOError, httplib2.HttpLib2Error) as e:
            entityModifierNewValueActionFailedWarning(entityValueList, Act.MODIFIER_TO, localFilename, str(e), k, kcount)
          except googleapiclient.http.HttpError as e:
            mg = GET_NOTE_HTTP_ERROR_PATTERN.match(str(e))
            if mg:
              entityModifierNewValueActionFailedWarning(entityValueList, Act.MODIFIER_TO, localFilename, mg.group(1), k, kcount)
            else:
              entityModifierNewValueActionFailedWarning(entityValueList, Act.MODIFIER_TO, localFilename, str(e), k, kcount)
          closeFile(f, True)
          if downloadOK and parentSpecified:
            body['name'] = filename
            body['mimeType'] = mimeType
            media_body = googleapiclient.http.MediaFileUpload(filename, mimetype=mimeType, resumable=True)
            Act.Set(Act.CREATE)
            try:
              result = callGAPI(drive.files(), 'create',
                                throwReasons=GAPI.DRIVE_USER_THROW_REASONS+[GAPI.FORBIDDEN, GAPI.INSUFFICIENT_PERMISSIONS,
                                                                            GAPI.INSUFFICIENT_PARENT_PERMISSIONS,
                                                                            GAPI.INVALID, GAPI.BAD_REQUEST, GAPI.CANNOT_ADD_PARENT,
                                                                            GAPI.FILE_NOT_FOUND, GAPI.UNKNOWN_ERROR, GAPI.STORAGE_QUOTA_EXCEEDED,
                                                                            GAPI.TEAMDRIVES_SHARING_RESTRICTION_NOT_ALLOWED,
                                                                            GAPI.TEAMDRIVE_FILE_LIMIT_EXCEEDED, GAPI.TEAMDRIVE_HIERARCHY_TOO_DEEP],
                                media_body=media_body, body=body, fields='id,name', supportsAllDrives=True)
              entityModifierNewValueActionPerformed([Ent.USER, user, Ent.DRIVE_FILE, f'{result["name"]}({result["id"]})'],
                                                    Act.MODIFIER_WITH_CONTENT_FROM, localFilename, k, kcount)
            except (GAPI.forbidden, GAPI.insufficientFilePermissions, GAPI.insufficientParentPermissions,
                    GAPI.invalid, GAPI.badRequest, GAPI.cannotAddParent,
                    GAPI.fileNotFound, GAPI.unknownError, GAPI.storageQuotaExceeded, GAPI.teamDrivesSharingRestrictionNotAllowed,
                    GAPI.teamDriveFileLimitExceeded, GAPI.teamDriveHierarchyTooDeep) as e:
              entityActionFailedWarning([Ent.USER, user, Ent.DRIVE_FILE, body['name']], str(e), k, kcount)
            except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
              userDriveServiceNotEnabledWarning(user, str(e), i, count)
            Act.Set(Act.DOWNLOAD)
        Ind.Decrement()
      except (GAPI.badRequest, GAPI.invalidArgument, GAPI.notFound) as e:
        entityActionFailedWarning([Ent.NOTE, name], str(e), j, jcount)
      except GAPI.authError:
        userKeepServiceNotEnabledWarning(user, i, count)
        break
    Ind.Decrement()

# gam <UserTypeEntity> create noteacl <NotesNameEntity>
#	(user|group <EmailAddress>)+
#	(json [charset <Charset>] <JSONData>)|(json file <FileName> [charset <Charset>])
#	[nodetails]
def createNotesACLs(users):
  noteNameEntity = getUserObjectEntity(Cmd.OB_NAME, Ent.NOTES_ACLS)
  body = {'requests': []}
  showDetails = True
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'user', 'group'}:
      body['requests'].append({'parent': None,
                               'permission': {'role': 'WRITER', myarg: {'email': getEmailAddress(noUid=True)}}})
    elif myarg == 'json':
      jsonData = getJSON([])
      for permission in jsonData.get('permissions', []):
        if permission['role'] == 'WRITER':
          if 'user' in permission:
            body['requests'].append({'parent': None,
                                     'permission': {'role': 'WRITER', 'user': {'email': permission['user']['email']}}})
          elif 'group' in permission:
            body['requests'].append({'parent': None,
                                     'permission': {'role': 'WRITER', 'group': {'email': permission['group']['email']}}})
    elif myarg == 'nodetails':
      showDetails = False
    else:
      unknownArgumentExit()
  kcount = len(body['requests'])
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, keep, noteNames, jcount = _validateUserGetObjectList(user, i, count, noteNameEntity, api=API.KEEP)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for name in noteNames:
      j += 1
      name = normalizeNoteName(name)
      entityKVList = [Ent.USER, user, Ent.NOTE, name]
      rbody = body.copy()
      for request in rbody['requests']:
        request['parent'] = name
      try:
        permissions = callGAPI(keep.notes().permissions(), 'batchCreate',
                               throwReasons=GAPI.KEEP_THROW_REASONS+[GAPI.FAILED_PRECONDITION],
                               parent=name, body=rbody)
        entityNumItemsActionPerformed(entityKVList, kcount, Ent.NOTE_ACL, j, jcount)
        if showDetails:
          Ind.Increment()
          _showNotePermissions(permissions['permissions'])
          Ind.Decrement()
      except (GAPI.badRequest, GAPI.invalidArgument, GAPI.notFound, GAPI.failedPrecondition) as e:
        entityActionFailedWarning(entityKVList, str(e), i, count)
      except GAPI.authError:
        userKeepServiceNotEnabledWarning(user, i, count)
        break

# gam <UserTypeEntity> delete noteacl <NotesNameEntity>
#	(user|group <EmailAddress>)+
#	(json [charset <Charset>] <JSONData>)|(json file <FileName> [charset <Charset>])
def deleteNotesACLs(users):
  noteNameEntity = getUserObjectEntity(Cmd.OB_NAME, Ent.NOTES_ACLS)
  emails = {'user': set(), 'group': set()}
  body = {'names': []}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg in {'user', 'group'}:
      emails[myarg].add(getEmailAddress(noUid=True).lower())
    elif myarg == 'json':
      jsonData = getJSON([])
      for permission in jsonData.get('permissions', []):
        if permission['role'] == 'WRITER':
          loc = permission['name'].find('/permissions')
          body['names'].append(permission['name'][loc+1:])
    else:
      unknownArgumentExit()
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, keep, noteNames, jcount = _validateUserGetObjectList(user, i, count, noteNameEntity, api=API.KEEP)
    if jcount == 0:
      continue
    Ind.Increment()
    j = 0
    for name in noteNames:
      j += 1
      name = normalizeNoteName(name)
      rbody = body.copy()
      if emails['user'] or emails['group']:
        try:
          note = callGAPI(keep.notes(), 'get',
                          throwReasons=GAPI.KEEP_THROW_REASONS,
                          name=name, fields='permissions')
          for permission in note['permissions']:
            if permission['role'] == 'WRITER':
              if (('user' in permission and permission['user']['email'].lower() in emails['user']) or
                  ('group' in permission and permission['group']['email'].lower() in emails['group'])):
                rbody['names'].append(permission['name'])
        except (GAPI.badRequest, GAPI.permissionDenied, GAPI.invalidArgument, GAPI.notFound) as e:
          entityActionFailedWarning([Ent.USER, user, Ent.NOTE, name], str(e), i, count)
          break
        except GAPI.authError:
          userKeepServiceNotEnabledWarning(user, i, count)
          break
      for k, perm in enumerate(rbody['names']):
        if perm.startswith('notes/'):
          pass
        elif perm.startswith('permissions'):
          rbody['names'][k] = f'{name}/{perm}'
        else:
          rbody['names'][k] = f'{name}/permissions/{perm}'
      kcount = len(rbody['names'])
      entityKVList = [Ent.USER, user, Ent.NOTE, name]
      try:
        callGAPI(keep.notes().permissions(), 'batchDelete',
                 throwReasons=GAPI.KEEP_THROW_REASONS,
                 parent=name, body=rbody)
        entityNumItemsActionPerformed(entityKVList, kcount, Ent.NOTE_ACL, j, jcount)
      except (GAPI.badRequest, GAPI.invalidArgument, GAPI.notFound) as e:
        entityActionFailedWarning(entityKVList, str(e), i, count)
      except GAPI.authError:
        userKeepServiceNotEnabledWarning(user, i, count)
        break

def verifyTasksServiceEnabled(svc, user, i, count):
  try:
    callGAPIpages(svc.tasklists(), 'list', 'items',
                  throwReasons=GAPI.TASKLIST_THROW_REASONS,
                  maxItems=1)
    return True
  except (GAPI.notFound, GAPI.badRequest, GAPI.invalid):
    userTasksServiceNotEnabledWarning(user, i, count)
    return False

def getTaskLists(svc, user, i, count):
  try:
    results = callGAPIpages(svc.tasklists(), 'list', 'items',
                            pageMessage=getPageMessageForWhom(),
                            throwReasons=GAPI.TASKLIST_THROW_REASONS,
                            maxResults=100)
  except (GAPI.badRequest, GAPI.invalid) as e:
    entityActionFailedWarning([Ent.USER, user, Ent.TASKLIST, None], str(e), i, count)
    results = None
  except GAPI.notFound:
    userTasksServiceNotEnabledWarning(user, i, count)
    results = None
  return results

def getTaskListIDfromTitle(svc, userTasklists, title, user, i, count):
  if userTasklists is None:
    printGettingEntityItemForWhom(Ent.TASKLIST, user, i, count)
    userTasklists = getTaskLists(svc, user, i, count)
    if userTasklists is None:
      return None, None
  for userTasklist in userTasklists:
    if userTasklist['title'] == title:
      return userTasklists, userTasklist['id']
  return userTasklists, None

TASK_SKIP_OBJECTS = ['selfLink']
TASK_TIME_OBJECTS = ['completed', 'updated']

def _showTask(tasklist, task, j=0, jcount=0, FJQC=None, compact=False):
  task['tasklistId'] = tasklist
  task['taskId'] = f"{tasklist}/{task['id']}"
  if FJQC is not None and FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(task, skipObjects=TASK_SKIP_OBJECTS, timeObjects=TASK_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.TASK, task['taskId']], j, jcount)
  Ind.Increment()
  showJSON(None, task, skipObjects=TASK_SKIP_OBJECTS+['notes'], timeObjects=TASK_TIME_OBJECTS)
  field = 'notes'
  if field in task:
    if not compact:
      printKeyValueList([field, None])
      Ind.Increment()
      printKeyValueList([Ind.MultiLineText(task[field])])
      Ind.Decrement()
    else:
      printKeyValueList(['notes', escapeCRsNLs(task[field])])
  Ind.Decrement()

TASK_STATUS_MAP = {
  'completed': 'completed',
  'needsaction': 'needsAction',
  }

def getTaskAttribute(myarg, body):
  if myarg == 'title':
    body[myarg] = getString(Cmd.OB_STRING, minLen=0)
  elif myarg == 'notes':
    body[myarg] = getStringWithCRsNLs()
  elif myarg == 'status':
    body[myarg] = getChoice(TASK_STATUS_MAP, mapChoice=True)
  elif myarg == 'due':
    body[myarg] = getTimeOrDeltaFromNow()
  else:
    return False
  return True

def getTaskMoveAttribute(myarg, kwargs):
  if myarg == 'parent':
    kwargs[myarg] = getString(Cmd.OB_TASK_ID)
  elif myarg == 'previous':
    kwargs[myarg] = getString(Cmd.OB_TASK_ID)
  else:
    return False
  return True

# gam <UserTypeEntity> create task <TasklistEntity>
#	<TaskAttribute>* [parent <TaskID>] [previous <TaskID>]
#	[compact|formatjson|returnidonly]
# gam <UserTypeEntity> update task <TasklistIDTaskIDEntity>
#	<TaskAttribute>*
#	[compact|formatjson]
# gam <UserTypeEntity> info task <TasklistIDTaskIDEntity>
#	[compact|formatjson]
# gam <UserTypeEntity> delete task <TasklistIDTaskIDEntity>
# gam <UserTypeEntity> move task <TasklistIDTaskIDEntity>
#	[parent <TaskID>] [previous <TaskID>]
#	[compact|formatjson]
def processTasks(users):
  action = Act.Get()
  if action != Act.CREATE:
    tasklistTaskEntity = getUserObjectEntity(Cmd.OB_TASKLIST_ID_TASK_ID_ENTITY, Ent.TASK, shlexSplit=True)
  else:
    tasklistTaskEntity = getUserObjectEntity(Cmd.OB_TASKLIST_ID_ENTITY, Ent.TASK, shlexSplit=True)
  if action in {Act.DELETE, Act.CLEAR}:
    FJQC = None
    checkForExtraneousArguments()
  else:
    FJQC = FormatJSONQuoteChar()
    body = {}
    kwargs = {}
    compact = returnIdOnly = False
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if action in {Act.CREATE, Act.UPDATE} and getTaskAttribute(myarg, body):
        pass
      elif action in {Act.CREATE, Act.MOVE} and getTaskMoveAttribute(myarg, kwargs):
        pass
      elif action == Act.CREATE and myarg == 'returnidonly':
        returnIdOnly = True
      elif myarg == 'compact':
        compact = True
      else:
        FJQC.GetFormatJSON(myarg)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, svc, tasklistTasks, jcount = _validateUserGetObjectList(user, i, count, tasklistTaskEntity,
                                                                  api=API.TASKS, showAction=FJQC is None or not FJQC.formatJSON)
    if jcount == 0 or not verifyTasksServiceEnabled(svc, user, i, count):
      continue
    userTasklists = None
    Ind.Increment()
    j = 0
    for tasklistTask in tasklistTasks:
      j += 1
      if action != Act.CREATE:
        if '/' not in tasklistTask:
          continue
        tasklist, task = tasklistTask.split('/', 1)
      else:
        tasklist = tasklistTask
        task = body.get('title', '')
        if tasklist.startswith('tltitle:'):
          tasklistTitle = tasklist[8:]
          userTasklists, tasklist = getTaskListIDfromTitle(svc, userTasklists, tasklistTitle, user, i, count)
          if tasklist is None:
            entityActionFailedWarning([Ent.USER, user, Ent.TASKLIST, tasklistTitle, Ent.TASK, task], Msg.TASKLIST_TITLE_NOT_FOUND, j, jcount)
            continue
      try:
        if action == Act.DELETE:
          callGAPI(svc.tasks(), 'delete',
                   throwReasons=GAPI.TASK_THROW_REASONS,
                   tasklist=tasklist, task=task)
          entityActionPerformed([Ent.USER, user, Ent.TASKLIST, tasklist, Ent.TASK, task], j, jcount)
        elif action == Act.INFO:
          result = callGAPI(svc.tasks(), 'get',
                            throwReasons=GAPI.TASK_THROW_REASONS,
                            tasklist=tasklist, task=task)
          _showTask(tasklist, result, j, jcount, FJQC, compact)
        else:
          if action == Act.CREATE:
            result = callGAPI(svc.tasks(), 'insert',
                              throwReasons=GAPI.TASK_THROW_REASONS,
                              tasklist=tasklist, body=body, **kwargs)
            if returnIdOnly:
              writeStdout(f"{result['id']}\n")
              continue
          elif action == Act.UPDATE:
            result = callGAPI(svc.tasks(), 'patch',
                              throwReasons=GAPI.TASK_THROW_REASONS,
                              tasklist=tasklist, task=task, body=body)
          else: #elif action == Act.MOVE
            result = callGAPI(svc.tasks(), 'move',
                              throwReasons=GAPI.TASK_THROW_REASONS,
                              tasklist=tasklist, task=task, **kwargs)
          if not FJQC.formatJSON:
            entityActionPerformed([Ent.USER, user, Ent.TASKLIST, tasklist, Ent.TASK, result['id']], j, jcount)
          Ind.Increment()
          _showTask(tasklist, result, j, jcount, FJQC, compact)
          Ind.Decrement()
      except (GAPI.badRequest, GAPI.permissionDenied, GAPI.invalid, GAPI.notFound) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.TASKLIST, tasklist, Ent.TASK, task], str(e), j, jcount)
      except GAPI.serviceNotAvailable:
        Ind.Decrement()
        userTasksServiceNotEnabledWarning(user, i, count)
        break
    Ind.Decrement()

TASK_ORDERBY_CHOICE_MAP = {
  'completed': ('completed', 'No date'),
  'due': ('due', 'No date'),
  'updated': ('updated', 'No date'),
  }

TASK_QUERY_TIME_MAP = {
  'completedmin': 'completedMin',
  'completedmax': 'completedMax',
  'duemin': 'dueMin',
  'duemax': 'dueMax',
  'updatedmin': 'updatedMin',
  }
TASK_QUERY_STATE_MAP = {
  'showcompleted': 'showCompleted',
  'showdeleted': 'showDeleted',
  'showhidden': 'showHidden',
  }

# gam <UserTypeEntity> show tasks [tasklists <TasklistEntity>]
#	[completedmin <Time>] [completedmax <Time>]
#	[duemin <Time>] [duemax <Time>]
#	[updatedmin <Time>]
#	[showcompleted [<Boolean>]] [showdeleted [<Boolean>]] [showhidden [<Boolean>]] [showall]
#	[orderby completed|due|updated]
#	[countsonly|compact|formatjson]
# gam <UserTypeEntity> print tasks [tasklists <TasklistEntity>] [todrive <ToDriveAttribute>*]
#	[completedmin <Time>] [completedmax <Time>]
#	[duemin <Time>] [duemax <Time>]
#	[updatedmin <Time>]
#	[showcompleted [<Boolean>]] [showdeleted [<Boolean>]] [showhidden [<Boolean>]] [showall]
#	[orderby completed|due|updated]
#	[countsonly | (formatjson [quotechar <Character>])]
def printShowTasks(users):
  def _showTaskAndChildren(tasklist, taskId, k, compact):
    if taskId in taskParentsProcessed:
      return k
    taskParentsProcessed.add(taskId)
    if taskId in taskData:
      k += 1
      _showTask(tasklist, taskData[taskId], k, kcount, FJQC, compact)
      Ind.Increment()
    for task in taskParents.get(taskId, []):
      k = _showTaskAndChildren(tasklist, task['taskId'], k, compact)
    if taskId in taskData:
      Ind.Decrement()
    return k

  def _printTaskAndChildren(tasklist, taskId):
    if taskId in taskParentsProcessed:
      return
    taskParentsProcessed.add(taskId)
    if taskId in taskData:
      task = taskData[taskId]
      task['tasklistId'] = tasklist
      task['taskId'] = f"{tasklist}/{task['id']}"
      row = flattenJSON(task, flattened={'User': user}, skipObjects=TASK_SKIP_OBJECTS, timeObjects=TASK_TIME_OBJECTS)
      if not FJQC.formatJSON:
        csvPF.WriteRowTitles(row)
      elif csvPF.CheckRowTitles(row):
        row = {'User': user, 'id': task['id'], 'tasklistId': tasklist, 'taskId': task['taskId'], 'title': task.get('title', '')}
        row['JSON'] = json.dumps(cleanJSON(task, skipObjects=TASK_SKIP_OBJECTS, timeObjects=TASK_TIME_OBJECTS),
                                 ensure_ascii=False, sort_keys=True)
        csvPF.WriteRowNoFilter(row)
      for task in taskParents.get(taskId, []):
        _printTaskAndChildren(tasklist, task['taskId'])

  csvPF = CSVPrintFile(['User', 'tasklistId', 'id', 'taskId', 'title', 'status', 'due', 'updated', 'completed'], 'sortall') if Act.csvFormat() else None
  if csvPF:
    csvPF.SetNoEscapeChar(True)
  CSVTitle = 'Tasks'
  FJQC = FormatJSONQuoteChar(csvPF)
  tasklistEntity = None
  kwargs = {'maxResults': 100}
  compact = countsOnly = False
  orderBy = orderByNoDataValue = None
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg in {'tasklist', 'tasklists'}:
      tasklistEntity = getUserObjectEntity(Cmd.OB_TASKLIST_ID_ENTITY, Ent.TASKLIST, shlexSplit=True)
    elif myarg in TASK_QUERY_TIME_MAP:
      kwargs[TASK_QUERY_TIME_MAP[myarg]] = getTimeOrDeltaFromNow()
    elif myarg in TASK_QUERY_STATE_MAP:
      kwargs[TASK_QUERY_STATE_MAP[myarg]] = getBoolean()
    elif myarg == 'showall':
      for field in TASK_QUERY_STATE_MAP.values():
        kwargs[field] = True
    elif not csvPF and myarg == 'compact':
      compact = True
    elif myarg == 'orderby':
      orderBy, orderByNoDataValue = getChoice(TASK_ORDERBY_CHOICE_MAP, mapChoice=True)
    elif myarg == 'countsonly':
      countsOnly = True
      if csvPF:
        csvPF.SetTitles(['User', CSVTitle])
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, False)
  if csvPF and FJQC.formatJSON:
    csvPF.SetJSONTitles(['User', 'tasklistId', 'id', 'taskId', 'title', 'JSON'])
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    if tasklistEntity is None:
      user, svc = buildGAPIServiceObject(API.TASKS, user, i, count)
      if not svc:
        continue
      printGettingEntityItemForWhom(Ent.TASKLIST, user, i, count)
      results = getTaskLists(svc, user, i, count)
      if results is None:
        continue
      tasklists = [tasklist['id'] for tasklist in results]
      jcount = len(tasklists)
    else:
      userTasklists = None
      user, svc, tasklists, jcount = _validateUserGetObjectList(user, i, count, tasklistEntity, api=API.TASKS,
                                                                showAction=FJQC is None or not FJQC.formatJSON)
      if jcount == 0:
        continue
    taskCount = 0
#    if not csvPF and not FJQC.formatJSON:
#      entityPerformActionNumItems([Ent.USER, user], jcount, Ent.TASKLIST, i, count)
    Ind.Increment()
    j = 0
    for tasklist in tasklists:
      j += 1
      if tasklist.startswith('tltitle:'):
        tasklistTitle = tasklist[8:]
        userTasklists, tasklist = getTaskListIDfromTitle(svc, userTasklists, tasklistTitle, user, i, count)
        if tasklist is None:
          entityActionFailedWarning([Ent.USER, user, Ent.TASKLIST, tasklistTitle], Msg.TASKLIST_TITLE_NOT_FOUND, j, jcount)
          continue
      printGettingEntityItemForWhom(Ent.TASK, tasklist, j, jcount)
      try:
        tasks = callGAPIpages(svc.tasks(), 'list', 'items',
                              pageMessage=getPageMessageForWhom(),
                              throwReasons=GAPI.TASK_THROW_REASONS,
                              tasklist=tasklist, **kwargs)
        kcount = len(tasks)
        if countsOnly:
          taskCount += kcount
          continue
        taskParents = {None: []}
        taskData = {}
        taskParentsProcessed = set()
        if orderBy is None:
          for task in tasks:
            taskData[task['id']] = task
            parent = task.get('parent', None)
            taskInfo = {'taskId': task['id'], 'parent': parent, 'position': task['position']}
            taskParents.setdefault(parent, [])
            taskParents[parent].append(taskInfo)
          for parent in taskParents:
            taskParents[parent].sort(key=lambda k: k['position'])
        else:
          for task in tasks:
            taskData[task['id']] = task
            if orderBy not in task:
              task[orderBy] = orderByNoDataValue
            taskInfo = {'taskId': task['id'], orderBy: task[orderBy],
                        'parent': task.get('parent', ' '), 'position': task['position']}
            taskParents[None].append(taskInfo)
          taskParents[None].sort(key=lambda k: (k[orderBy], k['parent'], k['position']))
        if not csvPF:
          if not FJQC.formatJSON:
            entityPerformActionNumItems([Ent.TASKLIST, tasklist], kcount, Ent.TASK, j, jcount)
          Ind.Increment()
          k = 0
          for parent in taskParents.values():
            for task in parent:
              k = _showTaskAndChildren(tasklist, task['taskId'], k, compact)
          Ind.Decrement()
        else:
          for parent in taskParents.values():
            for task in parent:
              _printTaskAndChildren(tasklist, task['taskId'])
      except (GAPI.badRequest, GAPI.invalid, GAPI.notFound) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.TASKLIST, tasklist, Ent.TASK, None], str(e), i, count)
      except GAPI.serviceNotAvailable:
        userTasksServiceNotEnabledWarning(user, i, count)
    Ind.Decrement()
    if countsOnly:
      if csvPF:
        csvPF.WriteRowTitles({'User': user, CSVTitle: taskCount})
      else:
        printEntityKVList([Ent.USER, user], [CSVTitle, taskCount], i, count)
  if csvPF:
    csvPF.writeCSVfile(CSVTitle)

TASKLIST_SKIP_OBJECTS = ['selfLink']
TASKLIST_TIME_OBJECTS = ['updated']

def _showTasklist(tasklist, j=0, jcount=0, FJQC=None):
  if FJQC is not None and FJQC.formatJSON:
    printLine(json.dumps(cleanJSON(tasklist, skipObjects=TASKLIST_SKIP_OBJECTS, timeObjects=TASKLIST_TIME_OBJECTS), ensure_ascii=False, sort_keys=True))
    return
  printEntity([Ent.TASKLIST, tasklist['id']], j, jcount)
  Ind.Increment()
  showJSON(None, tasklist, skipObjects=TASKLIST_SKIP_OBJECTS, timeObjects=TASKLIST_TIME_OBJECTS)
  Ind.Decrement()

# gam <UserTypeEntity> create tasklist
#	[title <String>]
#	[returnidonly] [formatjson]
# gam <UserTypeEntity> update tasklist <TasklistEntity>
#	[title <String>]
#	[formatjson]
# gam <UserTypeEntity> info tasklist <TasklistEntity>
#	[formatjson]
# gam <UserTypeEntity> delete tasklist <TasklistEntity>
# gam <UserTypeEntity> clear tasklist <TasklistEntity>
def processTasklists(users):
  action = Act.Get()
  if action != Act.CREATE:
    tasklistEntity = getUserObjectEntity(Cmd.OB_TASKLIST_ID_ENTITY, Ent.TASKLIST, shlexSplit=True)
  else:
    tasklistEntity = {'item': Ent.TASKLIST, 'list': [None], 'dict': None}
  if action in {Act.DELETE, Act.CLEAR}:
    FJQC = None
    checkForExtraneousArguments()
  else:
    FJQC = FormatJSONQuoteChar()
    body = {}
    returnIdOnly = False
    while Cmd.ArgumentsRemaining():
      myarg = getArgument()
      if action in {Act.CREATE, Act.UPDATE} and myarg == 'title':
        body['title'] = getString(Cmd.OB_STRING, minLen=0)
      elif action == Act.CREATE and myarg == 'returnidonly':
        returnIdOnly = True
      else:
        FJQC.GetFormatJSON(myarg)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    userTasklists = None
    user, svc, tasklists, jcount = _validateUserGetObjectList(user, i, count, tasklistEntity,
                                                              api=API.TASKS,
                                                              showAction=action != Act.CREATE and (FJQC is None or not FJQC.formatJSON))
    if jcount == 0 or not verifyTasksServiceEnabled(svc, user, i, count):
      continue
    Ind.Increment()
    j = 0
    for tasklist in tasklists:
      j += 1
      if action != Act.CREATE:
        if tasklist.startswith('tltitle:'):
          tasklistTitle = tasklist[8:]
          userTasklists, tasklist = getTaskListIDfromTitle(svc, userTasklists, tasklistTitle, user, i, count)
          if userTasklists is None:
            continue
          if tasklist is None:
            entityActionFailedWarning([Ent.USER, user, Ent.TASKLIST, tasklistTitle], Msg.TASKLIST_TITLE_NOT_FOUND, j, jcount)
            continue
      try:
        if action == Act.DELETE:
          callGAPI(svc.tasklists(), 'delete',
                   throwReasons=GAPI.TASK_THROW_REASONS,
                   tasklist=tasklist)
          entityActionPerformed([Ent.USER, user, Ent.TASKLIST, tasklist], j, jcount)
        elif action == Act.CLEAR:
          callGAPI(svc.tasks(), 'clear',
                   throwReasons=GAPI.TASK_THROW_REASONS,
                   tasklist=tasklist)
          entityActionPerformed([Ent.USER, user, Ent.TASKLIST, tasklist], j, jcount)
        elif action == Act.INFO:
          result = callGAPI(svc.tasklists(), 'get',
                            throwReasons=GAPI.TASK_THROW_REASONS,
                            tasklist=tasklist)
          _showTasklist(result, j, jcount, FJQC)
        else:
          if action == Act.CREATE:
            result = callGAPI(svc.tasklists(), 'insert',
                              throwReasons=GAPI.TASK_THROW_REASONS,
                              body=body)
            if returnIdOnly:
              writeStdout(f"{result['id']}\n")
              continue
          else: # Act.UPDATE
            result = callGAPI(svc.tasklists(), 'patch',
                              throwReasons=GAPI.TASK_THROW_REASONS,
                              tasklist=tasklist, body=body)
          if not FJQC.formatJSON:
            entityActionPerformed([Ent.USER, user, Ent.TASKLIST, result['id']], i, count)
          Ind.Increment()
          _showTasklist(result, j, jcount, FJQC)
          Ind.Decrement()
      except (GAPI.badRequest, GAPI.invalid, GAPI.notFound) as e:
        entityActionFailedWarning([Ent.USER, user, Ent.TASKLIST, tasklist], str(e), j, jcount)
      except GAPI.serviceNotAvailable:
        Ind.Decrement()
        userTasksServiceNotEnabledWarning(user, i, count)
        break
    Ind.Decrement()

# gam <UserTypeEntity> show tasklists
#	[countsonly|formatjson]
# gam <UserTypeEntity> print tasklists [todrive <ToDriveAttribute>*]
#	[countsonly | (formatjson [quotechar <Character>])]
def printShowTasklists(users):
  csvPF = CSVPrintFile(['User', 'id', 'title']) if Act.csvFormat() else None
  if csvPF:
    csvPF.SetNoEscapeChar(True)
  CSVTitle = 'TaskLists'
  FJQC = FormatJSONQuoteChar(csvPF)
  countsOnly = False
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    elif myarg == 'countsonly':
      countsOnly = True
      if csvPF:
        csvPF.SetTitles(['User', CSVTitle])
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, svc = buildGAPIServiceObject(API.TASKS, user, i, count)
    if not svc:
      continue
    printGettingAllEntityItemsForWhom(Ent.TASKLIST, user, i, count)
    tasklists = getTaskLists(svc, user, i, count)
    if tasklists is None:
      continue
    jcount = len(tasklists)
    if countsOnly:
      if csvPF:
        csvPF.WriteRowTitles({'User': user, CSVTitle: jcount})
      else:
        printEntityKVList([Ent.USER, user], [CSVTitle, jcount], i, count)
    elif not csvPF:
      if not  FJQC.formatJSON:
        entityPerformActionNumItems([Ent.USER, user], jcount, Ent.TASKLIST, i, count)
      Ind.Increment()
      j = 0
      for tasklist in tasklists:
        j += 1
        _showTasklist(tasklist, j, jcount, FJQC)
      Ind.Decrement()
    else:
      for tasklist in tasklists:
        row = flattenJSON(tasklist, flattened={'User': user}, skipObjects=TASKLIST_SKIP_OBJECTS, timeObjects=TASKLIST_TIME_OBJECTS)
        if not FJQC.formatJSON:
          csvPF.WriteRowTitles(row)
        elif csvPF.CheckRowTitles(row):
          row = {'User': user, 'id': tasklist['id'], 'title': tasklist.get('title', '')}
          row['JSON'] = json.dumps(cleanJSON(tasklist, skipObjects=TASKLIST_SKIP_OBJECTS, timeObjects=TASKLIST_TIME_OBJECTS),
                                   ensure_ascii=False, sort_keys=True)
          csvPF.WriteRowNoFilter(row)
  if csvPF:
    csvPF.writeCSVfile(CSVTitle)

# gam <UserTypeEntity> import tasklist <Filename> [charset <Charset>]))
def importTasklist(users):
  filename = getString(Cmd.OB_FILE_NAME)
  encoding = getCharSet()
  try:
    jsonData = json.loads(readFile(filename, encoding=encoding))
  except (IndexError, KeyError, SyntaxError, TypeError, ValueError) as e:
    Cmd.Backup()
    usageErrorExit(Msg.JSON_ERROR.format(str(e), filename))
  if jsonData.get('kind', '') != 'tasks#taskLists':
    Cmd.Backup()
    usageErrorExit(f'{"Not a Tasks takeout JSON file"}: {filename}')
  parentIdMap = {}
  cleanData = {'items': []}
  for tasklist in jsonData.get('items', []):
    cleanTasklist = {'title': tasklist['title'], 'items': []}
    for task in tasklist.get('items', []):
      cleanTask = {}
      for field in ['id', 'parent', 'title', 'notes', 'status', 'due', 'completed', 'deleted']:
        if field in task:
          cleanTask[field] = task[field]
        parentIdMap[task['id']] = None
      cleanTasklist['items'].append(cleanTask.copy())
    cleanData['items'].append(cleanTasklist.copy())
  if not cleanData['items']:
    writeStdout('No tasks to import\n')
    return
  i, count, users = getEntityArgument(users)
  for user in users:
    i += 1
    user, svc = buildGAPIServiceObject(API.TASKS, user, i, count)
    if not svc:
      continue
    if not verifyTasksServiceEnabled(svc, user, i, count):
      continue
    for tasklist in cleanData['items']:
      body = {'title': tasklist['title']}
      result = callGAPI(svc.tasklists(), 'insert',
                        throwReasons=GAPI.TASK_THROW_REASONS,
                        body=body)
      tasklistId = result['id']
      for task in tasklist['items']:
        taskId = task.pop('id')
        if 'parent' in task:
          parent = parentIdMap[task.pop('parent')]
        else:
          parent = None
        result = callGAPI(svc.tasks(), 'insert',
                          throwReasons=GAPI.TASK_THROW_REASONS,
                          tasklist=tasklistId, parent=parent, body=task)
        parentIdMap[taskId] = result['id']

def getCRMOrgId():
  setTrueCustomerId()
  _, crm = buildGAPIServiceObject(API.CLOUDRESOURCEMANAGER, None)
  results = callGAPI(crm.organizations(), 'search',
                     query=f'directorycustomerid:{GC.Values[GC.CUSTOMER_ID]}',
                     pageSize=1, fields='organizations/name')
  orgs = results.get('organizations')
  if not orgs:
    # return nothing and let calling API deal with it
    # since caller knows what GCP role would serve best
    return None
  return orgs[0].get('name')

def CAARoleErrorExit(caa):
  sa_email = caa._http.credentials.signer_email
  systemErrorExit(NO_SA_ACCESS_CONTEXT_MANAGER_EDITOR_ROLE_RC,
                  f'Please grant service account {sa_email} the Access Context Manager Editor role in your GCP organization.')

def normalizeCAALevelName(caa, name):
  if name.startswith('accessPolicies/'):
    return name
  ap_name = getAccessPolicy(caa)
  return f'{ap_name}/accessLevels/{name}'

def buildCAAServiceObject():
  _, caa = buildGAPIServiceObject(API.ACCESSCONTEXTMANAGER, None)
  return caa

def getAccessPolicy(caa=None):
  if not caa:
    caa = buildCAAServiceObject()
  parent = getCRMOrgId()
  if not parent:
    CAARoleErrorExit(caa)
  try:
    aps = callGAPIpages(caa.accessPolicies(), 'list', 'accessPolicies',
                        throwReasons=[GAPI.PERMISSION_DENIED],
                        parent=parent, fields='nextPageToken,accessPolicies(name,title)')
  except GAPI.permissionDenied:
    CAARoleErrorExit(caa)
  if not aps:
    systemErrorExit(ACCESS_POLICY_ERROR_RC, 'You don\'t seem to have any access policies. That is odd.')
  elif len(aps) == 1:
    return aps[0]['name']
  for ap in aps:
    if ap.get('title') == 'Access policy created in Cloud Identity Console':
      return ap['name']
  systemErrorExit(ACCESS_POLICY_ERROR_RC, ' Could not find a org level access policy. That is odd.')

CAA_OS_TYPE_MAP = {
  'desktopmac': 'DESKTOP_MAC',
  'desktopwindows': 'DESKTOP_WINDOWS',
  'desktoplinux': 'DESKTOP_LINUX',
  'desktopchromeos': 'DESKTOP_CHROME_OS',
  'verifieddesktopchromeos': 'VERIFIED_DESKTOP_CHROME_OS',
  'android': 'ANDROID',
  'ios': 'IOS',
  }

def CAABuildOsConstraints():
  consts_obj = []
  for constraint in getString(Cmd.OB_STRING).split(','):
    new_const = {}
    if ':' in constraint:
      osType, new_const['minimumVersion'] = constraint.split(':')
    else:
      osType = constraint
    osType = osType.lower().replace('_', '')
    if osType not in CAA_OS_TYPE_MAP:
      invalidChoiceExit(osType, CAA_OS_TYPE_MAP, True)
    if osType != 'verifieddesktopchromeos':
      new_const['osType'] = CAA_OS_TYPE_MAP[osType]
    else:
      new_const['osType'] = 'DESKTOP_CHROME_OS'
      new_const['requireVerifiedChromeOs'] = True
    consts_obj.append(new_const)
  return consts_obj

CAA_ALLOWED_DEVICE_MANAGEMENT_LEVELS_MAP = {
  'basic': 'BASIC',
  'advanced': 'COMPLETE',
  'complete': 'COMPLETE',
  'none': 'NONE',
  }
CAA_ALLOWED_ENCRYPTIION_STATUS_MAP = {
  'encryptionunsupported': 'ENCRYPTION_UNSUPPORTED',
  'encrypted': 'ENCRYPTED',
  'unencrypted': 'UNENCRYPTED',
  }

def CAABuildDevicePolicy():
  device_policy = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'requirescreenlock':
      device_policy['requireScreenLock'] = getBoolean()
    elif myarg == 'allowedencryptionstatuses':
      device_policy['allowedEncryptionStatuses'] = []
      for status in getString(Cmd.OB_STRING).lower().split(','):
        if status not in CAA_ALLOWED_ENCRYPTIION_STATUS_MAP:
          invalidChoiceExit(status, CAA_ALLOWED_ENCRYPTIION_STATUS_MAP, True)
        device_policy['allowedEncryptionStatuses'].append(CAA_ALLOWED_ENCRYPTIION_STATUS_MAP[status])
    elif myarg == 'osconstraints':
      device_policy['osConstraints'] = CAABuildOsConstraints()
    elif myarg == 'alloweddevicemanagementlevels':
      device_policy['allowedDeviceManagementLevels'] = []
      for level in getString(Cmd.OB_STRING).lower().split(','):
        if level not in CAA_ALLOWED_DEVICE_MANAGEMENT_LEVELS_MAP:
          invalidChoiceExit(level, CAA_ALLOWED_DEVICE_MANAGEMENT_LEVELS_MAP, True)
        device_policy['allowedDeviceManagementLevels'].append(CAA_ALLOWED_DEVICE_MANAGEMENT_LEVELS_MAP[level])
    elif myarg == 'requireadminapproval':
      device_policy['requireAdminApproval'] = getBoolean()
    elif myarg == 'requirecorpowned':
      device_policy['requireCorpOwned'] = getBoolean()
    elif myarg == 'enddevicepolicy':
      break
    else:
      unknownArgumentExit()
  return device_policy

ISO3166_1_ALPHA_2_CODES = {
  "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ",
  "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ",
  "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ",
  "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR",
  "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY",
  "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP",
  "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY",
  "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ",
  "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ",
  "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW",
  "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ",
  "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ",
  "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW",
  }

def validateISO3166_1_alpha2_code(region):
  if region not in ISO3166_1_ALPHA_2_CODES:
    Cmd.Backup()
    expectedArgumentExit(Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_INVALID_CHOICE][1].format(region), Msg.INVALID_REGION)

def CAABuildCondition():
  condition = {}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'ipsubnetworks':
      condition['ipSubnetworks'] = getString(Cmd.OB_STRING_LIST).split(',')
    elif myarg == 'devicepolicy':
      condition['devicePolicy'] = CAABuildDevicePolicy()
    elif myarg == 'requiredaccesslevels':
      condition['requiredAccessLevels'] = getString(Cmd.OB_STRING_LIST).split(',')
    elif myarg == 'negate':
      condition['negate'] = getBoolean()
    elif myarg == 'members':
      condition['members'] = getString(Cmd.OB_STRING_LIST).split(',')
    elif myarg == 'regions':
      condition['regions'] = getString(Cmd.OB_STRING_LIST).upper().split(',')
      for region in condition['regions']:
        validateISO3166_1_alpha2_code(region)
    elif myarg == 'endcondition':
      break
    else:
      unknownArgumentExit()
  return condition

CAA_COMBINING_FUNCTIONS_MAP = {
  'and': 'AND',
  'or': 'OR',
  }

def CAABuildBasicLevel():
  basic_level = {'conditions': []}
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'combiningfunction':
      basic_level['combiningFunction'] = getChoice(CAA_COMBINING_FUNCTIONS_MAP, mapChoice=True)
    elif myarg == 'condition':
      basic_level['conditions'].append(CAABuildCondition())
    else:
      unknownArgumentExit()
  return basic_level

def CAABuildLevel(body):
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if myarg == 'basic':
      body['basic'] = CAABuildBasicLevel()
    elif myarg == 'custom':
      body['custom'] = {'expr': {'expression': getString(Cmd.OB_STRING), 'title': 'expr'}}
    elif myarg == 'description':
      body['description'] = getString(Cmd.OB_STRING, minLen=0)
    elif myarg == 'json':
      body.update(getJSON(['name']))
    else:
      unknownArgumentExit()

# gam create caalevel <String> [description <String>] (basic <CAABasicAttribute>+)|(custom <String>)|<JSONData>
def doCreateCAALevel():
  caa = buildCAAServiceObject()
  ap_name = getAccessPolicy(caa)
  title = getString(Cmd.OB_STRING).replace(' ', '_')
  allowed_title_chars = string.ascii_letters + string.digits + '_'
  name = ''.join([c for c in title if c in allowed_title_chars])[:50]
  name = f'{ap_name}/accessLevels/{name}'
  body = {'name': name, 'title': title}
  CAABuildLevel(body)
  try:
    callGAPI(caa.accessPolicies().accessLevels(), 'create',
             throwReasons=[GAPI.ALREADY_EXISTS, GAPI.FAILED_PRECONDITION, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
             parent=ap_name, body=body)
    entityActionPerformed([Ent.CAA_LEVEL, name])
  except (GAPI.alreadyExists, GAPI.failedPrecondition, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.CAA_LEVEL, name], str(e))
  except GAPI.permissionDenied:
    CAARoleErrorExit(caa)

# gam update caalevel <CAALevelName> [description <String>] (basic <CAABasicAttribute>+)|(custom <String>)|<JSONData>
def doUpdateCAALevel():
  caa = buildCAAServiceObject()
  name = normalizeCAALevelName(caa, getString(Cmd.OB_ACCESS_LEVEL_NAME))
  body = {}
  CAABuildLevel(body)
  updateMask = ','.join(body.keys())
  try:
    callGAPI(caa.accessPolicies().accessLevels(), 'patch',
             throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
             name=name, updateMask=updateMask, body=body)
    entityActionPerformed([Ent.CAA_LEVEL, name])
  except (GAPI.notFound, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.CAA_LEVEL, name], str(e))
  except GAPI.permissionDenied:
    CAARoleErrorExit(caa)

# gam delete caalevel <CAALevelName>
def doDeleteCAALevel():
  caa = buildCAAServiceObject()
  name = normalizeCAALevelName(caa, getString(Cmd.OB_ACCESS_LEVEL_NAME))
  checkForExtraneousArguments()
  try:
    callGAPI(caa.accessPolicies().accessLevels(), 'delete',
             throwReasons=[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT, GAPI.PERMISSION_DENIED],
             name=name)
    entityActionPerformed([Ent.CAA_LEVEL, name])
  except (GAPI.notFound, GAPI.invalidArgument) as e:
    entityActionFailedWarning([Ent.CAA_LEVEL, name], str(e))
  except GAPI.permissionDenied:
    CAARoleErrorExit(caa)

# gam print caalevels [todrive <ToDriveAttribute>*]
#	[formatjson [quotechar <Character>]]
# gam show caalevels
#	[formatjson]
def doPrintShowCAALevels():
  caa = buildCAAServiceObject()
  ap_name = getAccessPolicy(caa)
  csvPF = CSVPrintFile(['name', 'title']) if Act.csvFormat() else None
  FJQC = FormatJSONQuoteChar(csvPF)
  while Cmd.ArgumentsRemaining():
    myarg = getArgument()
    if csvPF and myarg == 'todrive':
      csvPF.GetTodriveParameters()
    else:
      FJQC.GetFormatJSONQuoteChar(myarg, True)
  try:
    levels = callGAPIpages(caa.accessPolicies().accessLevels(), 'list', 'accessLevels',
                           throwReasons=[GAPI.PERMISSION_DENIED],
                           parent=ap_name, accessLevelFormat='CEL', fields='*')
  except GAPI.permissionDenied:
    CAARoleErrorExit(caa)
  if not csvPF:
    count = len(levels)
    if not FJQC.formatJSON:
      performActionNumItems(count, Ent.CAA_LEVEL)
      i = 0
      for level in levels:
        i += 1
        printEntity([Ent.CAA_LEVEL, level['name']], i, count)
        Ind.Increment()
        showJSON(None, level)
        Ind.Decrement()
    else:
      for level in levels:
        printLine(json.dumps(cleanJSON(level), ensure_ascii=False, sort_keys=True))
  else:
    for level in levels:
      row = flattenJSON(level)
      if not FJQC.formatJSON:
        csvPF.WriteRowTitles(row)
      elif csvPF.CheckRowTitles(row):
        row = {'name': level['name'], 'title': level['title']}
        row['JSON'] = json.dumps(cleanJSON(level, timeObjects=NOTES_TIME_OBJECTS),
                                 ensure_ascii=False, sort_keys=True)
        csvPF.WriteRowNoFilter(row)
  if csvPF:
    csvPF.writeCSVfile('Context Aware Access Levels')

# Command line processing

CMD_ACTION = 0
CMD_FUNCTION = 1

# Batch commands
BATCH_CSV_COMMANDS = {
  Cmd.BATCH_CMD: 		(Act.PERFORM, doBatch),
  Cmd.CSV_CMD: 			(Act.PERFORM, doCSV),
  Cmd.CSVTEST_CMD: 		(Act.PERFORM, doCSVTest),
  Cmd.TBATCH_CMD: 		(Act.PERFORM, doThreadBatch),
  }

# Main commands
MAIN_COMMANDS = {
  'checkconn':			(Act.CHECK, doCheckConnection),
  'checkconnection':		(Act.CHECK, doCheckConnection),
  'comment':			(Act.COMMENT, doComment),
  'help': 			(Act.PERFORM, doUsage),
  'list': 			(Act.LIST, doListType),
  'report': 			(Act.REPORT, doReport),
  'sendemail': 			(Act.SENDEMAIL, doSendEmail),
  'version': 			(Act.PERFORM, doVersion),
  'whatis': 			(Act.INFO, doWhatIs),
  }

# Main commands with objects
MAIN_ADD_CREATE_FUNCTIONS = {
  Cmd.ARG_ADMIN:		doCreateAdmin,
  Cmd.ARG_ADMINROLE:		doCreateUpdateAdminRoles,
  Cmd.ARG_ALERTFEEDBACK:	doCreateAlertFeedback,
  Cmd.ARG_ALIAS:		doCreateUpdateAliases,
  Cmd.ARG_BROWSERTOKEN:		doCreateBrowserToken,
  Cmd.ARG_BUILDING:		doCreateBuilding,
  Cmd.ARG_CAALEVEL:		doCreateCAALevel,
  Cmd.ARG_CHATMESSAGE:		doCreateChatMessage,
  Cmd.ARG_CHROMENETWORK:	doCreateChromeNetwork,
  Cmd.ARG_CHROMEPOLICYIMAGE:	doCreateChromePolicyImage,
  Cmd.ARG_CIGROUP:		doCreateCIGroup,
  Cmd.ARG_CONTACT:		doCreateDomainContact,
  Cmd.ARG_COURSE:		doCreateCourse,
  Cmd.ARG_DATATRANSFER:		doCreateDataTransfer,
  Cmd.ARG_DEVICE:		doCreateCIDevice,
  Cmd.ARG_DOMAIN:		doCreateDomain,
  Cmd.ARG_DOMAINALIAS:		doCreateDomainAlias,
  Cmd.ARG_DRIVEFILEACL:		doCreateDriveFileACL,
  Cmd.ARG_DRIVELABELPERMISSION:	doCreateDriveLabelPermissions,
  Cmd.ARG_FEATURE:		doCreateFeature,
  Cmd.ARG_GCPFOLDER:		doCreateGCPFolder,
  Cmd.ARG_GCPSERVICEACCOUNT:	doCreateGCPServiceAccount,
  Cmd.ARG_GROUP:		doCreateGroup,
  Cmd.ARG_GUARDIAN:		doInviteGuardian,
  Cmd.ARG_GUARDIANINVITATION:	doInviteGuardian,
  Cmd.ARG_INBOUNDSSOASSIGNMENT:	doCreateInboundSSOAssignment,
  Cmd.ARG_INBOUNDSSOCREDENTIAL:	doCreateInboundSSOCredential,
  Cmd.ARG_INBOUNDSSOPROFILE:	doCreateInboundSSOProfile,
  Cmd.ARG_ORG:			doCreateOrg,
  Cmd.ARG_PERMISSION:		doCreatePermissions,
  Cmd.ARG_PRINTER:		doCreatePrinter,
  Cmd.ARG_PROJECT:		doCreateProject,
  Cmd.ARG_RESOLDCUSTOMER:	doCreateResoldCustomer,
  Cmd.ARG_RESOLDSUBSCRIPTION:	doCreateResoldSubscription,
  Cmd.ARG_RESOURCE:		doCreateResourceCalendar,
  Cmd.ARG_SAKEY:		doCreateSvcAcctKeys,
  Cmd.ARG_SCHEMA:		doCreateUpdateUserSchemas,
  Cmd.ARG_SHAREDDRIVE:		doCreateSharedDrive,
  Cmd.ARG_SITE:			deprecatedDomainSites,
  Cmd.ARG_SITEACL:		deprecatedDomainSites,
  Cmd.ARG_SVCACCT:		doCreateSvcAcct,
  Cmd.ARG_USER:			doCreateUser,
  Cmd.ARG_VAULTEXPORT:		doCreateVaultExport,
  Cmd.ARG_VAULTHOLD:		doCreateVaultHold,
  Cmd.ARG_VAULTMATTER:		doCreateVaultMatter,
  Cmd.ARG_VERIFY:		doCreateSiteVerification,
  }

MAIN_COMMANDS_WITH_OBJECTS = {
  'add':
    (Act.ADD,
     MAIN_ADD_CREATE_FUNCTIONS
    ),
  'approve':
    (Act.APPROVE,
     {Cmd.ARG_DEVICEUSER:	doApproveCIDeviceUser,
     }
    ),
  'block':
    (Act.BLOCK,
     {Cmd.ARG_DEVICEUSER:	doBlockCIDeviceUser,
     }
    ),
  'cancel':
    (Act.CANCEL,
     {Cmd.ARG_GUARDIANINVITATION:	doCancelGuardianInvitation,
      Cmd.ARG_USERINVITATION:	doCIUserInvitationsAction,
     }
    ),
  'cancelwipe':
    (Act.CANCEL_WIPE,
     {Cmd.ARG_DEVICE:		doCancelWipeCIDevice,
      Cmd.ARG_DEVICEUSER:	doCancelWipeCIDeviceUser,
     }
    ),
  'check':
    (Act.CHECK,
     {Cmd.ARG_SVCACCT:		doCheckUpdateSvcAcct,
      Cmd.ARG_USERINVITATION:	doCheckCIUserInvitations,
      Cmd.ARG_ISINVITABLE:	doCheckCIUserInvitations,
      Cmd.ARG_ORG:		doCheckOrgUnit,
     }
    ),
  'clear':
    (Act.CLEAR,
     {Cmd.ARG_CONTACT:		doClearDomainContacts,
     }
    ),
  'close':
    (Act.CLOSE,
     {Cmd.ARG_VAULTMATTER:	doCloseVaultMatter,
     }
    ),
  'copy':
    (Act.COPY,
     {Cmd.ARG_SHAREDDRIVEACLS:	doCopySyncSharedDriveACLs,
      Cmd.ARG_STORAGEBUCKET:	doCopyCloudStorageBucket,
      Cmd.ARG_VAULTEXPORT:	doCopyVaultExport,
     }
    ),
  'create':
    (Act.CREATE,
     MAIN_ADD_CREATE_FUNCTIONS
    ),
  'dedup':
    (Act.DEDUP,
     {Cmd.ARG_CONTACT:		doDedupDomainContacts,
     }
    ),
  'delete':
    (Act.DELETE,
     {Cmd.ARG_ADMIN:		doDeleteAdmin,
      Cmd.ARG_ADMINROLE:	doDeleteAdminRole,
      Cmd.ARG_ALIAS:		doDeleteAliases,
      Cmd.ARG_ALERT:		doDeleteOrUndeleteAlert,
      Cmd.ARG_BROWSER:		doDeleteBrowsers,
      Cmd.ARG_BUILDING:		doDeleteBuilding,
      Cmd.ARG_CAALEVEL:		doDeleteCAALevel,
      Cmd.ARG_CHATMESSAGE:	doDeleteChatMessage,
      Cmd.ARG_CHROMENETWORK:	doDeleteChromeNetwork,
      Cmd.ARG_CHROMEPOLICY:	doDeleteChromePolicy,
      Cmd.ARG_CHROMEPROFILE:	doDeleteChromeProfile,
      Cmd.ARG_CIGROUP:		doDeleteCIGroups,
      Cmd.ARG_CLASSROOMINVITATION:	doDeleteClassroomInvitations,
      Cmd.ARG_CONTACT:		doDeleteDomainContacts,
      Cmd.ARG_CONTACTPHOTO:	doDeleteDomainContactPhoto,
      Cmd.ARG_COURSE:		doDeleteCourse,
      Cmd.ARG_COURSES:		doDeleteCourses,
      Cmd.ARG_DEVICE:		doDeleteCIDevice,
      Cmd.ARG_DEVICEUSER:	doDeleteCIDeviceUser,
      Cmd.ARG_DOMAIN:		doDeleteDomain,
      Cmd.ARG_DOMAINALIAS:	doDeleteDomainAlias,
      Cmd.ARG_DOMAINCONTACT:	doDeleteDomainContacts,
      Cmd.ARG_DRIVEFILEACL:	doDeleteDriveFileACLs,
      Cmd.ARG_DRIVELABELPERMISSION:	doDeleteDriveLabelPermissions,
      Cmd.ARG_FEATURE:		doDeleteFeature,
      Cmd.ARG_GROUP:		doDeleteGroups,
      Cmd.ARG_GUARDIAN:		doDeleteGuardian,
      Cmd.ARG_INBOUNDSSOASSIGNMENT:	doDeleteInboundSSOAssignment,
      Cmd.ARG_INBOUNDSSOCREDENTIAL:	doDeleteInboundSSOCredential,
      Cmd.ARG_INBOUNDSSOPROFILE:	doDeleteInboundSSOProfile,
      Cmd.ARG_MOBILE:		doDeleteMobileDevices,
      Cmd.ARG_ORG:		doDeleteOrg,
      Cmd.ARG_ORGS:		doDeleteOrgs,
      Cmd.ARG_PERMISSION:	doDeletePermissions,
      Cmd.ARG_PRINTER:		doDeletePrinter,
      Cmd.ARG_PROJECT:		doDeleteProject,
      Cmd.ARG_RESOLDSUBSCRIPTION:	doDeleteResoldSubscription,
      Cmd.ARG_RESOURCE:		doDeleteResourceCalendar,
      Cmd.ARG_RESOURCES:	doDeleteResourceCalendars,
      Cmd.ARG_SAKEY:		doDeleteSvcAcctKeys,
      Cmd.ARG_SCHEMA:		doDeleteUserSchemas,
      Cmd.ARG_SHAREDDRIVE:	doDeleteSharedDrive,
      Cmd.ARG_SITEACL:		deprecatedDomainSites,
      Cmd.ARG_SVCACCT:		doDeleteSvcAcct,
      Cmd.ARG_USER:		doDeleteUser,
      Cmd.ARG_USERS:		doDeleteUsers,
      Cmd.ARG_VAULTEXPORT:	doDeleteVaultExport,
      Cmd.ARG_VAULTHOLD:	doDeleteVaultHold,
      Cmd.ARG_VAULTMATTER:	doDeleteVaultMatter,
     }
    ),
  'download':
    (Act.DOWNLOAD,
     {Cmd.ARG_STORAGEBUCKET:	doDownloadCloudStorageBucket,
      Cmd.ARG_STORAGEFILE:	doDownloadCloudStorageFile,
      Cmd.ARG_VAULTEXPORT:	doDownloadVaultExport,
     }
    ),
  'enable':
    (Act.ENABLE,
     {Cmd.ARG_API:		doEnableAPIs,
     }
    ),
  'get':
    (Act.DOWNLOAD,
     {Cmd.ARG_CONTACTPHOTO:	doGetDomainContactPhoto,
      Cmd.ARG_DEVICEFILE:	doGetCrOSDeviceFiles,
     }
    ),
  'getcommand':
    (Act.GET_COMMAND_RESULT,
     {Cmd.ARG_CROS:		doGetCommandResultCrOSDevices,
     }
    ),
  'hide':
    (Act.HIDE,
     {Cmd.ARG_SHAREDDRIVE:	doHideUnhideSharedDrive,
     }
    ),
  'info':
    (Act.INFO,
     {Cmd.ARG_ADMINROLE:	doInfoPrintShowAdminRoles,
      Cmd.ARG_ALERT:		doInfoAlert,
      Cmd.ARG_ALIAS:		doInfoAliases,
      Cmd.ARG_BUILDING:		doInfoBuilding,
      Cmd.ARG_BROWSER:		doInfoBrowsers,
      Cmd.ARG_CHATEVENT:	doInfoChatEvent,
      Cmd.ARG_CHATMEMBER:	doInfoChatMember,
      Cmd.ARG_CHATMESSAGE:	doInfoChatMessage,
      Cmd.ARG_CHATSPACE:	doInfoChatSpace,
      Cmd.ARG_CHROMEAPP:	doInfoChromeApp,
      Cmd.ARG_CHROMEPROFILE:	doInfoChromeProfile,
      Cmd.ARG_CHROMESCHEMA:	doInfoChromePolicySchemas,
      Cmd.ARG_CIGROUP:		doInfoCIGroups,
      Cmd.ARG_CIGROUPMEMBERS:	doInfoCIGroupMembers,
      Cmd.ARG_CIPOLICY:		doInfoCIPolicies,
      Cmd.ARG_CONTACT:		doInfoDomainContacts,
      Cmd.ARG_COURSE:		doInfoCourse,
      Cmd.ARG_COURSES:		doInfoCourses,
      Cmd.ARG_CROS:		doInfoCrOSDevices,
      Cmd.ARG_CROSTELEMETRY:	doInfoPrintShowCrOSTelemetry,
      Cmd.ARG_CURRENTPROJECTID:	doInfoCurrentProjectId,
      Cmd.ARG_CUSTOMER:		doInfoCustomer,
      Cmd.ARG_DATATRANSFER:	doInfoDataTransfer,
      Cmd.ARG_DEVICE:		doInfoCIDevice,
      Cmd.ARG_DEVICEUSER:	doInfoCIDeviceUser,
      Cmd.ARG_DEVICEUSERSTATE:	doInfoCIDeviceUserState,
      Cmd.ARG_DOMAIN:		doInfoDomain,
      Cmd.ARG_DOMAINALIAS:	doInfoDomainAlias,
      Cmd.ARG_DOMAINCONTACT:	doInfoDomainContacts,
      Cmd.ARG_DRIVEFILEACL:	doInfoDriveFileACLs,
      Cmd.ARG_DRIVELABEL:	doInfoDriveLabels,
      Cmd.ARG_INSTANCE:		doInfoInstance,
      Cmd.ARG_GROUP:		doInfoGroups,
      Cmd.ARG_GROUPMEMBERS:	doInfoGroupMembers,
      Cmd.ARG_INBOUNDSSOASSIGNMENT:	doInfoInboundSSOAssignment,
      Cmd.ARG_INBOUNDSSOCREDENTIAL:	doInfoInboundSSOCredential,
      Cmd.ARG_INBOUNDSSOPROFILE:	doInfoInboundSSOProfile,
      Cmd.ARG_MOBILE:		doInfoMobileDevices,
      Cmd.ARG_ORG:		doInfoOrg,
      Cmd.ARG_ORGS:		doInfoOrgs,
      Cmd.ARG_PEOPLEPROFILE:	doInfoDomainPeopleProfile,
      Cmd.ARG_PEOPLECONTACT:	doInfoDomainPeopleContacts,
      Cmd.ARG_PRINTER:		doInfoPrinter,
      Cmd.ARG_RESOLDCUSTOMER:	doInfoResoldCustomer,
      Cmd.ARG_RESOLDSUBSCRIPTION:	doInfoResoldSubscription,
      Cmd.ARG_RESOURCE:		doInfoResourceCalendar,
      Cmd.ARG_RESOURCES:	doInfoResourceCalendars,
      Cmd.ARG_SCHEMA:		doInfoUserSchemas,
      Cmd.ARG_SHAREDDRIVE:	doInfoSharedDrive,
      Cmd.ARG_SITE:		deprecatedDomainSites,
      Cmd.ARG_SITEACL:		deprecatedDomainSites,
      Cmd.ARG_USER:		doInfoUser,
      Cmd.ARG_USERS:		doInfoUsers,
      Cmd.ARG_USERINVITATION:	doInfoCIUserInvitations,
      Cmd.ARG_VAULTEXPORT:	doInfoVaultExport,
      Cmd.ARG_VAULTHOLD:	doInfoVaultHold,
      Cmd.ARG_VAULTMATTER:	doInfoVaultMatter,
      Cmd.ARG_VAULTQUERY:	doInfoVaultQuery,
      Cmd.ARG_VERIFY:		doInfoSiteVerification,
     }
    ),
  'issuecommand':
    (Act.ISSUE_COMMAND,
     {Cmd.ARG_CROS:		doIssueCommandCrOSDevices,
     }
    ),
  'move':
    (Act.MOVE,
     {Cmd.ARG_BROWSER:		doMoveBrowsers,
     }
    ),
  'print':
    (Act.PRINT,
     {Cmd.ARG_ADDRESSES:	doPrintAddresses,
      Cmd.ARG_ADMINROLE:	doInfoPrintShowAdminRoles,
      Cmd.ARG_ADMIN:		doPrintShowAdmins,
      Cmd.ARG_ALERT:		doPrintShowAlerts,
      Cmd.ARG_ALERTFEEDBACK:	doPrintShowAlertFeedback,
      Cmd.ARG_ALIAS:		doPrintAliases,
      Cmd.ARG_BROWSER:		doPrintShowBrowsers,
      Cmd.ARG_BROWSERTOKEN:	doPrintShowBrowserTokens,
      Cmd.ARG_BUILDING:		doPrintShowBuildings,
      Cmd.ARG_CAALEVEL:		doPrintShowCAALevels,
      Cmd.ARG_CHANNELCUSTOMER:	doPrintShowChannelCustomers,
      Cmd.ARG_CHANNELCUSTOMERENTITLEMENT:	doPrintShowChannelCustomerEntitlements,
      Cmd.ARG_CHANNELOFFER:	doPrintShowChannelOffers,
      Cmd.ARG_CHANNELPRODUCT:	doPrintShowChannelProducts,
      Cmd.ARG_CHANNELSKU:	doPrintShowChannelSKUs,
      Cmd.ARG_CHATMEMBER:	doPrintShowChatMembers,
      Cmd.ARG_CHATSPACE:	doPrintShowChatSpaces,
      Cmd.ARG_CHROMEAPP:	doPrintShowChromeApps,
      Cmd.ARG_CHROMEAPPDEVICES:	doPrintShowChromeAppDevices,
      Cmd.ARG_CHROMEAUES:	doPrintShowChromeAues,
      Cmd.ARG_CHROMEHISTORY:	doPrintShowChromeHistory,
      Cmd.ARG_CHROMENEEDSATTN:	doPrintShowChromeNeedsAttn,
      Cmd.ARG_CHROMEPOLICY:	doPrintShowChromePolicies,
      Cmd.ARG_CHROMEPROFILE:	doPrintShowChromeProfiles,
      Cmd.ARG_CHROMESCHEMA:	doPrintShowChromeSchemas,
      Cmd.ARG_CHROMESNVALIDITY:	doPrintChromeSnValidity,
      Cmd.ARG_CHROMEVERSIONS:	doPrintShowChromeVersions,
      Cmd.ARG_CIGROUP:		doPrintCIGroups,
      Cmd.ARG_CIGROUPMEMBERS:	doPrintCIGroupMembers,
      Cmd.ARG_CIPOLICY:		doPrintShowCIPolicies,
      Cmd.ARG_CLASSROOMINVITATION:	doPrintShowClassroomInvitations,
      Cmd.ARG_CONTACT:		doPrintShowDomainContacts,
      Cmd.ARG_COURSE:		doPrintCourses,
      Cmd.ARG_COURSES:		doPrintCourses,
      Cmd.ARG_COURSEANNOUNCEMENTS:	doPrintCourseAnnouncements,
      Cmd.ARG_COURSEMATERIALS:	doPrintCourseMaterials,
      Cmd.ARG_COURSEPARTICIPANTS:	doPrintCourseParticipants,
      Cmd.ARG_COURSESUBMISSIONS:	doPrintCourseSubmissions,
      Cmd.ARG_COURSETOPICS:	doPrintCourseTopics,
      Cmd.ARG_COURSEWORK:	doPrintCourseWork,
      Cmd.ARG_CROS:		doPrintCrOSDevices,
      Cmd.ARG_CROSACTIVITY:	doPrintCrOSActivity,
      Cmd.ARG_CROSTELEMETRY:	doInfoPrintShowCrOSTelemetry,
      Cmd.ARG_DATATRANSFER:	doPrintShowDataTransfers,
      Cmd.ARG_DEVICE:		doPrintCIDevices,
      Cmd.ARG_DEVICEUSER:	doPrintCIDeviceUsers,
      Cmd.ARG_DOMAIN:		doPrintShowDomains,
      Cmd.ARG_DOMAINALIAS:	doPrintShowDomainAliases,
      Cmd.ARG_DOMAINCONTACT:	doPrintShowDomainContacts,
      Cmd.ARG_DRIVEFILEACL:	doPrintShowDriveFileACLs,
      Cmd.ARG_DRIVELABEL:	doPrintShowDriveLabels,
      Cmd.ARG_DRIVELABELPERMISSION:	doPrintShowDriveLabelPermissions,
      Cmd.ARG_FEATURE:		doPrintShowFeatures,
      Cmd.ARG_GROUP:		doPrintGroups,
      Cmd.ARG_GROUPMEMBERS:	doPrintGroupMembers,
      Cmd.ARG_GROUPTREE:	doPrintShowGroupTree,
      Cmd.ARG_GUARDIAN:		doPrintShowGuardians,
      Cmd.ARG_INBOUNDSSOASSIGNMENT:	doPrintShowInboundSSOAssignments,
      Cmd.ARG_INBOUNDSSOCREDENTIAL:	doPrintShowInboundSSOCredentials,
      Cmd.ARG_INBOUNDSSOPROFILE:	doPrintShowInboundSSOProfiles,
      Cmd.ARG_LICENSE:		doPrintLicenses,
      Cmd.ARG_MOBILE:		doPrintMobileDevices,
      Cmd.ARG_ORG:		doPrintOrgs,
      Cmd.ARG_ORGS:		doPrintOrgs,
      Cmd.ARG_ORGUNITSHAREDDRIVE:	doPrintShowOrgunitSharedDrives,
      Cmd.ARG_OWNERSHIP:	doPrintShowOwnership,
      Cmd.ARG_PEOPLECONTACT:	doPrintShowDomainPeopleContacts,
      Cmd.ARG_PEOPLEPROFILE:	doPrintShowDomainPeopleProfiles,
      Cmd.ARG_PRINTER:		doPrintShowPrinters,
      Cmd.ARG_PRINTERMODEL:	doPrintShowPrinterModels,
      Cmd.ARG_PRIVILEGES:	doPrintShowPrivileges,
      Cmd.ARG_PROJECT:		doPrintShowProjects,
      Cmd.ARG_RESOLDSUBSCRIPTION:	doPrintShowResoldSubscriptions,
      Cmd.ARG_RESOURCE:		doPrintShowResourceCalendars,
      Cmd.ARG_RESOURCES:	doPrintShowResourceCalendars,
      Cmd.ARG_SCHEMA:		doPrintShowUserSchemas,
      Cmd.ARG_SHAREDDRIVE:	doPrintShowSharedDrives,
      Cmd.ARG_SHAREDDRIVEACLS:	doPrintShowSharedDriveACLs,
      Cmd.ARG_SHAREDDRIVEORGANIZERS:	doPrintSharedDriveOrganizers,
      Cmd.ARG_SITE:		deprecatedDomainSites,
      Cmd.ARG_SITEACL:		deprecatedDomainSites,
      Cmd.ARG_SITEACTIVITY:	deprecatedDomainSites,
      Cmd.ARG_SVCACCT:		doPrintShowSvcAccts,
      Cmd.ARG_TOKEN:		doPrintShowTokens,
      Cmd.ARG_TRANSFERAPPS:	doShowTransferApps,
      Cmd.ARG_USER:		doPrintUsers,
      Cmd.ARG_USERS:		doPrintUsers,
      Cmd.ARG_USERCOUNTSBYORGUNIT:	doPrintUserCountsByOrgUnit,
      Cmd.ARG_USERINVITATION:	doPrintShowCIUserInvitations,
      Cmd.ARG_VAULTCOUNT:	doPrintVaultCounts,
      Cmd.ARG_VAULTEXPORT:	doPrintShowVaultExports,
      Cmd.ARG_VAULTHOLD:	doPrintShowVaultHolds,
      Cmd.ARG_VAULTMATTER:	doPrintShowVaultMatters,
      Cmd.ARG_VAULTQUERY:	doPrintShowVaultQueries,
     }
    ),
  'remove':
    (Act.REMOVE,
     {Cmd.ARG_ALIAS:		doRemoveAliases,
      Cmd.ARG_DRIVELABELPERMISSION:	doDeleteDriveLabelPermissions,
     }
    ),
  'reopen':
    (Act.REOPEN,
     {Cmd.ARG_VAULTMATTER:	doReopenVaultMatter,
     }
    ),
  'replace':
    (Act.UPDATE,
     {Cmd.ARG_SAKEY:		doReplaceSvcAcctKeys,
     }
    ),
  'rotate':
    (Act.UPDATE,
     {Cmd.ARG_SAKEY:		doProcessSvcAcctKeys,
     }
    ),
  'revoke':
    (Act.REVOKE,
     {Cmd.ARG_BROWSERTOKEN:	doRevokeBrowserToken,
     }
    ),
  'send':
    (Act.SEND,
     {Cmd.ARG_USERINVITATION:	doCIUserInvitationsAction,
     }
    ),
  'setup':
    (Act.SETUP,
     {Cmd.ARG_CHAT:		doSetupChat,
     }
    ),
  'show':
    (Act.SHOW,
     {Cmd.ARG_ADMINROLE:	doInfoPrintShowAdminRoles,
      Cmd.ARG_ADMIN:		doPrintShowAdmins,
      Cmd.ARG_ALERT:		doPrintShowAlerts,
      Cmd.ARG_ALERTFEEDBACK:	doPrintShowAlertFeedback,
      Cmd.ARG_BROWSER:		doPrintShowBrowsers,
      Cmd.ARG_BROWSERTOKEN:	doPrintShowBrowserTokens,
      Cmd.ARG_BUILDING:		doPrintShowBuildings,
      Cmd.ARG_CAALEVEL:		doPrintShowCAALevels,
      Cmd.ARG_CHANNELCUSTOMER:	doPrintShowChannelCustomers,
      Cmd.ARG_CHANNELCUSTOMERENTITLEMENT:	doPrintShowChannelCustomerEntitlements,
      Cmd.ARG_CHANNELOFFER:	doPrintShowChannelOffers,
      Cmd.ARG_CHANNELPRODUCT:	doPrintShowChannelProducts,
      Cmd.ARG_CHANNELSKU:	doPrintShowChannelSKUs,
      Cmd.ARG_CHATMEMBER:	doPrintShowChatMembers,
      Cmd.ARG_CHATSPACE:	doPrintShowChatSpaces,
      Cmd.ARG_CHROMEAPP:	doPrintShowChromeApps,
      Cmd.ARG_CHROMEAPPDEVICES:	doPrintShowChromeAppDevices,
      Cmd.ARG_CHROMEAUES:	doPrintShowChromeAues,
      Cmd.ARG_CHROMEHISTORY:	doPrintShowChromeHistory,
      Cmd.ARG_CHROMENEEDSATTN:	doPrintShowChromeNeedsAttn,
      Cmd.ARG_CHROMEPOLICY:	doPrintShowChromePolicies,
      Cmd.ARG_CHROMEPROFILE:	doPrintShowChromeProfiles,
      Cmd.ARG_CHROMESCHEMA:	doPrintShowChromeSchemas,
      Cmd.ARG_CHROMEVERSIONS:	doPrintShowChromeVersions,
      Cmd.ARG_CIGROUPMEMBERS:	doShowCIGroupMembers,
      Cmd.ARG_CIPOLICY:		doPrintShowCIPolicies,
      Cmd.ARG_CLASSROOMINVITATION:	doPrintShowClassroomInvitations,
      Cmd.ARG_CONTACT:		doPrintShowDomainContacts,
      Cmd.ARG_CROSTELEMETRY:	doInfoPrintShowCrOSTelemetry,
      Cmd.ARG_DATATRANSFER:	doPrintShowDataTransfers,
      Cmd.ARG_DOMAIN:		doPrintShowDomains,
      Cmd.ARG_DOMAINALIAS:	doPrintShowDomainAliases,
      Cmd.ARG_DOMAINCONTACT:	doPrintShowDomainContacts,
      Cmd.ARG_DRIVEFILEACL:	doPrintShowDriveFileACLs,
      Cmd.ARG_DRIVELABEL:	doPrintShowDriveLabels,
      Cmd.ARG_DRIVELABELPERMISSION:	doPrintShowDriveLabelPermissions,
      Cmd.ARG_FEATURE:		doPrintShowFeatures,
      Cmd.ARG_GROUPMEMBERS:	doShowGroupMembers,
      Cmd.ARG_GROUPTREE:	doPrintShowGroupTree,
      Cmd.ARG_GUARDIAN:		doPrintShowGuardians,
      Cmd.ARG_INBOUNDSSOASSIGNMENT:	doPrintShowInboundSSOAssignments,
      Cmd.ARG_INBOUNDSSOCREDENTIAL:	doPrintShowInboundSSOCredentials,
      Cmd.ARG_INBOUNDSSOPROFILE:	doPrintShowInboundSSOProfiles,
      Cmd.ARG_ORGUNITSHAREDDRIVE:	doPrintShowOrgunitSharedDrives,
      Cmd.ARG_LICENSE:		doShowLicenses,
      Cmd.ARG_ORGTREE:		doShowOrgTree,
      Cmd.ARG_OWNERSHIP:	doPrintShowOwnership,
      Cmd.ARG_PEOPLECONTACT:	doPrintShowDomainPeopleContacts,
      Cmd.ARG_PEOPLEPROFILE:	doPrintShowDomainPeopleProfiles,
      Cmd.ARG_PRINTER:		doPrintShowPrinters,
      Cmd.ARG_PRINTERMODEL:	doPrintShowPrinterModels,
      Cmd.ARG_PRIVILEGES:	doPrintShowPrivileges,
      Cmd.ARG_PROJECT:		doPrintShowProjects,
      Cmd.ARG_RESOLDSUBSCRIPTION:	doPrintShowResoldSubscriptions,
      Cmd.ARG_RESOURCE:		doPrintShowResourceCalendars,
      Cmd.ARG_RESOURCES:	doPrintShowResourceCalendars,
      Cmd.ARG_SAKEY:		doShowSvcAcctKeys,
      Cmd.ARG_SCHEMA:		doPrintShowUserSchemas,
      Cmd.ARG_SHAREDDRIVE:	doPrintShowSharedDrives,
      Cmd.ARG_SHAREDDRIVEACLS:	doPrintShowSharedDriveACLs,
      Cmd.ARG_SHAREDDRIVEINFO:	doInfoSharedDrive,
      Cmd.ARG_SHAREDDRIVETHEMES:	doShowSharedDriveThemes,
      Cmd.ARG_SITE:		deprecatedDomainSites,
      Cmd.ARG_SITEACL:		deprecatedDomainSites,
      Cmd.ARG_SVCACCT:		doPrintShowSvcAccts,
      Cmd.ARG_TOKEN:		doPrintShowTokens,
      Cmd.ARG_TRANSFERAPPS:	doShowTransferApps,
      Cmd.ARG_USERINVITATION:	doPrintShowCIUserInvitations,
      Cmd.ARG_VAULTEXPORT:	doPrintShowVaultExports,
      Cmd.ARG_VAULTHOLD:	doPrintShowVaultHolds,
      Cmd.ARG_VAULTMATTER:	doPrintShowVaultMatters,
      Cmd.ARG_VAULTQUERY:	doPrintShowVaultQueries,
     }
    ),
  'suspend':
    (Act.SUSPEND,
     {Cmd.ARG_USER:		doSuspendUnsuspendUser,
      Cmd.ARG_USERS:		doSuspendUnsuspendUsers,
     }
    ),
  'sync':
    (Act.SYNC,
     {Cmd.ARG_DEVICE:		doSyncCIDevices,
      Cmd.ARG_SHAREDDRIVEACLS:	copySyncSharedDriveACLs,
     }
    ),
  'unhide':
    (Act.UNHIDE,
     {Cmd.ARG_SHAREDDRIVE:	doHideUnhideSharedDrive,
     }
    ),
  'update':
    (Act.UPDATE,
     {Cmd.ARG_ADMINROLE:	doCreateUpdateAdminRoles,
      Cmd.ARG_ALIAS:		doCreateUpdateAliases,
      Cmd.ARG_BROWSER:		doUpdateBrowsers,
      Cmd.ARG_BUILDING:		doUpdateBuilding,
      Cmd.ARG_CAALEVEL:		doUpdateCAALevel,
      Cmd.ARG_CHATMESSAGE:	doUpdateChatMessage,
      Cmd.ARG_CHROMEPOLICY:	doUpdateChromePolicy,
      Cmd.ARG_CIGROUP:		doUpdateCIGroups,
      Cmd.ARG_CONTACT:		doUpdateDomainContacts,
      Cmd.ARG_CONTACTPHOTO:	doUpdateDomainContactPhoto,
      Cmd.ARG_COURSE:		doUpdateCourse,
      Cmd.ARG_COURSES:		doUpdateCourses,
      Cmd.ARG_CROS:		doUpdateCrOSDevices,
      Cmd.ARG_CUSTOMER:		doUpdateCustomer,
      Cmd.ARG_DEVICE:		doUpdateCIDevice,
      Cmd.ARG_DEVICEUSER:	doUpdateCIDeviceUser,
      Cmd.ARG_DEVICEUSERSTATE:	doUpdateCIDeviceUserState,
      Cmd.ARG_DOMAIN:		doUpdateDomain,
      Cmd.ARG_DRIVEFILEACL:	doUpdateDriveFileACLs,
      Cmd.ARG_FEATURE:		doUpdateFeature,
      Cmd.ARG_GROUP:		doUpdateGroups,
      Cmd.ARG_INBOUNDSSOASSIGNMENT:	doUpdateInboundSSOAssignment,
      Cmd.ARG_INBOUNDSSOPROFILE:	doUpdateInboundSSOProfile,
      Cmd.ARG_MOBILE:		doUpdateMobileDevices,
      Cmd.ARG_ORG:		doUpdateOrg,
      Cmd.ARG_ORGS:		doUpdateOrgs,
      Cmd.ARG_PRINTER:		doUpdatePrinter,
      Cmd.ARG_PROJECT:		doUpdateProject,
      Cmd.ARG_RESOLDCUSTOMER:	doUpdateResoldCustomer,
      Cmd.ARG_RESOLDSUBSCRIPTION:	doUpdateResoldSubscription,
      Cmd.ARG_RESOURCE:		doUpdateResourceCalendar,
      Cmd.ARG_RESOURCES:	doUpdateResourceCalendars,
      Cmd.ARG_SAKEY:		doUpdateSvcAcctKeys,
      Cmd.ARG_SCHEMA:		doCreateUpdateUserSchemas,
      Cmd.ARG_SHAREDDRIVE:	doUpdateSharedDrive,
      Cmd.ARG_SITE:		deprecatedDomainSites,
      Cmd.ARG_SITEACL:		deprecatedDomainSites,
      Cmd.ARG_SVCACCT:		doCheckUpdateSvcAcct,
      Cmd.ARG_USER:		doUpdateUser,
      Cmd.ARG_USERS:		doUpdateUsers,
      Cmd.ARG_VAULTHOLD:	doUpdateVaultHold,
      Cmd.ARG_VAULTMATTER:	doUpdateVaultMatter,
      Cmd.ARG_VERIFY:		doUpdateSiteVerification,
     }
    ),
  'undelete':
    (Act.UNDELETE,
     {Cmd.ARG_ALERT:		doDeleteOrUndeleteAlert,
      Cmd.ARG_USER:		doUndeleteUser,
      Cmd.ARG_USERS:		doUndeleteUsers,
      Cmd.ARG_VAULTMATTER:	doUndeleteVaultMatter,
     }
    ),
  'unsuspend':
    (Act.UNSUSPEND,
     {Cmd.ARG_USER:		doSuspendUnsuspendUser,
      Cmd.ARG_USERS:		doSuspendUnsuspendUsers,
     }
    ),
  'upload':
    (Act.USE,
     {Cmd.ARG_SAKEY:		doUploadSvcAcctKeys,
     }
    ),
  'use':
    (Act.USE,
     {Cmd.ARG_PROJECT:		doUseProject,
     }
    ),
  'wipe':
    (Act.WIPE,
     {Cmd.ARG_DEVICE:		doWipeCIDevice,
      Cmd.ARG_DEVICEUSER:	doWipeCIDeviceUser,
     }
    ),
  'yubikey':
    (Act.RESET_YUBIKEY_PIV,
     {Cmd.ARG_RESETPIV:		doResetYubiKeyPIV,
     }
    ),
  }

MAIN_COMMANDS_OBJ_ALIASES = {
  Cmd.ARG_ADMINS:		Cmd.ARG_ADMIN,
  Cmd.ARG_ADMINROLES:		Cmd.ARG_ADMINROLE,
  Cmd.ARG_ALERTFEEDBACKS:	Cmd.ARG_ALERTFEEDBACK,
  Cmd.ARG_ALERTS:		Cmd.ARG_ALERT,
  Cmd.ARG_ALERTSFEEDBACK:	Cmd.ARG_ALERTFEEDBACK,
  Cmd.ARG_ALIASDOMAIN:		Cmd.ARG_DOMAINALIAS,
  Cmd.ARG_ALIASDOMAINS:		Cmd.ARG_DOMAINALIAS,
  Cmd.ARG_ALIASES:		Cmd.ARG_ALIAS,
  Cmd.ARG_APIS:			Cmd.ARG_API,
  Cmd.ARG_APIPROJECT:		Cmd.ARG_PROJECT,
  Cmd.ARG_APPDETAILS:		Cmd.ARG_CHROMEAPP,
  Cmd.ARG_BROWSERS:		Cmd.ARG_BROWSER,
  Cmd.ARG_BROWSERTOKENS:	Cmd.ARG_BROWSERTOKEN,
  Cmd.ARG_BUCKET:		Cmd.ARG_STORAGEBUCKET,
  Cmd.ARG_BUCKETS:		Cmd.ARG_STORAGEBUCKET,
  Cmd.ARG_BUILDINGS:		Cmd.ARG_BUILDING,
  Cmd.ARG_CAALEVELS:		Cmd.ARG_CAALEVEL,
  Cmd.ARG_CHATMEMBERS:		Cmd.ARG_CHATMEMBER,
  Cmd.ARG_CHANNELCUSTOMERS:	Cmd.ARG_CHANNELCUSTOMER,
  Cmd.ARG_CHANNELCUSTOMERENTITLEMENTS:	Cmd.ARG_CHANNELCUSTOMERENTITLEMENT,
  Cmd.ARG_CHANNELOFFERS:	Cmd.ARG_CHANNELOFFER,
  Cmd.ARG_CHANNELPRODUCTS:	Cmd.ARG_CHANNELPRODUCT,
  Cmd.ARG_CHANNELSKUS:		Cmd.ARG_CHANNELSKU,
  Cmd.ARG_CHATSPACES:		Cmd.ARG_CHATSPACE,
  Cmd.ARG_CHROMEAPPS:		Cmd.ARG_CHROMEAPP,
  Cmd.ARG_CHROMENETWORKS:	Cmd.ARG_CHROMENETWORK,
  Cmd.ARG_CHROMEPOLICIES:	Cmd.ARG_CHROMEPOLICY,
  Cmd.ARG_CHROMEPROFILES:	Cmd.ARG_CHROMEPROFILE,
  Cmd.ARG_CHROMESCHEMAS:	Cmd.ARG_CHROMESCHEMA,
  Cmd.ARG_CIGROUPS:		Cmd.ARG_CIGROUP,
  Cmd.ARG_CIGROUPSMEMBERS:	Cmd.ARG_CIGROUPMEMBERS,
  Cmd.ARG_CIMEMBER:		Cmd.ARG_CIGROUPMEMBERS,
  Cmd.ARG_CIMEMBERS:		Cmd.ARG_CIGROUPMEMBERS,
  Cmd.ARG_CIPOLICIES:		Cmd.ARG_CIPOLICY,
  Cmd.ARG_CLASSIFICATIONLABEL:	Cmd.ARG_DRIVELABEL,
  Cmd.ARG_CLASSIFICATIONLABELS:	Cmd.ARG_DRIVELABEL,
  Cmd.ARG_CLASSIFICATIONLABELPERMISSION:	Cmd.ARG_DRIVELABELPERMISSION,
  Cmd.ARG_CLASSIFICATIONLABELPERMISSIONS:	Cmd.ARG_DRIVELABELPERMISSION,
  Cmd.ARG_CLASS:		Cmd.ARG_COURSE,
  Cmd.ARG_CLASSES:		Cmd.ARG_COURSES,
  Cmd.ARG_CLASSPARTICIPANTS:	Cmd.ARG_COURSEPARTICIPANTS,
  Cmd.ARG_CLASSROOMINVITATIONS:	Cmd.ARG_CLASSROOMINVITATION,
  Cmd.ARG_CONTACTS:		Cmd.ARG_CONTACT,
  Cmd.ARG_CONTACTPHOTOS:	Cmd.ARG_CONTACTPHOTO,
  Cmd.ARG_CROSES:		Cmd.ARG_CROS,
  Cmd.ARG_DATATRANSFERS:	Cmd.ARG_DATATRANSFER,
  Cmd.ARG_DEVICES:		Cmd.ARG_DEVICE,
  Cmd.ARG_DEVICEFILES:		Cmd.ARG_DEVICEFILE,
  Cmd.ARG_DEVICEUSERS:		Cmd.ARG_DEVICEUSER,
  Cmd.ARG_DOMAINS:		Cmd.ARG_DOMAIN,
  Cmd.ARG_DOMAINALIASES:	Cmd.ARG_DOMAINALIAS,
  Cmd.ARG_DOMAINCONTACT:	Cmd.ARG_PEOPLECONTACT,
  Cmd.ARG_DOMAINCONTACTS:	Cmd.ARG_PEOPLECONTACT,
  Cmd.ARG_DOMAINPROFILES:	Cmd.ARG_PEOPLEPROFILE,
  Cmd.ARG_DRIVEFILEACLS:	Cmd.ARG_DRIVEFILEACL,
  Cmd.ARG_DRIVELABELS:		Cmd.ARG_DRIVELABEL,
  Cmd.ARG_DRIVELABELPERMISSIONS:	Cmd.ARG_DRIVELABELPERMISSION,
  Cmd.ARG_EXPORT:		Cmd.ARG_VAULTEXPORT,
  Cmd.ARG_EXPORTS:		Cmd.ARG_VAULTEXPORT,
  Cmd.ARG_FEATURES:		Cmd.ARG_FEATURE,
  Cmd.ARG_FORMS:		Cmd.ARG_FORM,
  Cmd.ARG_GROUPS:		Cmd.ARG_GROUP,
  Cmd.ARG_GROUPSMEMBERS:	Cmd.ARG_GROUPMEMBERS,
  Cmd.ARG_GUARDIANINVITATIONS:	Cmd.ARG_GUARDIANINVITATION,
  Cmd.ARG_GUARDIANINVITE:	Cmd.ARG_GUARDIANINVITATION,
  Cmd.ARG_GUARDIANS:		Cmd.ARG_GUARDIAN,
  Cmd.ARG_HOLD:			Cmd.ARG_VAULTHOLD,
  Cmd.ARG_HOLDS:		Cmd.ARG_VAULTHOLD,
  Cmd.ARG_INBOUNDSSOASSIGNMENTS:	Cmd.ARG_INBOUNDSSOASSIGNMENT,
  Cmd.ARG_INBOUNDSSOCREDENTIALS:	Cmd.ARG_INBOUNDSSOCREDENTIAL,
  Cmd.ARG_INBOUNDSSOPROFILES:	Cmd.ARG_INBOUNDSSOPROFILE,
  Cmd.ARG_INVITEGUARDIAN:	Cmd.ARG_GUARDIANINVITATION,
  Cmd.ARG_LICENCE:		Cmd.ARG_LICENSE,
  Cmd.ARG_LICENCES:		Cmd.ARG_LICENSE,
  Cmd.ARG_LICENSES:		Cmd.ARG_LICENSE,
  Cmd.ARG_MATTER:		Cmd.ARG_VAULTMATTER,
  Cmd.ARG_MATTERS:		Cmd.ARG_VAULTMATTER,
  Cmd.ARG_MEMBER:		Cmd.ARG_GROUPMEMBERS,
  Cmd.ARG_MEMBERS:		Cmd.ARG_GROUPMEMBERS,
  Cmd.ARG_MOBILES:		Cmd.ARG_MOBILE,
  Cmd.ARG_NICKNAME:		Cmd.ARG_ALIAS,
  Cmd.ARG_NICKNAMES:		Cmd.ARG_ALIAS,
  Cmd.ARG_ORGUNIT:		Cmd.ARG_ORG,
  Cmd.ARG_ORGUNITS:		Cmd.ARG_ORGS,
  Cmd.ARG_ORGUNITSHAREDDRIVES:	Cmd.ARG_ORGUNITSHAREDDRIVE,
  Cmd.ARG_OU:			Cmd.ARG_ORG,
  Cmd.ARG_OUS:			Cmd.ARG_ORGS,
  Cmd.ARG_OUSHAREDDRIVE:	Cmd.ARG_ORGUNITSHAREDDRIVE,
  Cmd.ARG_OUSHAREDDRIVES:	Cmd.ARG_ORGUNITSHAREDDRIVE,
  Cmd.ARG_OUTREE:		Cmd.ARG_ORGTREE,
  Cmd.ARG_PARTICIPANTS:		Cmd.ARG_COURSEPARTICIPANTS,
  Cmd.ARG_PEOPLE:		Cmd.ARG_PEOPLEPROFILE,
  Cmd.ARG_PEOPLECONTACTS:	Cmd.ARG_PEOPLECONTACT,
  Cmd.ARG_PEOPLEPROFILES:	Cmd.ARG_PEOPLEPROFILE,
  Cmd.ARG_PERMISSIONS:		Cmd.ARG_PERMISSION,
  Cmd.ARG_PRINTERS:		Cmd.ARG_PRINTER,
  Cmd.ARG_PRINTERMODELS:	Cmd.ARG_PRINTERMODEL,
  Cmd.ARG_PROJECTS:		Cmd.ARG_PROJECT,
  Cmd.ARG_RESELLERCUSTOMERS:	Cmd.ARG_RESOLDCUSTOMER,
  Cmd.ARG_RESELLERSUBSCRIPTIONS:	Cmd.ARG_RESOLDSUBSCRIPTION,
  Cmd.ARG_RESOLDCUSTOMERS:	Cmd.ARG_RESOLDCUSTOMER,
  Cmd.ARG_RESOLDSUBSCRIPTIONS:	Cmd.ARG_RESOLDSUBSCRIPTION,
  Cmd.ARG_ROLE:			Cmd.ARG_ADMINROLE,
  Cmd.ARG_ROLES:		Cmd.ARG_ADMINROLE,
  Cmd.ARG_SAKEYS:		Cmd.ARG_SAKEY,
  Cmd.ARG_SCHEMAS:		Cmd.ARG_SCHEMA,
  Cmd.ARG_SHAREDDRIVES:		Cmd.ARG_SHAREDDRIVE,
  Cmd.ARG_SIGNJWTSERVICEACCOUNT:	Cmd.ARG_GCPSERVICEACCOUNT,
  Cmd.ARG_SITEACLS:		Cmd.ARG_SITEACL,
  Cmd.ARG_SITES:		Cmd.ARG_SITE,
  Cmd.ARG_STORAGEBUCKETS:	Cmd.ARG_STORAGEBUCKET,
  Cmd.ARG_STORAGEFILES:		Cmd.ARG_STORAGEFILE,
  Cmd.ARG_SVCACCTS:		Cmd.ARG_SVCACCT,
  Cmd.ARG_TEAMDRIVE:		Cmd.ARG_SHAREDDRIVE,
  Cmd.ARG_TEAMDRIVES:		Cmd.ARG_SHAREDDRIVE,
  Cmd.ARG_TEAMDRIVEACLS:	Cmd.ARG_SHAREDDRIVEACLS,
  Cmd.ARG_TEAMDRIVEINFO:	Cmd.ARG_SHAREDDRIVEINFO,
  Cmd.ARG_TEAMDRIVEORGANIZERS:	Cmd.ARG_SHAREDDRIVEORGANIZERS,
  Cmd.ARG_TEAMDRIVETHEMES:	Cmd.ARG_SHAREDDRIVETHEMES,
  Cmd.ARG_TOKENS:		Cmd.ARG_TOKEN,
  Cmd.ARG_TRANSFER:		Cmd.ARG_DATATRANSFER,
  Cmd.ARG_TRANSFERS:		Cmd.ARG_DATATRANSFER,
  Cmd.ARG_USERINVITATIONS:	Cmd.ARG_USERINVITATION,
  Cmd.ARG_VAULTCOUNTS:		Cmd.ARG_VAULTCOUNT,
  Cmd.ARG_VAULTEXPORTS:		Cmd.ARG_VAULTEXPORT,
  Cmd.ARG_VAULTHOLDS:		Cmd.ARG_VAULTHOLD,
  Cmd.ARG_VAULTQUERIES:		Cmd.ARG_VAULTQUERY,
  Cmd.ARG_VAULTMATTERS:		Cmd.ARG_VAULTMATTER,
  Cmd.ARG_VERIFICATION:		Cmd.ARG_VERIFY,
  }

# Audit command sub-commands with objects
AUDIT_SUBCOMMANDS_WITH_OBJECTS = {
  'monitor':
    {'create': (Act.CREATE, doCreateMonitor),
     'delete': (Act.DELETE, doDeleteMonitor),
     'list': (Act.LIST, doShowMonitors),
    },
  }

def processAuditCommands():
  CL_subCommand = getChoice(list(AUDIT_SUBCOMMANDS_WITH_OBJECTS))
  CL_objectName = getChoice(AUDIT_SUBCOMMANDS_WITH_OBJECTS[CL_subCommand])
  Act.Set(AUDIT_SUBCOMMANDS_WITH_OBJECTS[CL_subCommand][CL_objectName][CMD_ACTION])
  AUDIT_SUBCOMMANDS_WITH_OBJECTS[CL_subCommand][CL_objectName][CMD_FUNCTION]()

# Oauth command sub-commands
OAUTH2_SUBCOMMANDS = {
  'create': 			(Act.CREATE, doOAuthCreate),
  'delete': 			(Act.DELETE, doOAuthDelete),
  'export': 			(Act.EXPORT, doOAuthExport),
  'info': 			(Act.INFO, doOAuthInfo),
  'refresh': 			(Act.REFRESH, doOAuthRefresh),
  'update': 			(Act.UPDATE, doOAuthUpdate),
  }

# Oauth sub-command aliases
OAUTH2_SUBCOMMAND_ALIASES = {
  'request':			'create',
  'revoke':			'delete',
  'verify':			'info',
  }

def processOauthCommands():
  CL_subCommand = getChoice(OAUTH2_SUBCOMMANDS, choiceAliases=OAUTH2_SUBCOMMAND_ALIASES)
  Act.Set(OAUTH2_SUBCOMMANDS[CL_subCommand][CMD_ACTION])
  if GC.Values[GC.ENABLE_DASA]:
    systemErrorExit(USAGE_ERROR_RC, Msg.COMMAND_NOT_COMPATIBLE_WITH_ENABLE_DASA.format('oauth', CL_subCommand))
  OAUTH2_SUBCOMMANDS[CL_subCommand][CMD_FUNCTION]()

# Calendar command sub-commands
CALENDAR_SUBCOMMANDS = {
  'showacl': 			(Act.SHOW, doCalendarsPrintShowACLs),
  'printacl': 			(Act.PRINT, doCalendarsPrintShowACLs),
  'addevent': 			(Act.ADD, doCalendarsCreateEvent),
  'deleteevent': 		(Act.DELETE, doCalendarsDeleteEventsOld),
  'moveevent': 			(Act.MOVE, doCalendarsMoveEventsOld),
  'updateevent': 		(Act.UPDATE, doCalendarsUpdateEventsOld),
  'printevents': 		(Act.PRINT, doCalendarsPrintShowEvents),
  'wipe': 			(Act.WIPE, doCalendarsWipeEvents),
  'modify': 			(Act.MODIFY, doCalendarsModifySettings),
  }

CALENDAR_OLDACL_SUBCOMMANDS = {
  'add': 			(Act.ADD, doCalendarsCreateACL),
  'create': 			(Act.CREATE, doCalendarsCreateACL),
  'delete': 			(Act.DELETE, doCalendarsDeleteACL),
  'update': 			(Act.UPDATE, doCalendarsUpdateACL),
  }

# Calendar sub-command aliases
CALENDAR_OLDACL_SUBCOMMAND_ALIASES = {
  'del':			'delete',
  }

# Calendars command sub-commands with objects
CALENDARS_SUBCOMMANDS_WITH_OBJECTS = {
  'add':
    (Act.ADD,
     {Cmd.ARG_CALENDARACL:	doCalendarsCreateACLs,
      Cmd.ARG_EVENT:		doCalendarsCreateEvent,
     }
    ),
  'create':
    (Act.CREATE,
     {Cmd.ARG_CALENDARACL:	doCalendarsCreateACLs,
      Cmd.ARG_EVENT:		doCalendarsCreateEvent,
     }
    ),
  'delete':
    (Act.DELETE,
     {Cmd.ARG_CALENDARACL:	doCalendarsDeleteACLs,
      Cmd.ARG_EVENT:		doCalendarsDeleteEvents,
     }
    ),
  'empty':
    (Act.EMPTY,
     {Cmd.ARG_CALENDARTRASH:	doCalendarsEmptyTrash,
     }
    ),
  'import':
    (Act.IMPORT,
     {Cmd.ARG_EVENT:		doCalendarsImportEvent,
     }
    ),
  'info':
    (Act.INFO,
     {Cmd.ARG_CALENDARACL:	doCalendarsInfoACLs,
      Cmd.ARG_EVENT:		doCalendarsInfoEvents,
     }
    ),
  'move':
    (Act.MOVE,
     {Cmd.ARG_EVENT:		doCalendarsMoveEvents,
     }
    ),
  'print':
    (Act.PRINT,
     {Cmd.ARG_CALENDARACL:	doCalendarsPrintShowACLs,
      Cmd.ARG_EVENT:		doCalendarsPrintShowEvents,
      Cmd.ARG_SETTINGS:		doCalendarsPrintShowSettings,
     }
    ),
  'purge':
    (Act.PURGE,
     {Cmd.ARG_EVENT:		doCalendarsPurgeEvents,
     }
    ),
  'show':
    (Act.SHOW,
     {Cmd.ARG_CALENDARACL:	doCalendarsPrintShowACLs,
      Cmd.ARG_EVENT:		doCalendarsPrintShowEvents,
      Cmd.ARG_SETTINGS:		doCalendarsPrintShowSettings,
     }
    ),
  'update':
    (Act.UPDATE,
     {Cmd.ARG_CALENDARACL:	doCalendarsUpdateACLs,
      Cmd.ARG_EVENT:		doCalendarsUpdateEvents,
     }
    ),
  'wipe':
    (Act.WIPE,
     {Cmd.ARG_EVENT:		doCalendarsWipeEvents,
     }
    ),
  }

CALENDARS_SUBCOMMANDS_OBJECT_ALIASES = {
  Cmd.ARG_ACL:			Cmd.ARG_CALENDARACL,
  Cmd.ARG_ACLS:			Cmd.ARG_CALENDARACL,
  Cmd.ARG_CALENDARACLS:		Cmd.ARG_CALENDARACL,
  Cmd.ARG_EVENTS:		Cmd.ARG_EVENT,
  }

def processCalendarsCommands():
  calendarList = getEntityList(Cmd.OB_EMAIL_ADDRESS_ENTITY)
  CL_subCommand = getChoice(CALENDAR_SUBCOMMANDS, defaultChoice=None)
  if CL_subCommand:
    Act.Set(CALENDAR_SUBCOMMANDS[CL_subCommand][CMD_ACTION])
    CALENDAR_SUBCOMMANDS[CL_subCommand][CMD_FUNCTION](calendarList)
    return
  CL_subCommand = getChoice(CALENDAR_OLDACL_SUBCOMMANDS, choiceAliases=CALENDAR_OLDACL_SUBCOMMAND_ALIASES, defaultChoice=None)
  if CL_subCommand:
    Act.Set(CALENDAR_OLDACL_SUBCOMMANDS[CL_subCommand][CMD_ACTION])
    CL_objectName = getChoice([Cmd.ARG_CALENDARACL, Cmd.ARG_EVENT], choiceAliases=CALENDARS_SUBCOMMANDS_OBJECT_ALIASES, defaultChoice=None)
    if not CL_objectName:
      CALENDAR_OLDACL_SUBCOMMANDS[CL_subCommand][CMD_FUNCTION](calendarList)
    else:
      CALENDARS_SUBCOMMANDS_WITH_OBJECTS[CL_subCommand][CMD_FUNCTION][CL_objectName](calendarList)
    return
  CL_subCommand = getChoice(CALENDARS_SUBCOMMANDS_WITH_OBJECTS)
  Act.Set(CALENDARS_SUBCOMMANDS_WITH_OBJECTS[CL_subCommand][CMD_ACTION])
  CL_objectName = getChoice(CALENDARS_SUBCOMMANDS_WITH_OBJECTS[CL_subCommand][CMD_FUNCTION], choiceAliases=CALENDARS_SUBCOMMANDS_OBJECT_ALIASES)
  CALENDARS_SUBCOMMANDS_WITH_OBJECTS[CL_subCommand][CMD_FUNCTION][CL_objectName](calendarList)

# Course command sub-commands
COURSE_SUBCOMMANDS = {
  'add': 			(Act.ADD, doCourseAddItems),
  'clear': 			(Act.REMOVE, doCourseClearParticipants),
  'remove': 			(Act.REMOVE, doCourseRemoveItems),
  'sync': 			(Act.SYNC, doCourseSyncParticipants),
  }

# Course sub-command aliases
COURSE_SUBCOMMAND_ALIASES = {
  'create':			'add',
  'del':			'remove',
  'delete':			'remove',
  }

def executeCourseCommands(courseIdList, getEntityListArg):
  CL_subCommand = getChoice(COURSE_SUBCOMMANDS, choiceAliases=COURSE_SUBCOMMAND_ALIASES)
  Act.Set(COURSE_SUBCOMMANDS[CL_subCommand][CMD_ACTION])
  COURSE_SUBCOMMANDS[CL_subCommand][CMD_FUNCTION](courseIdList, getEntityListArg)

def processCourseCommands():
  executeCourseCommands(getStringReturnInList(Cmd.OB_COURSE_ID), False)

def processCoursesCommands():
  executeCourseCommands(getEntityList(Cmd.OB_COURSE_ENTITY, shlexSplit=True), True)

# Resource command sub-commands
RESOURCE_SUBCOMMANDS_WITH_OBJECTS = {
  'add':
    (Act.ADD,
     {Cmd.ARG_CALENDARACL:	doResourceCreateCalendarACLs,
     }
    ),
  'create':
    (Act.CREATE,
     {Cmd.ARG_CALENDARACL:	doResourceCreateCalendarACLs,
     }
    ),
  'update':
    (Act.UPDATE,
     {Cmd.ARG_CALENDARACL:	doResourceUpdateCalendarACLs,
     }
    ),
  'delete':
    (Act.DELETE,
     {Cmd.ARG_CALENDARACL:	doResourceDeleteCalendarACLs,
     }
    ),
  'info':
    (Act.INFO,
     {Cmd.ARG_CALENDARACL:	doResourceInfoCalendarACLs,
     }
    ),
  'print':
    (Act.PRINT,
     {Cmd.ARG_CALENDARACL:	doResourcePrintShowCalendarACLs,
     }
    ),
  'show':
    (Act.SHOW,
     {Cmd.ARG_CALENDARACL:	doResourcePrintShowCalendarACLs,
     }
    ),
  }

# Resource sub-command aliases
RESOURCE_SUBCOMMAND_ALIASES = {
  'del':			'delete',
  }

RESOURCE_SUBCOMMANDS_OBJECT_ALIASES = {
  Cmd.ARG_ACL:			Cmd.ARG_CALENDARACL,
  Cmd.ARG_ACLS:			Cmd.ARG_CALENDARACL,
  Cmd.ARG_CALENDARACLS:		Cmd.ARG_CALENDARACL,
  }

def executeResourceCommands(resourceEntity):
  CL_subCommand = getChoice(RESOURCE_SUBCOMMANDS_WITH_OBJECTS, choiceAliases=RESOURCE_SUBCOMMAND_ALIASES)
  Act.Set(RESOURCE_SUBCOMMANDS_WITH_OBJECTS[CL_subCommand][CMD_ACTION])
  CL_objectName = getChoice(RESOURCE_SUBCOMMANDS_WITH_OBJECTS[CL_subCommand][CMD_FUNCTION], choiceAliases=RESOURCE_SUBCOMMANDS_OBJECT_ALIASES)
  RESOURCE_SUBCOMMANDS_WITH_OBJECTS[CL_subCommand][CMD_FUNCTION][CL_objectName](resourceEntity)

def processResourceCommands():
  executeResourceCommands(getStringReturnInList(Cmd.OB_RESOURCE_ID))

def processResourcesCommands():
  executeResourceCommands(getEntityList(Cmd.OB_RESOURCE_ENTITY))

# Commands
COMMANDS_MAP = {
  'oauth':			processOauthCommands,
  'audit':			processAuditCommands,
  'calendars':			processCalendarsCommands,
  'course':			processCourseCommands,
  'courses':			processCoursesCommands,
  'resource':			processResourceCommands,
  'resources':			processResourcesCommands,
  }

# Commands aliases
COMMANDS_ALIASES = {
  'oauth2':			'oauth',
  'calendar':			'calendars',
  }

# <CrOSTypeEntity> commands
CROS_COMMANDS = {
  'getcommand': 		(Act.GET_COMMAND_RESULT, getCommandResultCrOSDevices),
  'info': 			(Act.INFO, infoCrOSDevices),
  'issuecommand': 		(Act.ISSUE_COMMAND, issueCommandCrOSDevices),
  'list': 			(Act.LIST, doListCrOS),
  'print': 			(Act.PRINT, doPrintCrOSEntity),
  'update': 			(Act.UPDATE, updateCrOSDevices),
  }

CROS_COMMANDS_WITH_OBJECTS = {
  'get':
    (Act.DOWNLOAD,
     {Cmd.ARG_DEVICEFILE:	getCrOSDeviceFiles,
     }
    ),
  'show':
    (Act.SHOW,
     {Cmd.ARG_COUNT: 		showCountCrOS,
     }
    ),
  }

CROS_COMMANDS_OBJ_ALIASES = {
  Cmd.ARG_DEVICEFILES:		Cmd.ARG_DEVICEFILE,
  Cmd.ARG_COUNTS:		Cmd.ARG_COUNT,
  }

# <UserTypeEntity> commands
USER_COMMANDS = {
  'delegate': 			(Act.ADD, delegateTo),
  'deprovision':		(Act.DEPROVISION, deprovisionUser),
  'draftemail': 		(Act.DRAFT, draftMessage),
  'filter': 			(Act.ADD, createFilter),
  'forward': 			(Act.SET, setForward),
  'imap': 			(Act.SET, setImap),
  'importemail': 		(Act.IMPORT, importMessage),
  'insertemail': 		(Act.INSERT, insertMessage),
  'label': 			(Act.ADD, createLabel),
  'list': 			(Act.LIST, doListUser),
  'language': 			(Act.SET, setLanguage),
  'pop': 			(Act.SET, setPop),
  'profile': 			(Act.SET, setProfile),
  'sendas': 			(Act.ADD, createUpdateSendAs),
  'sendemail': 			(Act.SENDEMAIL, doSendEmail),
  'signature': 			(Act.SET, setSignature),
  'signout': 			(Act.SIGNOUT, signoutTurnoff2SVUsers),
  'turnoff2sv': 		(Act.TURNOFF2SV, signoutTurnoff2SVUsers),
  'vacation': 			(Act.SET, setVacation),
  'waitformailbox': 		(Act.WAITFORMAILBOX, waitForMailbox),
  }

# User commands with objects
#
USER_ADD_CREATE_FUNCTIONS = {
  Cmd.ARG_CALENDAR:		addCreateCalendars,
  Cmd.ARG_GROUP:		addUserToGroups,
  Cmd.ARG_CALENDARACL:		createCalendarACLs,
  Cmd.ARG_CHATMEMBER:		createChatMember,
  Cmd.ARG_CHATMESSAGE:		createChatMessage,
  Cmd.ARG_CHATSPACE:		createChatSpace,
  Cmd.ARG_CLASSROOMINVITATION:	createClassroomInvitations,
  Cmd.ARG_CONTACTDELEGATE:	processContactDelegates,
  Cmd.ARG_CSEIDENTITY:		createUpdateCSEIdentity,
  Cmd.ARG_CSEKEYPAIR:		createCSEKeyPair,
  Cmd.ARG_LOOKERSTUDIOPERMISSION:	processLookerStudioPermissions,
  Cmd.ARG_DELEGATE:		processDelegates,
  Cmd.ARG_DRIVEFILE:		createDriveFile,
  Cmd.ARG_DRIVEFILEACL:		createDriveFileACL,
  Cmd.ARG_DRIVEFILESHORTCUT:	createDriveFileShortcut,
  Cmd.ARG_DRIVEFOLDERPATH:	createDriveFolderPath,
  Cmd.ARG_DRIVELABELPERMISSION:	createDriveLabelPermissions,
  Cmd.ARG_EVENT:		createCalendarEvent,
  Cmd.ARG_FILTER:		createFilter,
  Cmd.ARG_FOCUSTIME:		createFocusTime,
  Cmd.ARG_FORM:			createForm,
  Cmd.ARG_FORWARDINGADDRESS:	createForwardingAddresses,
  Cmd.ARG_GUARDIAN:		inviteGuardians,
  Cmd.ARG_GUARDIANINVITATION:	inviteGuardians,
  Cmd.ARG_LABEL:		createLabel,
  Cmd.ARG_LABELLIST:		createLabelList,
  Cmd.ARG_LICENSE:		createLicense,
  Cmd.ARG_MEETSPACE:		createMeetSpace,
  Cmd.ARG_NOTE:			createNote,
  Cmd.ARG_NOTEACL:		createNotesACLs,
  Cmd.ARG_OUTOFOFFICE:		createOutOfOffice,
  Cmd.ARG_PEOPLECONTACT:	createUserPeopleContact,
  Cmd.ARG_PEOPLECONTACTGROUP:	createUserPeopleContactGroup,
  Cmd.ARG_PERMISSION:		createDriveFilePermissions,
  Cmd.ARG_SENDAS:		createUpdateSendAs,
  Cmd.ARG_SHAREDDRIVE:		createSharedDrive,
  Cmd.ARG_SHEET:		createSheet,
  Cmd.ARG_SITE:			deprecatedUserSites,
  Cmd.ARG_SITEACL:		deprecatedUserSites,
  Cmd.ARG_SMIME:		createSmime,
  Cmd.ARG_TASK:			processTasks,
  Cmd.ARG_TASKLIST:		processTasklists,
  Cmd.ARG_WORKINGLOCATION:	createWorkingLocation,
  }

USER_COMMANDS_WITH_OBJECTS = {
  'accept':
    (Act.ACCEPT,
     {Cmd.ARG_CLASSROOMINVITATION:	acceptClassroomInvitations,
     }
    ),
  'add':
    (Act.ADD,
     USER_ADD_CREATE_FUNCTIONS
    ),
  'append':
    (Act.APPEND,
     {Cmd.ARG_SHEETRANGE:	appendSheetRanges,
     }
    ),
  'archive':
    (Act.ARCHIVE,
     {Cmd.ARG_MESSAGE:		archiveMessages,
     }
    ),
  'cancel':
    (Act.CANCEL,
     {Cmd.ARG_GUARDIANINVITATION:	cancelGuardianInvitations,
     }
    ),
  'check':
    (Act.CHECK,
     {Cmd.ARG_DRIVEFILESHORTCUT:	checkDriveFileShortcut,
      Cmd.ARG_GROUP:		checkUserInGroups,
      Cmd.ARG_ISINVITABLE:	checkCIUserIsInvitable,
      Cmd.ARG_SERVICEACCOUNT:	checkServiceAccount,
     }
    ),
  'claim':
    (Act.CLAIM,
     {Cmd.ARG_OWNERSHIP:	claimOwnership,
     }
    ),
  'clear':
    (Act.CLEAR,
     {Cmd.ARG_GUARDIAN:		clearGuardians,
      Cmd.ARG_PEOPLECONTACT:	clearUserPeopleContacts,
      Cmd.ARG_SHEETRANGE:	clearSheetRanges,
      Cmd.ARG_TASKLIST:		processTasklists,
   }
    ),
  'collect':
    (Act.COLLECT,
     {Cmd.ARG_ORPHANS:		collectOrphans,
     }
    ),
  'copy':
    (Act.COPY,
     {Cmd.ARG_DRIVEFILE:	copyDriveFile,
      Cmd.ARG_OTHERCONTACT:	copyUserPeopleOtherContacts,
      Cmd.ARG_SHAREDDRIVEACLS:	copySyncSharedDriveACLs,
     }
    ),
  'create':
    (Act.CREATE,
     USER_ADD_CREATE_FUNCTIONS
    ),
  'dedup':
    (Act.DEDUP,
     {Cmd.ARG_PEOPLECONTACT:	dedupReplaceDomainUserPeopleContacts,
     }
    ),
  'delete':
    (Act.DELETE,
     {Cmd.ARG_ALIAS:		deleteUsersAliases,
      Cmd.ARG_ASP:		deleteASP,
      Cmd.ARG_BACKUPCODE:	deleteBackupCodes,
      Cmd.ARG_CALENDAR:		deleteCalendars,
      Cmd.ARG_CALENDARACL:	deleteCalendarACLs,
      Cmd.ARG_CHATMEMBER:	deleteUpdateChatMember,
      Cmd.ARG_CHATMESSAGE:	deleteChatMessage,
      Cmd.ARG_CHATSPACE:	deleteChatSpace,
      Cmd.ARG_CLASSROOMINVITATION:	deleteClassroomInvitations,
      Cmd.ARG_CONTACTDELEGATE:	processContactDelegates,
      Cmd.ARG_CSEIDENTITY:	processCSEIdentity,
      Cmd.ARG_LOOKERSTUDIOPERMISSION:	processLookerStudioPermissions,
      Cmd.ARG_DELEGATE:		processDelegates,
      Cmd.ARG_DRIVEFILE:	deleteDriveFile,
      Cmd.ARG_DRIVEFILEACL:	deleteDriveFileACLs,
      Cmd.ARG_DRIVELABELPERMISSION:	deleteDriveLabelPermissions,
      Cmd.ARG_EMPTYDRIVEFOLDERS:	deleteEmptyDriveFolders,
      Cmd.ARG_EVENT:		deleteCalendarEvents,
      Cmd.ARG_FILEREVISION:	deleteFileRevisions,
      Cmd.ARG_FILTER:		deleteFilters,
      Cmd.ARG_FOCUSTIME:	deleteFocusTime,
      Cmd.ARG_FORWARDINGADDRESS:	deleteForwardingAddresses,
      Cmd.ARG_GROUP:		deleteUserFromGroups,
      Cmd.ARG_GUARDIAN:		deleteGuardians,
      Cmd.ARG_LABEL:		deleteLabel,
      Cmd.ARG_LABELLIST:	deleteLabelList,
      Cmd.ARG_LABELID:		deleteLabelId,
      Cmd.ARG_LABELIDLIST:	deleteLabelIdList,
      Cmd.ARG_LICENSE:		deleteLicense,
      Cmd.ARG_MESSAGE:		processMessages,
      Cmd.ARG_NOTE:		deleteInfoNotes,
      Cmd.ARG_NOTEACL:		deleteNotesACLs,
      Cmd.ARG_OUTOFOFFICE:	deleteOutOfOffice,
      Cmd.ARG_OTHERCONTACT:	processUserPeopleOtherContacts,
      Cmd.ARG_PEOPLECONTACT:	deleteUserPeopleContacts,
      Cmd.ARG_PEOPLECONTACTGROUP:	deleteUserPeopleContactGroups,
      Cmd.ARG_PEOPLECONTACTPHOTO:	deleteUserPeopleContactPhoto,
      Cmd.ARG_PERMISSION:	deletePermissions,
      Cmd.ARG_PHOTO:		deletePhoto,
      Cmd.ARG_SENDAS:		deleteInfoSendAs,
      Cmd.ARG_SMIME:		deleteSmime,
      Cmd.ARG_SHAREDDRIVE:	deleteSharedDrive,
      Cmd.ARG_SITEACL:		deprecatedUserSites,
      Cmd.ARG_TASK:		processTasks,
      Cmd.ARG_TASKLIST:		processTasklists,
      Cmd.ARG_THREAD:		processThreads,
      Cmd.ARG_TOKEN:		deleteTokens,
      Cmd.ARG_USER:		deleteUsers,
      Cmd.ARG_WORKINGLOCATION:	deleteWorkingLocation,
     }
    ),
  'disable':
    (Act.DISABLE,
     {Cmd.ARG_CSEKEYPAIR:	processCSEKeyPair,
     }
    ),
  'draft':
    (Act.DRAFT,
     {Cmd.ARG_MESSAGE:		draftMessage,
     }
    ),
  'empty':
    (Act.EMPTY,
     {Cmd.ARG_CALENDARTRASH:	emptyCalendarTrash,
      Cmd.ARG_DRIVETRASH:	emptyDriveTrash,
     }
    ),
  'enable':
    (Act.ENABLE,
     {Cmd.ARG_CSEKEYPAIR:	processCSEKeyPair,
     }
    ),
  'end':
    (Act.END,
     {Cmd.ARG_MEETCONFERENCE:	endMeetConference,
     }
    ),
  'export':
    (Act.EXPORT,
     {Cmd.ARG_MESSAGE:		exportMessages,
      Cmd.ARG_THREAD:		exportThreads,
     }
    ),
  'get':
    (Act.DOWNLOAD,
     {Cmd.ARG_DOCUMENT:		getGoogleDocument,
      Cmd.ARG_DRIVEFILE:	getDriveFile,
      Cmd.ARG_NOTEATTACHMENT:	getNoteAttachments,
      Cmd.ARG_PEOPLECONTACTPHOTO:	getUserPeopleContactPhoto,
      Cmd.ARG_PHOTO:		getUserPhoto,
      Cmd.ARG_PROFILE_PHOTO:	getProfilePhoto,
     }
    ),
  'hide':
    (Act.HIDE,
     {Cmd.ARG_SHAREDDRIVE:	hideUnhideSharedDrive,
     }
    ),
  'import':
    (Act.IMPORT,
     {Cmd.ARG_EVENT:		importCalendarEvent,
      Cmd.ARG_MESSAGE:		importMessage,
      Cmd.ARG_TASKLIST:		importTasklist,
     }
    ),
  'info':
    (Act.INFO,
     {Cmd.ARG_CALENDAR:		infoCalendars,
      Cmd.ARG_CALENDARACL:	infoCalendarACLs,
      Cmd.ARG_CHATEVENT:	infoChatEvent,
      Cmd.ARG_CHATMEMBER:	infoChatMember,
      Cmd.ARG_CHATMESSAGE:	infoChatMessage,
      Cmd.ARG_CHATSPACE:	infoChatSpace,
      Cmd.ARG_CHATSPACEDM:	infoChatSpaceDM,
      Cmd.ARG_CIGROUPMEMBERS:	infoCIGroupMembers,
      Cmd.ARG_CSEIDENTITY:	processCSEIdentity,
      Cmd.ARG_CSEKEYPAIR:	processCSEKeyPair,
      Cmd.ARG_DRIVEFILE:	showFileInfo,
      Cmd.ARG_DRIVEFILEACL:	infoDriveFileACLs,
      Cmd.ARG_DRIVELABEL:	infoDriveLabels,
      Cmd.ARG_EVENT:		infoCalendarEvents,
      Cmd.ARG_FILTER:		infoFilters,
      Cmd.ARG_FORWARDINGADDRESS:	infoForwardingAddresses,
      Cmd.ARG_GROUPMEMBERS:	infoGroupMembers,
      Cmd.ARG_MEETSPACE:	infoMeetSpace,
      Cmd.ARG_NOTE:		deleteInfoNotes,
      Cmd.ARG_PEOPLECONTACT:	infoUserPeopleContacts,
      Cmd.ARG_PEOPLECONTACTGROUP:	infoUserPeopleContactGroups,
      Cmd.ARG_SENDAS:		deleteInfoSendAs,
      Cmd.ARG_SHAREDDRIVE:	infoSharedDrive,
      Cmd.ARG_SHEET:		infoPrintShowSheets,
      Cmd.ARG_SITE:		deprecatedUserSites,
      Cmd.ARG_SITEACL:		deprecatedUserSites,
      Cmd.ARG_TASK:		processTasks,
      Cmd.ARG_TASKLIST:		processTasklists,
      Cmd.ARG_USER:		infoUsers,
     }
    ),
  'insert':
    (Act.INSERT,
     {Cmd.ARG_MESSAGE:		insertMessage,
     }
    ),
  'modify':
    (Act.MODIFY,
     {Cmd.ARG_CALENDAR:		modifyCalendars,
      Cmd.ARG_CHATMEMBER:	deleteUpdateChatMember,
      Cmd.ARG_MESSAGE:		processMessages,
      Cmd.ARG_THREAD:		processThreads,
     }
    ),
  'move':
    (Act.MOVE,
     {Cmd.ARG_DRIVEFILE:	moveDriveFile,
      Cmd.ARG_EVENT:		moveCalendarEvents,
      Cmd.ARG_OTHERCONTACT:	processUserPeopleOtherContacts,
      Cmd.ARG_TASK:		processTasks,
     }
    ),
  'obliterate':
    (Act.OBLITERATE,
     {Cmd.ARG_CSEKEYPAIR:	processCSEKeyPair,
     }
    ),
  'purge':
    (Act.PURGE,
     {Cmd.ARG_DRIVEFILE:	purgeDriveFile,
      Cmd.ARG_EVENT:		purgeCalendarEvents,
     }
    ),
  'print':
    (Act.PRINT,
     {Cmd.ARG_ANALYTICACCOUNT:	printShowAnalyticAccounts,
      Cmd.ARG_ANALYTICACCOUNTSUMMARY:	printShowAnalyticAccountSummaries,
      Cmd.ARG_ANALYTICDATASTREAM:	printShowAnalyticDatastreams,
      Cmd.ARG_ANALYTICPROPERTY:	printShowAnalyticProperties,
      Cmd.ARG_ASP:		printShowASPs,
      Cmd.ARG_BACKUPCODE:	printShowBackupCodes,
      Cmd.ARG_CALENDAR:		printShowCalendars,
      Cmd.ARG_CALENDARACL:	printShowCalendarACLs,
      Cmd.ARG_CALSETTINGS:	printShowCalSettings,
      Cmd.ARG_CHATEVENT:	printShowChatEvents,
      Cmd.ARG_CHATMEMBER:	printShowChatMembers,
      Cmd.ARG_CHATMESSAGE:	printShowChatMessages,
      Cmd.ARG_CHATSPACE:	printShowChatSpaces,
      Cmd.ARG_CLASSROOMINVITATION:	printShowClassroomInvitations,
      Cmd.ARG_CLASSROOMPROFILE:	printShowClassroomProfile,
      Cmd.ARG_CONTACTDELEGATE:	printShowContactDelegates,
      Cmd.ARG_CSEIDENTITY:	printShowCSEIdentities,
      Cmd.ARG_CSEKEYPAIR:	printShowCSEKeyPairs,
      Cmd.ARG_LOOKERSTUDIOASSET:	printShowLookerStudioAssets,
      Cmd.ARG_LOOKERSTUDIOPERMISSION:	printShowLookerStudioPermissions,
      Cmd.ARG_DELEGATE:		printShowDelegates,
      Cmd.ARG_DISKUSAGE:	printDiskUsage,
      Cmd.ARG_DRIVEACTIVITY:	printDriveActivity,
      Cmd.ARG_DRIVEFILEACL:	printShowDriveFileACLs,
      Cmd.ARG_DRIVELABEL:	printShowDriveLabels,
      Cmd.ARG_DRIVELABELPERMISSION:	printShowDriveLabelPermissions,
      Cmd.ARG_DRIVELASTMODIFICATION:	printShowDrivelastModifications,
      Cmd.ARG_DRIVESETTINGS:	printShowDriveSettings,
      Cmd.ARG_EMPTYDRIVEFOLDERS:	printEmptyDriveFolders,
      Cmd.ARG_EVENT:		printShowCalendarEvents,
      Cmd.ARG_FILECOMMENT:	printShowFileComments,
      Cmd.ARG_FILECOUNT:	printShowFileCounts,
      Cmd.ARG_FILEINFO:		showFileInfo,
      Cmd.ARG_FILELIST:		printFileList,
      Cmd.ARG_FILEPATH:		printShowFilePaths,
      Cmd.ARG_FILEREVISION:	printShowFileRevisions,
      Cmd.ARG_FILESHARECOUNT:	printShowFileShareCounts,
      Cmd.ARG_FILETREE:		printShowFileTree,
      Cmd.ARG_FILTER:		printShowFilters,
      Cmd.ARG_FOCUSTIME:	printShowFocusTime,
      Cmd.ARG_FORM:		printShowForms,
      Cmd.ARG_FORMRESPONSE:	printShowFormResponses,
      Cmd.ARG_FORWARD:		printShowForward,
      Cmd.ARG_FORWARDINGADDRESS:	printShowForwardingAddresses,
      Cmd.ARG_GMAILPROFILE:	printShowGmailProfile,
      Cmd.ARG_GROUP:		printShowUserGroups,
      Cmd.ARG_GROUPSLIST:	printUserGroupsList,
      Cmd.ARG_GROUPTREE:	printShowGroupTree,
      Cmd.ARG_GUARDIAN:		printShowGuardians,
      Cmd.ARG_IMAP:		printShowImap,
      Cmd.ARG_LABEL:		printShowLabels,
      Cmd.ARG_LANGUAGE:		printShowLanguage,
      Cmd.ARG_MEETCONFERENCE:	printShowMeetConferences,
      Cmd.ARG_MEETPARTICIPANT:	printShowMeetParticipants,
      Cmd.ARG_MEETRECORDING:	printShowMeetRecordings,
      Cmd.ARG_MEETTRANSCRIPT:	printShowMeetTranscripts,
      Cmd.ARG_MESSAGE:		printShowMessages,
      Cmd.ARG_NOTE:		printShowNotes,
      Cmd.ARG_OTHERCONTACT:	printShowUserPeopleOtherContacts,
      Cmd.ARG_OUTOFOFFICE:	printShowOutOfOffice,
      Cmd.ARG_FILEPARENTTREE:	printFileParentTree,
      Cmd.ARG_PEOPLECONTACT:	printShowUserPeopleContacts,
      Cmd.ARG_PEOPLECONTACTGROUP:	printShowUserPeopleContactGroups,
      Cmd.ARG_PEOPLEPROFILE:	printShowUserPeopleProfiles,
      Cmd.ARG_POP:		printShowPop,
      Cmd.ARG_SENDAS:		printShowSendAs,
      Cmd.ARG_SHAREDDRIVE:	printShowSharedDrives,
      Cmd.ARG_SHAREDDRIVEACLS:	printShowSharedDriveACLs,
      Cmd.ARG_SHAREDDRIVEORGANIZERS:	printSharedDriveOrganizers,
      Cmd.ARG_SHEET:		infoPrintShowSheets,
      Cmd.ARG_SHEETRANGE:	printShowSheetRanges,
      Cmd.ARG_SIGNATURE:	printShowSignature,
      Cmd.ARG_SMIME:		printShowSmimes,
      Cmd.ARG_SITE:		deprecatedUserSites,
      Cmd.ARG_SITEACL:		deprecatedUserSites,
      Cmd.ARG_SITEACTIVITY:	deprecatedUserSites,
      Cmd.ARG_TASK:		printShowTasks,
      Cmd.ARG_TASKLIST:		printShowTasklists,
      Cmd.ARG_THREAD:		printShowThreads,
      Cmd.ARG_TOKEN:		printShowTokens,
      Cmd.ARG_USER:		doPrintUserEntity,
      Cmd.ARG_USERLIST:		doPrintUserList,
      Cmd.ARG_VACATION:		printShowVacation,
      Cmd.ARG_VAULTHOLD:	printShowUserVaultHolds,
      Cmd.ARG_WORKINGLOCATION:	printShowWorkingLocation,
      Cmd.ARG_YOUTUBECHANNEL:	printShowYouTubeChannel,
     }
    ),
  'process':
    (Act.PROCESS,
     {Cmd.ARG_FILEDRIVELABEL:	processFileDriveLabels,
     }
    ),
  'remove':
    (Act.REMOVE,
     {Cmd.ARG_CALENDAR:		removeCalendars,
      Cmd.ARG_CHATMEMBER:	deleteUpdateChatMember,
      Cmd.ARG_DRIVELABELPERMISSION:	deleteDriveLabelPermissions,
     }
    ),
  'replacedomain':
    (Act.REPLACE_DOMAIN,
     {Cmd.ARG_PEOPLECONTACT:	dedupReplaceDomainUserPeopleContacts,
     }
    ),
  'show':
    (Act.SHOW,
     {Cmd.ARG_ANALYTICACCOUNT:	printShowAnalyticAccounts,
      Cmd.ARG_ANALYTICACCOUNTSUMMARY:	printShowAnalyticAccountSummaries,
      Cmd.ARG_ANALYTICDATASTREAM:	printShowAnalyticDatastreams,
      Cmd.ARG_ANALYTICPROPERTY:	printShowAnalyticProperties,
      Cmd.ARG_ASP:		printShowASPs,
      Cmd.ARG_BACKUPCODE:	printShowBackupCodes,
      Cmd.ARG_CALENDAR:		printShowCalendars,
      Cmd.ARG_CALENDARACL:	printShowCalendarACLs,
      Cmd.ARG_CALSETTINGS:	printShowCalSettings,
      Cmd.ARG_CHATEVENT:	printShowChatEvents,
      Cmd.ARG_CHATMEMBER:	printShowChatMembers,
      Cmd.ARG_CHATMESSAGE:	printShowChatMessages,
      Cmd.ARG_CHATSPACE:	printShowChatSpaces,
      Cmd.ARG_CLASSROOMINVITATION:	printShowClassroomInvitations,
      Cmd.ARG_CLASSROOMPROFILE:	printShowClassroomProfile,
      Cmd.ARG_CONTACTDELEGATE:	printShowContactDelegates,
      Cmd.ARG_COUNT: 		showCountUser,
      Cmd.ARG_CSEIDENTITY:	printShowCSEIdentities,
      Cmd.ARG_CSEKEYPAIR:	printShowCSEKeyPairs,
      Cmd.ARG_LOOKERSTUDIOASSET:	printShowLookerStudioAssets,
      Cmd.ARG_LOOKERSTUDIOPERMISSION:	printShowLookerStudioPermissions,
      Cmd.ARG_DELEGATE:		printShowDelegates,
      Cmd.ARG_DISKUSAGE:	printDiskUsage,
      Cmd.ARG_DRIVEACTIVITY:	printDriveActivity,
      Cmd.ARG_DRIVEFILEACL:	printShowDriveFileACLs,
      Cmd.ARG_DRIVELABEL:	printShowDriveLabels,
      Cmd.ARG_DRIVELABELPERMISSION:	printShowDriveLabelPermissions,
      Cmd.ARG_DRIVELASTMODIFICATION:	printShowDrivelastModifications,
      Cmd.ARG_DRIVESETTINGS:	printShowDriveSettings,
      Cmd.ARG_EVENT:		printShowCalendarEvents,
      Cmd.ARG_FILECOMMENT:	printShowFileComments,
      Cmd.ARG_FILECOUNT:	printShowFileCounts,
      Cmd.ARG_FILEINFO:		showFileInfo,
      Cmd.ARG_FILELIST:		printFileList,
      Cmd.ARG_FILEPATH:		printShowFilePaths,
      Cmd.ARG_FILEREVISION:	printShowFileRevisions,
      Cmd.ARG_FILESHARECOUNT:	printShowFileShareCounts,
      Cmd.ARG_FILETREE:		printShowFileTree,
      Cmd.ARG_FILTER:		printShowFilters,
      Cmd.ARG_FOCUSTIME:	printShowFocusTime,
      Cmd.ARG_FORM:		printShowForms,
      Cmd.ARG_FORMRESPONSE:	printShowFormResponses,
      Cmd.ARG_FORWARD:		printShowForward,
      Cmd.ARG_FORWARDINGADDRESS:	printShowForwardingAddresses,
      Cmd.ARG_GMAILPROFILE:	printShowGmailProfile,
      Cmd.ARG_GROUP:		printShowUserGroups,
      Cmd.ARG_GROUPTREE:	printShowGroupTree,
      Cmd.ARG_GUARDIAN:		printShowGuardians,
      Cmd.ARG_IMAP:		printShowImap,
      Cmd.ARG_LABEL:		printShowLabels,
      Cmd.ARG_LANGUAGE:		printShowLanguage,
      Cmd.ARG_MEETCONFERENCE:	printShowMeetConferences,
      Cmd.ARG_MEETPARTICIPANT:	printShowMeetParticipants,
      Cmd.ARG_MEETRECORDING:	printShowMeetRecordings,
      Cmd.ARG_MEETTRANSCRIPT:	printShowMeetTranscripts,
      Cmd.ARG_MESSAGE:		printShowMessages,
      Cmd.ARG_NOTE:		printShowNotes,
      Cmd.ARG_OTHERCONTACT:	printShowUserPeopleOtherContacts,
      Cmd.ARG_OUTOFOFFICE:	printShowOutOfOffice,
      Cmd.ARG_PEOPLECONTACT:	printShowUserPeopleContacts,
      Cmd.ARG_PEOPLECONTACTGROUP:	printShowUserPeopleContactGroups,
      Cmd.ARG_PEOPLEPROFILE:	printShowUserPeopleProfiles,
      Cmd.ARG_POP:		printShowPop,
      Cmd.ARG_PROFILE:		showProfile,
      Cmd.ARG_SENDAS:		printShowSendAs,
      Cmd.ARG_SHAREDDRIVE:	printShowSharedDrives,
      Cmd.ARG_SHAREDDRIVEACLS:	printShowSharedDriveACLs,
      Cmd.ARG_SHAREDDRIVEINFO:	infoSharedDrive,
      Cmd.ARG_SHAREDDRIVETHEMES:	showSharedDriveThemes,
      Cmd.ARG_SHEET:		infoPrintShowSheets,
      Cmd.ARG_SHEETRANGE:	printShowSheetRanges,
      Cmd.ARG_SIGNATURE:	printShowSignature,
      Cmd.ARG_SITE:		deprecatedUserSites,
      Cmd.ARG_SITEACL:		deprecatedUserSites,
      Cmd.ARG_SMIME:		printShowSmimes,
      Cmd.ARG_TASK:		printShowTasks,
      Cmd.ARG_TASKLIST:		printShowTasklists,
      Cmd.ARG_THREAD:		printShowThreads,
      Cmd.ARG_TOKEN:		printShowTokens,
      Cmd.ARG_VAULTHOLD:	printShowUserVaultHolds,
      Cmd.ARG_VACATION:		printShowVacation,
      Cmd.ARG_WORKINGLOCATION:	printShowWorkingLocation,
      Cmd.ARG_YOUTUBECHANNEL:	printShowYouTubeChannel,
     }
    ),
  'spam':
    (Act.SPAM,
     {Cmd.ARG_MESSAGE:		processMessages,
      Cmd.ARG_THREAD:		processThreads,
     }
    ),
  'suspend':
    (Act.SUSPEND,
     {Cmd.ARG_USER:		suspendUnsuspendUsers,
     }
    ),
  'sync':
    (Act.SYNC,
     {Cmd.ARG_CHATMEMBER:	syncChatMembers,
      Cmd.ARG_GROUP:		syncUserWithGroups,
      Cmd.ARG_GUARDIAN:		syncGuardians,
      Cmd.ARG_LICENSE:		syncLicense,
      Cmd.ARG_SHAREDDRIVEACLS:	copySyncSharedDriveACLs,
     }
    ),
  'transfer':
    (Act.TRANSFER,
     {Cmd.ARG_DRIVE:		transferDrive,
      Cmd.ARG_CALENDAR:		transferCalendars,
      Cmd.ARG_OWNERSHIP:	transferOwnership,
     }
    ),
  'trash':
    (Act.TRASH,
     {Cmd.ARG_DRIVEFILE:	trashDriveFile,
      Cmd.ARG_MESSAGE:		processMessages,
      Cmd.ARG_THREAD:		processThreads,
     }
    ),
  'undelete':
    (Act.UNDELETE,
     {Cmd.ARG_USER:		undeleteUsers,
     }
    ),
  'unhide':
    (Act.UNHIDE,
     {Cmd.ARG_SHAREDDRIVE:	hideUnhideSharedDrive,
     }
    ),
  'unsuspend':
    (Act.UNSUSPEND,
     {Cmd.ARG_USER:		suspendUnsuspendUsers,
     }
    ),
  'untrash':
    (Act.UNTRASH,
     {Cmd.ARG_DRIVEFILE:	untrashDriveFile,
      Cmd.ARG_MESSAGE:		processMessages,
      Cmd.ARG_THREAD:		processThreads,
     }
    ),
  'update':
    (Act.UPDATE,
     {Cmd.ARG_BACKUPCODE:	updateBackupCodes,
      Cmd.ARG_CALATTENDEES:	updateCalendarAttendees,
      Cmd.ARG_CALENDAR:		updateCalendars,
      Cmd.ARG_CALENDARACL:	updateCalendarACLs,
      Cmd.ARG_CHATMEMBER:	deleteUpdateChatMember,
      Cmd.ARG_CHATMESSAGE:	updateChatMessage,
      Cmd.ARG_CHATSPACE:	updateChatSpace,
      Cmd.ARG_CSEIDENTITY:	createUpdateCSEIdentity,
      Cmd.ARG_LOOKERSTUDIOPERMISSION:	processLookerStudioPermissions,
      Cmd.ARG_DELEGATE:		updateDelegates,
      Cmd.ARG_DOCUMENT:		updateGoogleDocument,
      Cmd.ARG_DRIVEFILE:	updateDriveFile,
      Cmd.ARG_DRIVEFILEACL:	updateDriveFileACLs,
      Cmd.ARG_EVENT:		updateCalendarEvents,
      Cmd.ARG_FILEREVISION:	updateFileRevisions,
      Cmd.ARG_FORM:		updateForm,
      Cmd.ARG_GROUP:		updateUserGroups,
      Cmd.ARG_LABEL:		updateLabels,
      Cmd.ARG_LABELID:		updateLabelSettingsById,
      Cmd.ARG_LABELSETTINGS:	updateLabelSettings,
      Cmd.ARG_LICENSE:		updateLicense,
      Cmd.ARG_MEETSPACE:	updateMeetSpace,
      Cmd.ARG_OTHERCONTACT:	processUserPeopleOtherContacts,
      Cmd.ARG_PEOPLECONTACT:	updateUserPeopleContacts,
      Cmd.ARG_PEOPLECONTACTGROUP:	updateUserPeopleContactGroup,
      Cmd.ARG_PEOPLECONTACTPHOTO:	updateUserPeopleContactPhoto,
      Cmd.ARG_PHOTO:		updatePhoto,
      Cmd.ARG_SENDAS:		createUpdateSendAs,
      Cmd.ARG_SERVICEACCOUNT:	checkServiceAccount,
      Cmd.ARG_SHAREDDRIVE:	updateSharedDrive,
      Cmd.ARG_SHEET:		updateSheets,
      Cmd.ARG_SHEETRANGE:	updateSheetRanges,
      Cmd.ARG_SMIME:		updateSmime,
      Cmd.ARG_SITE:		deprecatedUserSites,
      Cmd.ARG_SITEACL:		deprecatedUserSites,
      Cmd.ARG_TASK:		processTasks,
      Cmd.ARG_TASKLIST:		processTasklists,
      Cmd.ARG_USER:		updateUsers,
     }
    ),
  'watch':
    (Act.WATCH,
     {Cmd.ARG_GMAIL:		watchGmail,
     }
    ),
  'wipe':
    (Act.WIPE,
     {Cmd.ARG_EVENT:		wipeCalendarEvents,
     }
    ),
  }

# User commands aliases
USER_COMMANDS_ALIASES = {
  'del':	'delete',
  'delegates':	'delegate',
  'deprov':	'deprovision',
  'imap4':	'imap',
  'pop3':	'pop',
  'sig':	'signature',
  'utf':	'unicode',
  'utf-8':	'unicode',
  'utf8':	'unicode',
  }

USER_COMMANDS_OBJ_ALIASES = {
  Cmd.ARG_3LO:			Cmd.ARG_TOKEN,
  Cmd.ARG_ALIASES:		Cmd.ARG_ALIAS,
  Cmd.ARG_APPLICATIONSPECIFICPASSWORDS:	Cmd.ARG_ASP,
  Cmd.ARG_ANALYTICACCOUNTS:	Cmd.ARG_ANALYTICACCOUNT,
  Cmd.ARG_ANALYTICACCOUNTSUMMARIES:	Cmd.ARG_ANALYTICACCOUNTSUMMARY,
  Cmd.ARG_ANALYTICDATASTREAMS:	Cmd.ARG_ANALYTICDATASTREAM,
  Cmd.ARG_ANALYTICPROPERTIES:	Cmd.ARG_ANALYTICPROPERTY,
  Cmd.ARG_ASPS:			Cmd.ARG_ASP,
  Cmd.ARG_BACKUPCODES:		Cmd.ARG_BACKUPCODE,
  Cmd.ARG_CALENDARS:		Cmd.ARG_CALENDAR,
  Cmd.ARG_CALENDARACLS:		Cmd.ARG_CALENDARACL,
  Cmd.ARG_CLASSIFICATIONLABEL:	Cmd.ARG_DRIVELABEL,
  Cmd.ARG_CLASSIFICATIONLABELS:	Cmd.ARG_DRIVELABEL,
  Cmd.ARG_CLASSIFICATIONLABELPERMISSION:	Cmd.ARG_DRIVELABELPERMISSION,
  Cmd.ARG_CLASSIFICATIONLABELPERMISSIONS:	Cmd.ARG_DRIVELABELPERMISSION,
  Cmd.ARG_CLASSROOMINVITATIONS:	Cmd.ARG_CLASSROOMINVITATION,
  Cmd.ARG_CHATEVENTS:		Cmd.ARG_CHATEVENT,
  Cmd.ARG_CHATMEMBERS:		Cmd.ARG_CHATMEMBER,
  Cmd.ARG_CHATMESSAGES:		Cmd.ARG_CHATMESSAGE,
  Cmd.ARG_CHATSPACES:		Cmd.ARG_CHATSPACE,
  Cmd.ARG_CIMEMBER:		Cmd.ARG_CIGROUPMEMBERS,
  Cmd.ARG_CIMEMBERS:		Cmd.ARG_CIGROUPMEMBERS,
  Cmd.ARG_CONTACT:		Cmd.ARG_PEOPLECONTACT,
  Cmd.ARG_CONTACTS:		Cmd.ARG_PEOPLECONTACT,
  Cmd.ARG_CONTACTDELEGATES:	Cmd.ARG_CONTACTDELEGATE,
  Cmd.ARG_CONTACTGROUP:		Cmd.ARG_PEOPLECONTACTGROUP,
  Cmd.ARG_CONTACTGROUPS:	Cmd.ARG_PEOPLECONTACTGROUP,
  Cmd.ARG_CONTACTPHOTO:		Cmd.ARG_PEOPLECONTACTPHOTO,
  Cmd.ARG_CONTACTPHOTOS:	Cmd.ARG_PEOPLECONTACTPHOTO,
  Cmd.ARG_CSEIDENTITIES:	Cmd.ARG_CSEIDENTITY,
  Cmd.ARG_CSEKEYPAIRS:		Cmd.ARG_CSEKEYPAIR,
  Cmd.ARG_COUNTS:		Cmd.ARG_COUNT,
  Cmd.ARG_DATASTUDIOASSET:	Cmd.ARG_LOOKERSTUDIOASSET,
  Cmd.ARG_DATASTUDIOPERMISSION:	Cmd.ARG_LOOKERSTUDIOPERMISSION,
  Cmd.ARG_DATASTUDIOASSETS:	Cmd.ARG_LOOKERSTUDIOASSET,
  Cmd.ARG_DATASTUDIOPERMISSIONS:	Cmd.ARG_LOOKERSTUDIOPERMISSION,
  Cmd.ARG_LOOKERSTUDIOASSETS:	Cmd.ARG_LOOKERSTUDIOASSET,
  Cmd.ARG_LOOKERSTUDIOPERMISSIONS:	Cmd.ARG_LOOKERSTUDIOPERMISSION,
  Cmd.ARG_DELEGATES:		Cmd.ARG_DELEGATE,
  Cmd.ARG_DOMAINCONTACT:	Cmd.ARG_PEOPLECONTACT,
  Cmd.ARG_DOMAINCONTACTS:	Cmd.ARG_PEOPLECONTACT,
  Cmd.ARG_DRIVEFILEACLS:	Cmd.ARG_DRIVEFILEACL,
  Cmd.ARG_DRIVEFILESHORTCUTS:	Cmd.ARG_DRIVEFILESHORTCUT,
  Cmd.ARG_DRIVELABELS:		Cmd.ARG_DRIVELABEL,
  Cmd.ARG_DRIVELABELPERMISSIONS:	Cmd.ARG_DRIVELABELPERMISSION,
  Cmd.ARG_DRIVELASTMODIFICATIONS:	Cmd.ARG_DRIVELASTMODIFICATION,
  Cmd.ARG_EVENTS:		Cmd.ARG_EVENT,
  Cmd.ARG_FILECOMMENTS:		Cmd.ARG_FILECOMMENT,
  Cmd.ARG_FILECOUNTS:		Cmd.ARG_FILECOUNT,
  Cmd.ARG_FILEDRIVELABELS:	Cmd.ARG_FILEDRIVELABEL,
  Cmd.ARG_FILEPATHS:		Cmd.ARG_FILEPATH,
  Cmd.ARG_FILEREVISIONS:	Cmd.ARG_FILEREVISION,
  Cmd.ARG_FILESHARECOUNTS:	Cmd.ARG_FILESHARECOUNT,
  Cmd.ARG_FILTERS:		Cmd.ARG_FILTER,
  Cmd.ARG_FOCUSTIMES:		Cmd.ARG_FOCUSTIME,
  Cmd.ARG_FORMS:		Cmd.ARG_FORM,
  Cmd.ARG_FORMRESPONSES:	Cmd.ARG_FORMRESPONSE,
  Cmd.ARG_FORWARDS:		Cmd.ARG_FORWARD,
  Cmd.ARG_FORWARDINGADDRESSES:	Cmd.ARG_FORWARDINGADDRESS,
  Cmd.ARG_GROUPS:		Cmd.ARG_GROUP,
  Cmd.ARG_GROUPLIST:		Cmd.ARG_GROUPSLIST,
  Cmd.ARG_GROUPSMEMBERS:	Cmd.ARG_GROUPMEMBERS,
  Cmd.ARG_GUARDIANINVITATIONS:	Cmd.ARG_GUARDIANINVITATION,
  Cmd.ARG_GUARDIANINVITE:	Cmd.ARG_GUARDIANINVITATION,
  Cmd.ARG_GUARDIANS:		Cmd.ARG_GUARDIAN,
  Cmd.ARG_HOLD:			Cmd.ARG_VAULTHOLD,
  Cmd.ARG_HOLDS:		Cmd.ARG_VAULTHOLD,
  Cmd.ARG_IMAP4:		Cmd.ARG_IMAP,
  Cmd.ARG_INVITEGUARDIAN:	Cmd.ARG_GUARDIANINVITATION,
  Cmd.ARG_LABELS:		Cmd.ARG_LABEL,
  Cmd.ARG_LICENCE:		Cmd.ARG_LICENSE,
  Cmd.ARG_LICENCES:		Cmd.ARG_LICENSE,
  Cmd.ARG_LICENSES:		Cmd.ARG_LICENSE,
  Cmd.ARG_MEETCONFERENCES:	Cmd.ARG_MEETCONFERENCE,
  Cmd.ARG_MEETPARTICIPANTS:	Cmd.ARG_MEETPARTICIPANT,
  Cmd.ARG_MEETRECORDINGS:	Cmd.ARG_MEETRECORDING,
  Cmd.ARG_MEETTRANSCRIPTS:	Cmd.ARG_MEETTRANSCRIPT,
  Cmd.ARG_MEETSPACES:		Cmd.ARG_MEETSPACE,
  Cmd.ARG_MEMBER:		Cmd.ARG_GROUPMEMBERS,
  Cmd.ARG_MEMBERS:		Cmd.ARG_GROUPMEMBERS,
  Cmd.ARG_MESSAGES:		Cmd.ARG_MESSAGE,
  Cmd.ARG_NOTES:		Cmd.ARG_NOTE,
  Cmd.ARG_NOTEACLS:		Cmd.ARG_NOTEACL,
  Cmd.ARG_NOTESACL:		Cmd.ARG_NOTEACL,
  Cmd.ARG_NOTESACLS:		Cmd.ARG_NOTEACL,
  Cmd.ARG_NOTEATTACHMENTS:	Cmd.ARG_NOTEATTACHMENT,
  Cmd.ARG_OAUTH:		Cmd.ARG_TOKEN,
  Cmd.ARG_OTHERCONTACTS:	Cmd.ARG_OTHERCONTACT,
  Cmd.ARG_OUTOFOFFICES:		Cmd.ARG_OUTOFOFFICE,
  Cmd.ARG_PEOPLECONTACTS:	Cmd.ARG_PEOPLECONTACT,
  Cmd.ARG_PEOPLECONTACTGROUPS:	Cmd.ARG_PEOPLECONTACTGROUP,
  Cmd.ARG_PEOPLECONTACTPHOTOS:	Cmd.ARG_PEOPLECONTACTPHOTO,
  Cmd.ARG_PEOPLE:		Cmd.ARG_PEOPLEPROFILE,
  Cmd.ARG_PEOPLEPROFILES:	Cmd.ARG_PEOPLEPROFILE,
  Cmd.ARG_PERMISSIONS:		Cmd.ARG_PERMISSION,
  Cmd.ARG_POP3:			Cmd.ARG_POP,
  Cmd.ARG_SECCALS:		Cmd.ARG_CALENDAR,
  Cmd.ARG_SHAREDDRIVES:		Cmd.ARG_SHAREDDRIVE,
  Cmd.ARG_SHEETS:		Cmd.ARG_SHEET,
  Cmd.ARG_SHEETRANGES:		Cmd.ARG_SHEETRANGE,
  Cmd.ARG_SIG:			Cmd.ARG_SIGNATURE,
  Cmd.ARG_SITES:		Cmd.ARG_SITE,
  Cmd.ARG_SITEACLS:		Cmd.ARG_SITEACL,
  Cmd.ARG_SMIMES:		Cmd.ARG_SMIME,
  Cmd.ARG_TASKS:		Cmd.ARG_TASK,
  Cmd.ARG_TASKLISTS:		Cmd.ARG_TASKLIST,
  Cmd.ARG_TEAMDRIVE:		Cmd.ARG_SHAREDDRIVE,
  Cmd.ARG_TEAMDRIVES:		Cmd.ARG_SHAREDDRIVE,
  Cmd.ARG_TEAMDRIVEACLS:	Cmd.ARG_SHAREDDRIVEACLS,
  Cmd.ARG_TEAMDRIVEINFO:	Cmd.ARG_SHAREDDRIVEINFO,
  Cmd.ARG_TEAMDRIVEORGANIZERS:	Cmd.ARG_SHAREDDRIVEORGANIZERS,
  Cmd.ARG_TEAMDRIVETHEMES:	Cmd.ARG_SHAREDDRIVETHEMES,
  Cmd.ARG_THREADS:		Cmd.ARG_THREAD,
  Cmd.ARG_TOKENS:		Cmd.ARG_TOKEN,
  Cmd.ARG_USERS:		Cmd.ARG_USER,
  Cmd.ARG_VAULTHOLDS:		Cmd.ARG_VAULTHOLD,
  Cmd.ARG_VERIFICATIONCODES:	Cmd.ARG_BACKUPCODE,
  Cmd.ARG_WORKINGLOCATIONS:	Cmd.ARG_WORKINGLOCATION,
  Cmd.ARG_YOUTUBECHANNELS:	Cmd.ARG_YOUTUBECHANNEL,
  }

def showAPICallsRetryData():
  if GC.Values.get(GC.SHOW_API_CALLS_RETRY_DATA) and GM.Globals[GM.API_CALLS_RETRY_DATA]:
    Ind.Reset()
    writeStderr(Msg.API_CALLS_RETRY_DATA)
    Ind.Increment()
    for k, v in sorted(iter(GM.Globals[GM.API_CALLS_RETRY_DATA].items())):
      m, s = divmod(int(v[1]), 60)
      h, m = divmod(m, 60)
      writeStderr(formatKeyValueList(Ind.Spaces(), [k, f'{v[0]}/{h}:{m:02d}:{s:02d}'], '\n'))
    Ind.Decrement()

def adjustRedirectedSTDFilesIfNotMultiprocessing():
  def adjustRedirectedSTDFile(stdtype):
    rdFd = GM.Globals[stdtype].get(GM.REDIRECT_FD)
    rdMultiFd = GM.Globals[stdtype].get(GM.REDIRECT_MULTI_FD)
    if rdFd and rdMultiFd and rdFd != rdMultiFd:
      try:
        rdFd.write(rdMultiFd.getvalue())
        rdMultiFd.close()
        GM.Globals[stdtype][GM.REDIRECT_MULTI_FD] = rdFd
        if (stdtype == GM.STDOUT) and (GM.Globals.get(GM.SAVED_STDOUT) is not None):
          sys.stdout = rdFd
      except IOError as e:
        systemErrorExit(FILE_ERROR_RC, e)

  adjustRedirectedSTDFile(GM.STDOUT)
  if GM.Globals[GM.STDERR].get(GM.REDIRECT_NAME) != 'stdout':
    adjustRedirectedSTDFile(GM.STDERR)
  else:
    GM.Globals[GM.STDERR][GM.REDIRECT_MULTI_FD] = GM.Globals[GM.STDOUT][GM.REDIRECT_MULTI_FD]

def closeSTDFilesIfNotMultiprocessing(closeSTD):
  def closeSTDFile(stdtype, stdfile):
    rdFd = GM.Globals[stdtype].get(GM.REDIRECT_FD)
    rdMultiFd = GM.Globals[stdtype].get(GM.REDIRECT_MULTI_FD)
    if rdFd and rdMultiFd and (rdFd == rdMultiFd) and (rdFd != stdfile):
      try:
        rdFd.flush()
        if closeSTD:
          rdFd.close()
      except BrokenPipeError:
        pass

  closeSTDFile(GM.STDOUT, sys.stdout)
  if GM.Globals[GM.STDERR].get(GM.REDIRECT_NAME) != 'stdout':
    closeSTDFile(GM.STDERR, sys.stderr)

# Process GAM command
def ProcessGAMCommand(args, processGamCfg=True, inLoop=False, closeSTD=True):
  setSysExitRC(0)
  Cmd.InitializeArguments(args)
  Ind.Reset()
  try:
    logCmd = Cmd.QuotedArgumentList(args)
    if checkArgumentPresent(Cmd.LOOP_CMD):
      if processGamCfg and (not SetGlobalVariables()):
        sys.exit(GM.Globals[GM.SYSEXITRC])
      doLoop(logCmd)
      sys.exit(GM.Globals[GM.SYSEXITRC])
    if processGamCfg and (not SetGlobalVariables()):
      sys.exit(GM.Globals[GM.SYSEXITRC])
    if checkArgumentPresent(Cmd.LOOP_CMD):
      doLoop(logCmd)
      sys.exit(GM.Globals[GM.SYSEXITRC])
    if not Cmd.ArgumentsRemaining():
      doUsage()
      sys.exit(GM.Globals[GM.SYSEXITRC])
    CL_command = getChoice(BATCH_CSV_COMMANDS, defaultChoice=None)
    if CL_command:
      Act.Set(BATCH_CSV_COMMANDS[CL_command][CMD_ACTION])
      if GM.Globals[GM.CMDLOG_LOGGER]:
        writeGAMCommandLog(GM.Globals, logCmd, '*')
      BATCH_CSV_COMMANDS[CL_command][CMD_FUNCTION]()
      sys.exit(GM.Globals[GM.SYSEXITRC])
    CL_command = getChoice(MAIN_COMMANDS, defaultChoice=None)
    if CL_command:
      adjustRedirectedSTDFilesIfNotMultiprocessing()
      Act.Set(MAIN_COMMANDS[CL_command][CMD_ACTION])
      MAIN_COMMANDS[CL_command][CMD_FUNCTION]()
      sys.exit(GM.Globals[GM.SYSEXITRC])
    CL_command = getChoice(MAIN_COMMANDS_WITH_OBJECTS, defaultChoice=None)
    if CL_command:
      adjustRedirectedSTDFilesIfNotMultiprocessing()
      Act.Set(MAIN_COMMANDS_WITH_OBJECTS[CL_command][CMD_ACTION])
      CL_objectName = getChoice(MAIN_COMMANDS_WITH_OBJECTS[CL_command][CMD_FUNCTION], choiceAliases=MAIN_COMMANDS_OBJ_ALIASES)
      MAIN_COMMANDS_WITH_OBJECTS[CL_command][CMD_FUNCTION][CL_objectName]()
      sys.exit(GM.Globals[GM.SYSEXITRC])
    CL_command = getChoice(COMMANDS_MAP, choiceAliases=COMMANDS_ALIASES, defaultChoice=None)
    if CL_command:
      adjustRedirectedSTDFilesIfNotMultiprocessing()
      COMMANDS_MAP[CL_command]()
      sys.exit(GM.Globals[GM.SYSEXITRC])
    GM.Globals[GM.ENTITY_CL_START] = Cmd.Location()
    entityType, entityList = getEntityToModify(crosAllowed=True, delayGet=True)
    if entityType == Cmd.ENTITY_USERS:
      CL_command = getChoice(list(USER_COMMANDS)+list(USER_COMMANDS_WITH_OBJECTS), choiceAliases=USER_COMMANDS_ALIASES)
      if (CL_command != 'list') and (GC.Values[GC.AUTO_BATCH_MIN] > 0):
        _, count, entityList = getEntityArgument(entityList)
        if count > GC.Values[GC.AUTO_BATCH_MIN]:
          if GM.Globals[GM.CMDLOG_LOGGER]:
            writeGAMCommandLog(GM.Globals, logCmd, '*')
          doAutoBatch(Cmd.ENTITY_USER, entityList, CL_command)
          sys.exit(GM.Globals[GM.SYSEXITRC])
      adjustRedirectedSTDFilesIfNotMultiprocessing()
      if CL_command in USER_COMMANDS:
        Act.Set(USER_COMMANDS[CL_command][CMD_ACTION])
        USER_COMMANDS[CL_command][CMD_FUNCTION](entityList)
      else:
        Act.Set(USER_COMMANDS_WITH_OBJECTS[CL_command][CMD_ACTION])
        CL_objectName = getChoice(USER_COMMANDS_WITH_OBJECTS[CL_command][CMD_FUNCTION], choiceAliases=USER_COMMANDS_OBJ_ALIASES,
                                  defaultChoice=[Cmd.ARG_USER, NO_DEFAULT][CL_command != 'print'])
        USER_COMMANDS_WITH_OBJECTS[CL_command][CMD_FUNCTION][CL_objectName](entityList)
    else:
      CL_command = getChoice(list(CROS_COMMANDS)+list(CROS_COMMANDS_WITH_OBJECTS))
      if (CL_command != 'list') and (GC.Values[GC.AUTO_BATCH_MIN] > 0):
        _, count, entityList = getEntityArgument(entityList)
        if count > GC.Values[GC.AUTO_BATCH_MIN]:
          if GM.Globals[GM.CMDLOG_LOGGER]:
            writeGAMCommandLog(GM.Globals, logCmd, '*')
          doAutoBatch(Cmd.ENTITY_CROS, entityList, CL_command)
          sys.exit(GM.Globals[GM.SYSEXITRC])
      adjustRedirectedSTDFilesIfNotMultiprocessing()
      if CL_command in CROS_COMMANDS:
        Act.Set(CROS_COMMANDS[CL_command][CMD_ACTION])
        CROS_COMMANDS[CL_command][CMD_FUNCTION](entityList)
      else:
        Act.Set(CROS_COMMANDS_WITH_OBJECTS[CL_command][CMD_ACTION])
        CL_objectName = getChoice(CROS_COMMANDS_WITH_OBJECTS[CL_command][CMD_FUNCTION], choiceAliases=CROS_COMMANDS_OBJ_ALIASES,
                                  defaultChoice=NO_DEFAULT)
        CROS_COMMANDS_WITH_OBJECTS[CL_command][CMD_FUNCTION][CL_objectName](entityList)
    sys.exit(GM.Globals[GM.SYSEXITRC])
  except KeyboardInterrupt:
    batchWriteStderr('\nControl-C\n')
    setSysExitRC(KEYBOARD_INTERRUPT_RC)
    showAPICallsRetryData()
    adjustRedirectedSTDFilesIfNotMultiprocessing()
  except OSError as e:
    printErrorMessage(SOCKET_ERROR_RC, str(e))
    showAPICallsRetryData()
    adjustRedirectedSTDFilesIfNotMultiprocessing()
  except MemoryError:
    printErrorMessage(MEMORY_ERROR_RC, Msg.GAM_OUT_OF_MEMORY)
    showAPICallsRetryData()
    adjustRedirectedSTDFilesIfNotMultiprocessing()
  except (GAPI.permissionDenied, GAPI.accessNotConfigured):
    SvcAcctAPIDisabledExit()
  except SystemExit as e:
    GM.Globals[GM.SYSEXITRC] = e.code
    if GM.Globals[GM.SYSEXITRC] != STDOUT_STDERR_ERROR_RC and not inLoop:
      showAPICallsRetryData()
      try:
        adjustRedirectedSTDFilesIfNotMultiprocessing()
      except SystemExit:
        pass
  except Exception:
    print_exc(file=sys.stderr)
    setSysExitRC(UNKNOWN_ERROR_RC)
    showAPICallsRetryData()
    adjustRedirectedSTDFilesIfNotMultiprocessing()
  if processGamCfg:
    if not inLoop:
      if GM.Globals.get(GM.SAVED_STDOUT) is not None:
        sys.stdout = GM.Globals[GM.SAVED_STDOUT]
      closeSTDFilesIfNotMultiprocessing(closeSTD)
  if GM.Globals[GM.PID] == 0 and GM.Globals[GM.CMDLOG_LOGGER]:
    writeGAMCommandLog(GM.Globals, logCmd, GM.Globals[GM.SYSEXITRC])
    if not inLoop:
      closeGAMCommandLog(GM.Globals)
  return GM.Globals[GM.SYSEXITRC]

# Process GAM command
def CallGAMCommand(args, processGamCfg=True, inLoop=False, closeSTD=False):
  return ProcessGAMCommand(args, processGamCfg=processGamCfg, inLoop=inLoop, closeSTD=closeSTD)
