#!/usr/bin/env python
# Copyright (c) TripleBlind Holdings, Inc. Confidential and Proprietary. All rights reserved.


def main():
    global args

    import argparse
    import json
    import os
    import os.path
    import pprint
    import re
    import sys
    from datetime import datetime
    from pathlib import Path
    from typing import List, Tuple
    from uuid import UUID

    from colorama import Fore, Style
    from dateutil.parser import parse
    from tripleblind.asset import TabularDataset
    from tripleblind.exceptions import (
        TripleblindAPIError,
        TripleblindAssetError,
        TripleblindPermissionError,
    )
    from tripleblind.session import get_default_session

    import tripleblind as tb
    from tripleblind import config

    app_path = os.path.abspath(sys.argv[0])
    app = "tb"
    subcmd_alias = {
        "assets": "asset",
        "ps": "process",
        "req": "requests",
        "requ": "requests",
        "reque": "requests",
        "reques": "requests",
        "request": "requests",
        "teams": "team",
    }

    sdk_version = tb.__version__

    # IMPORTANT: This multiline text must be left justified for later formatting
    # The lines starting with ==== are submode sections, the DEFAULT section
    # is shown by default.
    helptext = f"""TripleBlind utility. SDK version {sdk_version}
==== DEFAULT
Command line interface tool for TripleBlind systems.

Usage:
{app} [GROUP] | ACTION [parameters]

where GROUP is one of:
    asset[s]        Actions related to data or algorithmic assets (default)
    process | ps    Actions related to processes
    req[uests]      Actions related to access requests
    team[s]         Actions related to teams
    validate        Validate model compatibility
    version         Show version and configuration information
    install         Show instructions to setup `tb` as a command alias
    admin           Administrative actions for organization owners

For further help on GROUP parameters, use:
{app} GROUP --help

--token=TOKEN  override the default tripleblind.yaml token
--help         display this help message or help with a subcmd

Examples:
{app} asset list --mine --details --max=100
{app} asset list /^test-.*-0[012]/
{app} list d6d7711c-d6df-495b-bcf0-5d75a701f667 --details
{app} asset create tabular.csv --name 'my data' --desc 'csv data' --public
{app} asset delete 'my data'
{app} asset remove alg 'my network'
{app} asset unmask 'csv dataset' colname1 'col name2'
{app} asset set data /^cats/ name dogs
{app} set dogs discoverable true
{app} set dogs desc 'New description'
{app} asset retrieve dogs saveas.csv
{app} list --owned --since=8-1-2020 --before=1-1-2021

{app} process list 26b09421-bcf0-485d-9a48-859e3d14751c
{app} process list 'Tabular' --history
{app} ps list 'Tabular' --compact
{app} ps list 'Tabular' --since="3 days ago" --max=100
{app} process cancel 'Tabular'
{app} process connect 'Continuous Inference'

{app} requests
{app} requests list --details
{app} req list "Tabular"
{app} requests reply

{app} team activate Cardiology
{app} team list

{app} validate ~/my_models/model.onnx
{app} validate ~/my_models/

{app} version

==== ASSET

~*{app} asset*~      Create, find, and manage assets


Usage:
{app} [asset] ~_create_~ [TYPE] FILE
{app} [asset] ~_delete_~ [TYPE] FILE
{app} [asset] ~_retrieve_~ [TYPE] [SEARCH] [SAVEAS]
{app} [asset] ~_list_~ [TYPE] [SEARCH]
{app} [asset] ~_remove_~ [TYPE] [SEARCH]
{app} [asset] ~_set_~ [TYPE] [SEARCH] name|desc|filename|discoverable PARAM2
{app} [asset] ~_mask_~ [TYPE] [SEARCH] COLNAME1 COLNAME2 ...
{app} [asset] ~_unmask_~ [TYPE] [SEARCH] COLNAME1 COLNAME2 ...

{app} [asset] ~_preproc_~ create [TYPE] [SEARCH] [PREPROC.PY] [SAMPLE.CSV]
{app} [asset] ~_preproc_~ test [TYPE] [SEARCH|TEST.CSV] [PREPROC.PY]

where:
    TYPE  (optional) 'alg' or 'data'
    SEARCH  a search substring or a /regex/, or an asset ID
            use --exact with a string to return only full matches (not substring)
            use --exclude with a regex to return what ~*doesn't*~ match

~_create_~ position a file on your Access Point and index it on the Router
    FILE is the filename to position on your access point
    use --name NAME to set the asset name (otherwise prompted)
    use --desc DESC to set the asset description (otherwise prompted)
    use --public to make the asset discoverable
    use --overwrite to clobber any existing asset
    use --auto_rename to automatically correct invalid field names
~_delete_~ permanently destroy an asset (same as 'remove --delete')
~_retrieve_~ pull down a local copy of owned data
    SAVEAS is an optional filename, default used if not given
    use --overwrite to clobber any existing file
~_list_~ display asset information from index
~_remove_~ remove the asset from the index, implies --owned
    use --delete to destroy Access Point files, otherwise just de-listed
    use --yes to skip confirmation
~_set_~ change property in PARAM1 to value in PARAM2
    PARAM1='name' sets the asset name
    PARAM1='desc' sets the description (can use markdown)
    PARAM1='filename' sets the default retrieval filename
    PARAM1='discoverable' sets the visibility to others
~_mask_~ hide the values of the given tabular dataset field names
~_unmask_~ allow the given tabular dataset field names to be seen in output
~_preproc_~ Tools for creation and testing of data preprocessors
    PREPROC.PY=name for preprocessor file.  Defaults to "preproc.py"
    SAMPLE.CSV=name for retrieved mock data file.  Defaults to "preproc.csv"
    TEST.CSV=name for data file for test.  Defaults to "preproc.csv"

Flags:
--since=X      include only assets created after X
--before=X     include only assets created before X
--order=X      controls display order, one of: name, date, owner
--max=X        limits returned results, default is 500, 0 for all
--mine         limits any SEARCH to personally created assets
--owned        limits any SEARCH to team owned assets
--yes          with any command skips confirmations
--all          with any command applies the action to all SEARCH matches, otherwise you will be prompted to choose the item(s) on which to run the action
--compact      shows less information
--details      shows more information
--quiet        minimizes output
--token=TOKEN  override the default tripleblind.yaml token
--help         display this help message or help with a subcmd

Examples:
{app} asset list --mine --details
{app} asset list /^test-.*-0[012]/
{app} list d6d7711c-d6df-495b-bcf0-5d75a701f667 --details
{app} asset create tabular.csv --name 'my data' --desc 'csv data' --public
{app} asset delete 'my data'
{app} asset remove alg 'my network'
{app} asset set data /^cats/ name dogs
{app} set dogs discoverable true
{app} set dogs desc 'New description'
{app} asset retrieve dogs saveas.csv
{app} list --owned --since=8-1-2020 --before=1-1-2021

# Preprocessor creation utilities
{app} preproc create "Bank customers" # creates preproc.py preproc.csv
{app} preproc create "Bank customers"
{app} preproc create "Bank customers" bank_trans.py bank_trans.csv # name stub
{app} preproc create 0010a7b5-e073-45f3-b0ba-627e074826ac
# Preprocessor testing
{app} preproc test 0010a7b5-e073-45f3-b0ba-627e074826ac preproc.py
{app} preproc test testdata.csv preproc.py


==== PROCESS

~*{app} process*~    Find and view your processes

Usage:
{app} process ~_list_~ [SEARCH] [STATUS]
{app} process ~_retrieve_~ [TYPE] [SEARCH] [SAVEAS]
{app} process ~_cancel_~ [SEARCH]
{app} process ~_connect_~ [SEARCH] [--raw]

where:
    SEARCH  a search substring or a /regex/, or a process (job) ID

~_list_~ display process information from index
    --active          search for actively running processes
    --waiting         search for processes waiting for permission approval
    --history         search for completed and failed processes
    --since=MM-DD-YY  search for jobs run since specified date; defaults to yesterday
~_retrieve_~ retrieve a local copy of the process result asset
    SAVEAS            optional filename, asset default name if not given
    --overwrite       clobber any existing file
~_cancel_~ cancel a process in the Waiting stage
~_connect_~ connect to the output stream of a running process
    --raw             dump the output messages as raw JSON

Note: The alias 'ps' can be used for 'process', e.g. "{app} ps list"

Flags:
--yes          with any command skips confirmations
--all          with any command applies the action to all SEARCH matches, otherwise you will be prompted to choose the item(s) on which to run the action
--compact      shows less information
--details      shows more information
--quiet        minimizes output
--max=X        limits returned results, default is 20, 0 for all
--token=TOKEN  override the default tripleblind.yaml token
--help         display this help message or help with a subcmd

Examples:
{app} process list 26b09421-bcf0-485d-9a48-859e3d14751c
{app} process list 'Tabular' --history
{app} ps list 'Tabular' --compact
{app} ps list 'Tabular' --since="3 days ago" --max=100
{app} ps cancel 'Tabular'

==== REQUESTS

~*{app} req[uests]*~    Work with asset access requests

Usage:
{app} req[uests] [~_reply_~] [ASSET|all [--exclude]]
{app} req[uests] ~_list_~

where:
    ASSET   a search substring or a /regex/, or the asset ID of the asset
            of which you are approving the usage.  Use --exclude for any
            ASSET that ~*doesn't*~ match the given pattern.

~_reply_~ display and respond request information from index (default action)
~_list_~  display access request information from index

Flags:
--details      shows more information
--token=TOKEN  override the default tripleblind.yaml token
--help         display this help message or help with a subcmd

Examples:
    {app} requests
    {app} requests list --details
    {app} req list "Tabular"
    {app} requests reply

==== TEAM

~*{app} team[s]*~    Manage your teams

Usage:
{app} team[s] ~_activate_~ TEAM|"default"
{app} team[s] ~_add_~ USER [TEAM] [PERMISSIONS]
{app} team[s] ~_info_~ [TEAM]
{app} team[s] ~_list_~ [SEARCH]
{app} team[s] ~_members_~ [TEAM] [SEARCH]
{app} team[s] ~_remove_~ USER [TEAM]
{app} team[s] ~_set-owner_~ USER [TEAM]

where:
    TEAM         the name or integer ID of a team. Default is the active team.
    SEARCH       a team name search substring
    USER         the email address or user ID of an individual
    PERMISSIONS  permissions to grant the user.  Either "all" or one or more of:
                    update_asset delete_asset manage_agreement
                    create_job manage_job
                    publish_algorithm grant_permission_algorithm download_algorithm download_algorithm_output
                    publish_dataset grant_permission_dataset download_dataset download_dataset_output
                    invite_user manage_user remove_user
                 If not specified, the user will be added to the team with no permissions initially.

~_activate_~   make the given team the context for future actions.  Use "default" to reset.
~_add_~        add a user to the team, optionally specifying permissions (if you have right to do so)
               NOTE: If the user isn't part of the organization already, they will receive an invitation email to join.
~_info_~       display details about a team.  Default is the active team.
~_list_~       display teams you are a part of
~_members_~    list members of the given team or all user teams if none specified
~_remove_~     remove a user from the team
~_set-owner_~  change ownership of the team
               NOTE: Only the team owner or an organization owner can change ownership.

Flags:
--compact      shows less information
--token=TOKEN  override the default tripleblind.yaml token
--help         display this help message or help with a subcmd

Examples:
{app} team info --compact
{app} team info Cardiology
{app} team list
{app} team list Card
{app} team activate Cardiology
{app} team activate 3
{app} team add miky@tripleblind.com Cardiology create_job download_dataset_output
{app} team members

==== VALIDATE

~*{app} validate*~    Validate model compatibility with SMPC Inference

Usage:
{app} validate ~_folder_~
{app} validate ~_modelfile_~

where:
    folder     a directory containing model files
    modelfile  an .onnx, .h5, or .pth model file

==== ADMIN

~*{app} admin*~    Administer your organization (only for organization owners)

Usage:
~*{app} admin user list*~ [SEARCH]
~*{app} admin team list*~ [SEARCH]
~*{app} admin team create*~ NAME [OWNER]
~*{app} admin owner list*~
~*{app} admin owner add*~ OWNER
~*{app} admin owner remove*~ OWNER

where:
    NAME    the name of a team or the team's ID
    OWNER   a user email address or user ID
    SEARCH  a search substring

~*user list*~      list all users in the organization (use --details for more information)
~*team list*~      list all teams in the organization
~*team create*~    create a new team, optionally specifying the owner (defaults to you)
~*owner list*~     list all owners of the organization
~*owner add*~      make an existing user an owner of the organization
~*owner remove*~   remove an existing organization owner

Examples:
{app} admin owner list
{app} admin owner add bob@tripleblind.com
{app} admin owner remove bob@tripleblind.com
{app} admin team create "Cardiology Dept"
{app} admin team list "Card"  # all teams with "Card" in the name
{app} admin user list
{app} admin user list "steve" --details


==== INSTALL

Easy access to this script can be set up in various ways, depending on your operating system.
Here are some examples:

    Linux/bash - in your .bashrc add:
        ~*alias {app}='python "{app_path}"'*~
    Mac/bash - in your .bash_profile add:
        ~*alias {app}='python "{app_path}"'*~
    Mac/zsh - in your .zshrc add:
        ~*alias {app}='python "{app_path}"'*~
    Windows - in your path add a "{app}.bat" file that holds:
        ~*@python {app_path} %**~

Note: Typically you will need to restart your shell or command line window for these changes to take effect.
"""

    def install_completion_helper(actions, flags):
        if (
            not Path()
            .home()
            .joinpath(".local/share/bash-completion/completions")
            .exists()
        ):
            return  # no completion directory (e.g. not Linux), skip

        # Create the bash completion script
        script = """#!/usr/bin/env bash

                    # Autocompletion information for this script.  Setup via:
                    CMD_NAME=$(basename "${BASH_SOURCE}")  # just the filename, no path

                    function _repo() {
                        local cur prev opts
                        COMPREPLY=()
                        cur="${COMP_WORDS[COMP_CWORD]}"
                    """
        script += 'opts="' + " ".join([a for a in actions.keys()]) + '"\n'
        script += 'global_flags="--help ' + " ".join(flags) + '"\n'

        script += """   if [[ ${cur} == --base= ]]; then
                            # Can complete with a branch name
                            base_opts="$( git branch )"
                            COMPREPLY=( $(compgen -W "${base_opts}" -- ${cur}) )
                            return 0
                        fi

                        if [[ ${cur} == -* ]]; then
                            # user is typing a flag that starts with a dash...
                            COMPREPLY=( $(compgen -W "${global_flags}" -- ${cur}) )
                            return 0
                        fi

                        mode="${COMP_WORDS[1]}"
                        case "${mode}" in
                        """

        # actions can have a list, a dict, or None as a value, e.g.:
        # {
        #     "asset": ["create", "list", "retrieve"],
        #     "create": None,
        #     "admin": [
        #         {"team": ["create", "list", "set-owner"]},
        #         {"owner": ["add", "remove", "list"]},
        #         {"user": ["list"]},
        #     ],
        # }
        for action in actions.keys():
            opts = actions[action]
            script += f"""{action}|{action}s)"""
            if opts is None:
                # Only completion for "list" is global flags
                script += """
                                COMPREPLY=( $(compgen -W "${global_flags}" -- ${cur}) )
                                return 0
                                ;;
                          """
            elif type(opts) is list and not type(opts[0]) is dict:
                if None in opts:
                    opts.remove(None)  # special case in the def that we don't need
                script += f"""
                                {action}_opts="{' '.join(opts)}"
                                COMPREPLY=( $(compgen -W "${{{action}_opts}}" -- ${{cur}}) )
                                return 0
                                ;;
                          """
            elif type(opts) is list and type(opts[0]) is dict:
                script += f"""
                            if [[ $COMP_CWORD == 2 ]] ; then
                                # Second word can complete with a subgroup name
                                {action}_opts="{' '.join([list(o.keys())[0] for o in opts])}"
                                COMPREPLY=( $(compgen -W "${{{action}_opts}}" -- ${{cur}}) )
                                return 0
                            elif [[ $COMP_CWORD == 3 ]] ; then
                          """

                for o in opts:
                    # deal with subgroup actions
                    script += f"""
                                if [[ "${{COMP_WORDS[2]}}" == "{list(o.keys())[0]}" ]] ; then
                                    admin_3_opts="{' '.join(list(o.values())[0])}"
                                    COMPREPLY=( $(compgen -W "${{admin_3_opts}}" -- ${{cur}}) )
                                    return 0
                                fi
                                """

                script += """
                            fi

                            # No further opts
                            COMPREPLY=( $(compgen -W "" -- ${{cur}}) )
                            return 0
                            ;;
                          """

            else:
                script += """
                          ;;
                          """

        script += """
                            *)
                                ;;
                        esac

                        COMPREPLY=($(compgen -W "${opts}" -- ${cur}))
                        return 0
                    }

                    complete -F _repo $CMD_NAME
                    return"""

        # Save the script in the completion directory, naming it the same as this script
        script_name = (
            Path()
            .home()
            .joinpath(".local/share/bash-completion/completions")
            .joinpath(app)
        )
        with open(script_name, "w") as f:
            f.write(script)

        # Give the script execute permissions
        import stat

        perms = os.stat(script_name).st_mode
        if perms & stat.S_IEXEC != stat.S_IEXEC:
            os.chmod(script_name, perms | stat.S_IEXEC)

    def show_help(section=None):
        UNDERLINE = "\033[1;4m"
        BOLD = "\033[1m"
        RESET = "\033[0m"

        output_help(
            helptext.replace("~_", UNDERLINE)
            .replace("~*", BOLD)
            .replace("_~", RESET)
            .replace("*~", RESET),
            sys.argv[1:] if not section else section,
        )

    def pctl(msg: str, end=True):
        # Print a control message in different color
        print(f"{Fore.GREEN}{msg}{Style.RESET_ALL}", end=None if end else "")

    def perror(msg: str = None, end=True, exit=None):
        # Print an error message in different color.  Optionally raise exception and exit.
        print(f"{Fore.RED}{msg or ''}{Style.RESET_ALL}", end=None if end else "")
        if exit is not None:
            if isinstance(exit, Exception):
                raise exit
            elif exit is True:
                # Exit with a failure code (True would be 0, a success)
                raise SystemExit(1)
            else:
                raise SystemExit(exit)

    def output_help(content, section):
        # Overly-complex way of printing that accounts for screen width and wraps
        # in such a way that it honors initial and sub-indents.
        subcmd = "DEFAULT"
        if type(section) is str:
            subcmd = section
        elif section:
            subcmd = section[0].upper()
        if subcmd in [
            "SET",
            "PREPROC",
            "LIST",
            "CREATE",
            "DELETE",
            "REMOVE",
        ]:
            # The "asset" commands don't require the explicit subcommand
            subcmd = "ASSET"
        elif subcmd.lower() in subcmd_alias:
            # expand subcommand aliases to full name
            subcmd = subcmd_alias[subcmd.lower()].upper()
        if subcmd not in [
            "ASSET",
            "PROCESS",
            "REQUESTS",
            "DEFAULT",
            "TEAM",
            "VALIDATE",  # impossible to reach, but here for completeness
            "VERSION",
            "INSTALL",
            "ADMIN",
            "--HELP",
        ]:
            print("Unknown command:", subcmd)
            subcmd = "DEFAULT"
        if subcmd == "--HELP":
            subcmd = "DEFAULT"

        max = tb.util.get_terminal_width() - 1

        in_subcmd = None
        for line in content.split("\n"):
            if line.startswith("="):
                in_subcmd = line.strip("=").strip()
                continue

            if not in_subcmd or subcmd == in_subcmd:
                bare = line.strip()
                i1 = len(line) - len(bare)
                i2 = 0
                while True:
                    # Look for a gap (2 or more spaces) within the output line. Ex:
                    #    --order=X      controls display order, one of: name, date, owner
                    #    ^-- i1         ^-- i2
                    space = bare[i2:].find(" ")
                    if space >= 0 and bare[space + i2 + 1] == " ":
                        i2 = space + i2 + 1
                    else:
                        if space == -1 or i2 >= len(bare):
                            # no gap found
                            i2 = 0
                            break

                        if i2 > 1 and bare[i2] == " " and bare[i2 - 1] == " ":
                            # gap found
                            i2 += 1
                            break

                        i2 += 1  # keep looking for a > 1-space gap

                i2 += i1
                bare += " "
                while bare:
                    indent = " " * i1
                    if len(bare) + i1 > max:
                        # find a natural breaking point for the line (e.g. a space)
                        last_char = min(max - i1, len(bare) - 1)
                        while last_char > 0 and bare[last_char] != " ":
                            last_char -= 1
                        if last_char <= 0:
                            last_char = max - i1
                        else:
                            last_char += 1
                    else:
                        last_char = max - i1

                    print(indent + bare[:(last_char)])
                    bare = bare[(last_char):]
                    i1 = i2

    ##

    def install_alias():
        show_help("INSTALL")
        install_completion_helper(legal_actions, simple_flags)

    ##########################################################################
    # Preprocessor assist

    def preproc_create(asset: TabularDataset, stub_filename, data_filename):
        import pandas as pd
        from tripleblind.table_asset import TableAsset

        # Check if files already exist, get confirmation
        if "--yes" not in flags:
            data_exists = Path(data_filename).exists()
            stub_exists = Path(stub_filename).exists()
            # Slightly excessive, but just felt better
            p = None
            if data_exists and stub_exists:
                p = f"The files '{stub_filename}' and '{data_filename}' already exist, replace?"
            elif stub_exists:
                p = f"The file '{stub_filename}' already exists, replace?"
            elif data_exists:
                p = f"The file '{data_filename}' already exists, replace?"
            if p:
                confirm_or_exit(p, show_skip_all=False)  # Exits if they cancel

        # Create stub file using info
        pctl("Creating...")

        # Retrieve mock data for the asset
        try:
            TableAsset.cast(asset).get_mock_data().to_csv(data_filename, index=False)
            mock = pd.read_csv(data_filename, header=0)
        except:
            mock = pd.DataFrame()  # Load failed, but allow to continue anyway

        stub = f"""import pandas as pd  # required
import numpy as np  # delete if not needed


# This transform() function will be called to preprocess a dataset when you
# reference this file from a TabularPreprocessorBuilder.python_transform(). A
# dataframe containing the raw data is provided, and the function must return a
# dataframe that is passed to the operation.  Within the function, you can do
# any operation using pure Python, Panda's or NumPy tools in order to build the
# output dataframe.
#
def transform(df: pd.DataFrame) -> pd.DataFrame:
    # You can either modify the given dataframe, or construct a totally
    # independent dataframe.  All in-memory work will be discarded, only the
    # output dataframe is used.

    # Available fields in asset '{a.name}' ({a.uuid}):"""

        # Enumerate available fields
        for field in mock.columns:
            stub += f"    # df['{field}']\n"

        stub += f"""

    # TODO: Insert your preprocessing logic here


    # Return a dataframe.  Can be the original df, a subset of the df, or even
    # a completely new dataframe.
    out_df = df
    # out_df = df[{[field for field in mock.columns[:2]]}]
    # out_df = pd.DataFrame([1, 2, 3], columns=["new_col"])
    return out_df
"""
        tb.util.save_to(stub_filename, stub)

        # Print "what's next"
        tb.util.wrap(
            f"""A stub preprocessor has been created in {stub_filename} and representative sample data in {data_filename}.

Next steps:
  1) Edit this stub to implement your preprocessor.
  2) Test using:  tb preproc test "{stub_filename}" "{data_filename}"
  3) Reference from your main script.
""",
            True,
            3,
        )

    def preproc_test(asset, stub_filename, data_filename):
        from collections import namedtuple

        Import = namedtuple("Import", ["module", "name", "alias"])

        def get_imports(path):
            import ast

            with open(path) as fh:
                root = ast.parse(fh.read(), path)

            for node in ast.iter_child_nodes(root):
                if isinstance(node, ast.Import):
                    module = []
                elif isinstance(node, ast.ImportFrom):
                    module = node.module.split(".")
                else:
                    continue

                for n in node.names:
                    yield Import(module, n.name, n.asname)

        import pandas as pd

        stub_filename = Path(stub_filename).absolute()
        data_filename = Path(data_filename).absolute()

        if not data_filename.exists():
            raise SystemExit(f"Unable to find test data '{data_filename}'")
        if not stub_filename.exists():
            raise SystemExit(f"Unable to find script '{stub_filename}'")

        # Load dataframe
        try:
            df = pd.read_csv(data_filename, header=0)
        except Exception as e:
            perror(f"Failed to load data from: {data_filename}\n", exit=e)

        # Locate stub module
        import sys
        from importlib import import_module

        sys.path.append(str(stub_filename.parent))
        imp = import_module(stub_filename.stem)  # str(stub_filename))
        fn = getattr(imp, "transform")

        # Scan imports -- allow pandas, numpy and standard Python libs
        legal_libs = ["pandas", "numpy"]
        # List of std libs from: https://docs.python.org/3/library/index.html
        # Removed obvious ones that will fail or are useless, e.g. requests,
        # curses, tkinter, etc.
        # TODO: Use sys.stdlib.module_names once we use Python 3.10+
        legal_libs.append(
            "2to3,__future__,__main__,_thread,abc,aifc,argparse,array,ast,asynchat,asyncio,asyncore,atexit,audioop,base64,bdb,binascii,bisect,builtins,bz2,calendar,cgi,cgitb,chunk,cmath,cmd,code,codecs,codeop,collections,collections.abc,colorsys,compileall,concurrent.futures,configparser,contextlib,contextvars,copy,copyreg,crypt,csv,ctypes,dataclasses,datetime,dbm,decimal,difflib,dis,distutils,doctest,email,ensurepip,enum,errno,faulthandler,fcntl,filecmp,fileinput,fnmatch,fractions,ftplib,functools,gc,getopt,getpass,gettext,glob,graphlib,grp,gzip,hashlib,heapq,hmac,html,html.entities,html.parser,http,http.client,http.cookiejar,http.cookies,http.server,imaplib,imghdr,imp,inspect,io,ipaddress,itertools,json,keyword,linecache,locale,logging,logging.config,logging.handlers,lzma,mailbox,mailcap,marshal,math,mimetypes,mmap,msilib,msvcrt,multiprocessing,multiprocessing.shared_memory,netrc,nis,nntplib,numbers,operator,optparse,os,os.path,ossaudiodev,pathlib,pdb,pickle,pickletools,pipes,platform,plistlib,poplib,posix,pprint,pty,pwd,py_compile,pyclbr,pydoc,queue,quopri,random,re,readline,reprlib,resource,rlcompleter,runpy,sched,secrets,select,selectors,shelve,shlex,shutil,signal,site,smtpd,smtplib,sndhdr,socket,socketserver,spwd,sqlite3,ssl,stat,statistics,stringprep,struct,subprocess,sunau,symtable,sys,sysconfig,syslog,tabnanny,tarfile,telnetlib,tempfile,termios,test,test.support,test.support.bytecode_helper,test.support.import_helper,test.support.os_helper,test.support.script_helper,test.support.socket_helper,test.support.threading_helper,test.support.warnings_helper,textwrap,threading,time,timeit,token,tokenize,tomllib,trace,traceback,tracemalloc,tsring,tty,turtle,types,typing,unicodedata,unittest,unittest.mock,unittest.mock,urllib,urllib.error,urllib.parse,urllib.request,urllib.response,urllib.robotparser,uu,uuid,venv,warnings,wave,weakref,webbrowser,winreg,wsgiref,xdrlib,xml.dom,xml.dom.minidom,xml.dom.pulldom,xml.etree.ElementTree,xml.parsers.expat,xml.sax,xml.sax.handler,xml.sax.saxutils,xml.sax.xmlreader,xmlrpc,xmlrpc.client,xmlrpc.server,zipapp,zipfile,zlib,zoneinfo".split(
                ","
            )
        )
        for x in get_imports(str(stub_filename)):
            if x.name not in legal_libs:
                perror(
                    f"ERROR: Detected import of module '{x.name}'.\n"
                    + "Only 'pandas', 'numpy' and most standard libraries are allowed in preprocessors.",
                    exit=True,
                )

        pctl(
            f"Executing transform() from '{stub_filename}' against '{data_filename}'...\n"
        )

        # Run the submodule
        try:
            output = fn(df)
        except Exception as e:
            perror("ERROR: Failure while invoking the transform(df) function.")
            perror("See the exception below for more information\n", exit=e)

        print("Transform complete.  Output dataframe:")
        print("======================================")
        print(f"{output}")

        save_as = str(data_filename).replace(".csv", ".preprocessed.csv")
        output.to_csv(save_as, index=False)
        print(f"\nPreprocessed data saved to '{save_as}' for examination.")

    ##########################################################################

    def position_on_access_point(filename: str, name, desc, is_discoverable):
        try:
            with open(filename, "rb") as f:
                if filename.endswith(".csv"):
                    from tripleblind.asset import CSVDataset

                    new_asset = CSVDataset.create(
                        filename,
                        name=name,
                        desc=desc,
                        is_discoverable=is_discoverable,
                        allow_overwrite="--overwrite" in flags,
                        auto_rename_columns="--auto_rename" in flags,
                    )
                else:
                    new_asset = tb.Asset.position(
                        f,
                        name=name,
                        desc=desc,
                        is_discoverable=is_discoverable,
                        allow_overwrite="--overwrite" in flags,
                    )
        except Exception as e:
            perror("ERROR: Positioning failed")
            perror(e, exit=True)

        print(f"\nNew asset created, asset id: {new_asset.uuid}")

    def handle_create():
        parser = argparse.ArgumentParser()
        if sys.argv[1] == "asset":
            parser.add_argument("asset")  # just to gobble up the arg, ignored
        parser.add_argument("create")
        parser.add_argument("filename", help="Path of file to place on Access Point")
        parser.add_argument("--name", "-n", help="Name of the asset")
        parser.add_argument("--desc", "-d", help="Long description of the file")
        parser.add_argument(
            "--public", help="Discoverable by others", action="store_true"
        )
        parser.add_argument(
            "--overwrite",
            help="Allow overwrite of existing datasets",
            action="store_true",
        )
        parser.add_argument(
            "--auto_rename",
            help="Allow automatic field rename for invalid field names",
            action="store_true",
        )
        # These global flags are not used here, but prevent error messages
        parser.add_argument("--token", "-t", help="Global flag")
        parser.add_argument("--org1", "-1", help="Global flag")
        parser.add_argument("--org2", "-2", help="Global flag")
        parser.add_argument("--org3", "-3", help="Global flag")
        parser.add_argument("--show_traffic", action="store_true", help="DEBUGGING")
        args = parser.parse_args()

        if not os.path.isfile(args.filename):
            raise SystemExit("ERROR: Unable to find file: ", args.filename)

        if args.name:
            name = args.name
        else:
            name = input("Enter a name for this asset on the Router (short name): ")
        if not name:
            raise SystemExit("ERROR: A name is required.")

        if args.desc:
            desc = args.desc
        else:
            print(
                "Describe this asset (longer description, hit enter on a blank line to finish):"
            )
            lines = []
            while True:
                line = input()
                if not line:
                    break
                lines.append(line)
            desc = "\\n".join(lines)

        if args.public:
            is_discoverable = bool(args.public)
        else:
            yn = input(
                "Would you like to allow others on the Router to discover this data? [y/N] "
            )
            is_discoverable = (yn and yn[0] in ["Y", "y"]) == True

        print()
        print("Creating asset")
        print("         File:", args.filename)
        print("         Name:", name)
        print("         Desc:", desc.split("\\n")[0])
        for d in desc.split("\\n")[1:]:
            print("              ", d)
        print(" Discoverable?:", is_discoverable)

        position_on_access_point(args.filename, name, desc, is_discoverable)

    def to_local(dt):
        from dateutil.tz import gettz, tzlocal

        tz = tzlocal()  # this system's timezone
        if dt.tzinfo:
            return dt.astimezone(tz)
        else:
            return dt.replace(tzinfo=gettz("UTC")).astimezone(tz)

    def parse_date(datestr):
        # Convert a string into a date.  Supports absolute or relative dates.
        import dateparser

        return to_local(dateparser.parse(datestr))

    def nice_date(date):
        # Convert to a human readable date
        import humanize

        # Humanize consumes datetimes without timezone info.  So:
        # 1. Create a local date (ensures there is timezone info
        #    and that it has been forced to the local zone)
        # 2. Strip off the tzinfo for humanize
        # 3. Call humanize to get a friendly version, like "44 minutes ago"
        dt = to_local(date).replace(tzinfo=None)
        return humanize.naturaltime(dt)

    def is_valid_name(name):
        # Generally speaking, an asset name can be any string, but filenames have
        # limitations.  For now we'll apply the stricter filename limitations to
        # both to avoid really odd problems.
        for c in name:
            if c.isalnum():
                # Allow all alpha-numeric characters (A-Z, a-z, 0-9)
                continue
            if c in '~`!@#$%^&*()-_+=[]{}|;"<>,.? ':
                # Allow most symbols, but not colon (:), slash (either / or \),
                # just to be safe.
                continue
            if ord(c) > 128:
                continue
            return False
        return True

    def confirm_or_exit(msg, show_skip_all=True):
        while True:
            print(msg, end="")
            if show_skip_all:
                print(" [Y/N/Skip/All] ", end="")
            else:
                print(" [Y/N] ", end="")
            sys.stdout.flush()
            response = sys.stdin.readline()
            if show_skip_all and response.lower().startswith("s"):
                return "skip"
            if show_skip_all and response.lower().startswith("a"):
                return "all"
            if response.lower().startswith("y"):
                return "yes"
            if response.lower().startswith("n"):
                print("Cancelled.")
                raise SystemExit(2)

    # Note: Result Asset ID info is only printed when user passes --history flag in tb ps list
    def print_jobs(status, l, style, show_idx):
        if not l:
            return
        num_cols = 0

        if style == "compact":
            l.insert(num_cols, "Job ID")
            l.insert(num_cols + 1, "Name")
            num_cols += 2
        else:
            # if status active, waiting, or None(all)
            l.insert(num_cols, "Job ID")
            l.insert(num_cols + 1, "Started")
            l.insert(num_cols + 2, "Owner")
            l.insert(num_cols + 3, "Status")
            l.insert(num_cols + 4, "Name")
            num_cols += 5

        if show_idx:
            l.insert(0, "")
            num_cols += 1

        if status == "history":
            l.insert(num_cols, "Result Asset ID")
            num_cols += 1

        tb.util.print_in_columns(l, fixed_columns=num_cols, align_right=False)
        if action == "list":
            count = (len(l) // num_cols) - 1  # number rows, minus the header
            print(f"  Showing {count} processes")

    def prep_job_for_print(job, status, style, idx):
        if job is None:
            return

        l = []

        if idx:
            # index
            l.append(idx)

        if style == "compact":
            l.append(job.id)
            l.append(job.job_name)
            if status == "history":
                l.append(job._result)  # result asset ID
            return l

        # job id
        l.append(job.id)

        # Started (when job started)
        date = to_local(parse(job.created))
        if "--details" in flags:
            l.append(date.strftime("%Y-%m-%d %H:%M:%S"))  # e.g. "2022-05-25 23:51:47"
        else:
            l.append(nice_date(date))  # e.g. "44 minutes ago"

        # who ran the job
        l.append(job.owner["username"])

        # status == active
        if job.status == "proc":
            l.append("Preprocessing Request")
        elif job.status == "pay":
            l.append("Queuing")
        elif job.status == "que":
            l.append("Queued")
        elif job.status == "calc":
            l.append("Calculating")
        # status == waiting
        elif job.status == "perm":
            l.append("Waiting on Permissions")
        # status == history
        elif job.status == "comp":
            l.append("Completed")
        elif job.status == "fail":
            l.append("Failed")
        elif job.status == "term":
            l.append("Terminated")
        else:
            l.append("")

        # Name of job
        l.append(job.job_name)

        if status == "history":
            l.append(job._result)  # result asset ID

        return l

    # adds necessary column names to beginning of list
    def show_access_requests(l):
        if not l:
            return

        # Add headers to the list
        l.insert(0, "")
        l.insert(1, "Requestor")
        l.insert(2, "Asset(s)")
        l.insert(3, "Reason")
        num_cols = 4

        tb.util.print_in_columns(l, fixed_columns=num_cols, align_right=False)

    def format_requestor_name(req):
        if req.by_team:
            if req.by_user["first_name"] or req.by_user["last_name"]:
                name = f"{req.by_user['first_name']} {req.by_user['last_name']}".strip()
            else:
                name = req.by_user["username"]
            return f"{name} -- {req.by_team['name']}"
        else:
            return req.by_user  # old

    # adds all necessary info to single list
    def prep_req_for_compact_print(req, idx):
        if req is None:
            return

        # index
        l = [idx]
        # requestor
        l.append(format_requestor_name(req))
        # name of asset being requested in format: name/asset-id
        l.append(req.perm_meta[0]["name"].split("/")[0] if req.perm_meta else "")
        # name of the job requesting the asset
        l.append(req.job_name)

        if req.perm_meta:
            for asset in req.perm_meta[1:]:
                l.append("")
                l.append("")
                l.append(asset["name"].split("/")[0])
                l.append("")

        return l

    def show_access_requests_detailed(reqs):
        req_idx = 0
        max = tb.util.get_terminal_width() - 1
        pp = pprint.PrettyPrinter(indent=2, width=max - 10)

        for req in reqs:
            req_idx += 1

            job_meta = pp.pformat(req.job_meta)
            formatted_meta = ""
            for line in job_meta.splitlines():
                formatted_meta += " " * 10 + line + "\n"

            if req.preprocessors:
                preprocs = pp.pformat(req.preprocessors)
                formatted_preprocs = ""
                for line in preprocs.splitlines():
                    formatted_preprocs += " " * 10 + line + "\n"
            else:
                formatted_preprocs = " " * 10 + "None" + "\n"

            print()
            print("=" * 25)
            if len(reqs) > 1:
                print(f"Asset Access Request #{req_idx}")
            else:
                print(f"Asset Access Request")
            print("=" * 25)
            print(f"         Requestor: {format_requestor_name(req)}")
            print(f"          Asset(s): ", end="")
            for idx, asset in enumerate(req.perm_meta):
                name = asset["name"].split("/")[0]
                if idx == 0:
                    print(name)
                else:
                    print(f"                    {name}")
            print(f"         Asset IDs: ", end="")
            for idx, id in enumerate(req.asset):
                id = req.asset[idx]
                if idx == 0:
                    print(id)
                else:
                    print(f"                    {id}")
            print(f"            Reason: {req.job_name}")
            print(f"      Job Metadata:")
            print(formatted_meta)  # print job meta
            print(f"     Preprocessors:")
            print(formatted_preprocs)  # print data preprocs

    def asset_name_match(name, search):
        if not search:
            return True

        if isinstance(search, str):  # is string
            return search in name
        else:  # is regex
            return search.match(name)

    def list_requests(reqs, search):
        if not reqs:
            return None

        matches = []
        idx = 0

        if search and search.startswith("/") and not search[1:].endswith("/"):
            # A regex search was unquoted and had a space in it, so it got broken into
            # several arg entries.
            while True:
                arg = get_arg()
                if arg == None:
                    raise SystemExit(
                        "Error: Unterminated regex search value.  To use a literal /, enclose search in quotes."
                    )
                search += " " + arg
                if search.endswith("/"):
                    break

        if search and search.startswith("/") and search.endswith("/"):
            # Search string is a regex, compile it
            search = re.compile(search[1:-1])

        l_full = []
        match_count = 0
        for req in reqs:
            # filter the list if there is a search
            match_count = 0
            if "--exclude" in flags:
                for asset in req.perm_meta[0:]:  # check match for each asset in job
                    if asset_name_match(asset["name"].split("/")[0], search):
                        match_count += 1
                if match_count > 0:  # if matches, don't append
                    continue
            elif req.perm_meta:
                match_count = len(req.perm_meta)
                for asset in req.perm_meta[0:]:
                    if not asset_name_match(asset["name"].split("/")[0], search):
                        match_count -= 1
                if match_count < 1:  # if no matches, don't append
                    continue
            matches.append(req)

            if "--details" in flags:
                continue
            else:
                idx += 1
                l = prep_req_for_compact_print(req, idx)  # return list for print
                for i in l:
                    l_full.append(i)  # combine into single list for print

        if "--details" in flags:
            show_access_requests_detailed(reqs)
        else:
            show_access_requests(l_full)
        return matches

    ##########################################################################
    # Subcommand helpers

    def team_get_name(team_id: int) -> str:
        teams = tb.Team.get_all()  # list of Teams
        for team in teams:
            if team["id"] == team_id:
                return team["name"]
        return ""

    def team_lookup(team, silent_fail=False) -> Tuple[int, str]:
        try:
            # Test if the team is an integer, if so, assume it's a team ID.
            team_id = int(team)
        except ValueError:
            team_id = None

        # Search for team matching given name or ID
        for t in tb.Team.get_all():
            if (team_id and t["id"] == team_id) or t["name"].lower() == str(
                team
            ).lower():
                return t["id"], t["name"]
        if silent_fail:
            return None, None
        else:
            raise SystemExit(f"Could not find team '{team}'")

    def team_activate(team):
        team_id = None

        if team == "default":
            team_id = "''"
        else:
            try:
                if not team:
                    perror("No team specified.", exit=True)

                team_id, _ = team_lookup(team)
                if not team_id:
                    perror(f"Unknown team '{team}'", exit=True)

                all_teams = tb.Team.get_all()
                found = False
                for t in all_teams:
                    found |= t["id"] == team_id

                if not found:
                    team_id = None
                    perror(f"Invalid team ID ({team}) specified.", exit=True)
            except SystemExit as e:
                perror(e)
                if team_id is None:
                    print("\nAvailable teams:")
                    team_list(None)
                raise SystemExit(1)

        # Open tripleblind.yaml and update the 'active_team' value with the new team ID.
        # Keep the comments intact.
        with open(config.config_file, "r") as f:
            lines = f.readlines()
        with open(config.config_file, "w") as f:
            written = False
            for line in lines:
                if line.startswith("active_team:"):
                    f.write(f"active_team: {team_id}\n")
                    written = True
                else:
                    f.write(line)
            if not written:
                f.write(f"active_team: {team_id}\n")

        if team_id == "''":
            print("Active team set to default")
        else:
            print(f"Active team set to {team_id} ({team_get_name(team_id)})")

    def team_add(user):
        # Support for "tb team add USER [TEAM] [PERMISSIONS]"

        if not user:
            perror(f"You must provide a user id or email address.", exit=True)

        # Check if the user is a UUID
        try:
            UUID(user)
        except ValueError:
            # Validate that the user is an email address
            if not re.match(r"[^@]+@[^@]+\.[^@]+", user):
                perror(f"Invalid user id or email address '{user}'", exit=True)

        # Next param could be team
        team_id = get_default_session().active_team
        if peek_arg():
            team_id, _ = team_lookup(peek_arg(), silent_fail=True)
            if team_id:
                get_arg()  # consume the param
            else:
                team_id = get_default_session().active_team
        _, team_name = team_lookup(team_id)

        # Remaining params are permissions
        permissions = []
        while peek_arg():
            permissions.append(get_arg())  # validated within Team.add_member

        try:
            print(f"Adding '{user}' to team '{team_name}' ({team_id})...")
            tb.Team.add_member(user, team_id, permissions)
            tb.util.wrap(
                "Success! If the user is already a member of the organization "
                + "they are already part of the team. Otherwise they will "
                + "receive an invitation in their email and be added to the "
                + "team once they create their TripleBlind account."
            )
            print()
            if permissions:
                tb.util.wrap(
                    "This user has been granted the permissions:\n  {permissions}"
                )
            else:
                tb.util.wrap(
                    "Permissions were not specified, an administrator can grant "
                    + "team rights either now or as soon as the user accepts "
                    + "the invitation to create an account."
                )
        except ValueError as e:
            perror(f"{e}", exit=True)
        except TripleblindAPIError as e:
            perror(f"Failed to add user to team.\n{e}", exit=True)

    def team_remove(user):
        # Support for "tb team remove USER [TEAM]"
        if not user:
            perror(f"You must provide a user id or email address.", exit=True)
        team = get_arg()
        if get_arg():
            perror("Too many parameters.", exit=True)

        # Use the "@" symbol to detect email address parameter
        if not team:
            team = get_default_team_id()[1]
        if not "@" in user and "@" in str(team):
            user, team = team, user
        team_id, team_name = team_lookup(team)
        if not team_id:
            perror(f"Unknown team '{team}'", exit=True)

        # Check if the user is a UUID
        try:
            UUID(user)
        except ValueError:
            # Validate that the user is an email address
            pass
            # if not re.match(r"[^@]+@[^@]+\.[^@]+", user):
            #    perror(f"Invalid user id or email address '{user}'", exit=True)

        try:
            print(f"Removing '{user}' from team '{team_name}' ({team_id})...")
            tb.Team.remove_member(user, team_id)
            print(f"Removed from team.")
        except ValueError as e:
            perror(f"{e}", exit=True)
        except TripleblindAPIError as e:
            perror(f"Failed to remove user from team.\n{e}", exit=True)

    def get_default_team_id() -> Tuple[bool, str]:
        team_id = config.active_team
        if team_id == "":
            return True, tb.Team.get_all()[0]["id"]  # assume the first is the default
        return False, team_id

    def team_get_id(team):
        using_default = False
        if not team:
            using_default, team_id = get_default_team_id()
        else:
            team_id, _ = team_lookup(team)
        return team_id, using_default

    def team_info(team):
        team_id, using_default = team_get_id(team)
        if "--compact" in flags:
            if using_default:
                print("")
            else:
                print(team_id)
            return

        if team_id is None:
            perror(f"Invalid team in '{config.config_file}'", exit=True)
        team_name = team_get_name(team_id)

        if team:
            print("Team details:", team)
        else:
            print("Active team:")

        if using_default:
            team_id = f"{team_id} (default)"
        print(f"    ID: {team_id}")
        print(f"  Name: {team_name}")

    def team_list_members():
        # Check if there is an optional team name or optional search provided as arg
        param = get_arg()
        search = get_arg()
        team_id = None
        if param:
            team_id, team_name = team_lookup(param, silent_fail=True)
            if not team_id:
                # Assume the first param was actually a search against default team
                if search:
                    perror(
                        f"Unknown team '{param}' and/or unknown parameter '{search}'",
                        exit=True,
                    )
                search = param

        users = tb.User.get_all_known(team_id)
        if search and users:
            search = search.lower()
            filtered = {}
            for user_id in users.keys():
                info = users[user_id]
                if (
                    search in info["email"].lower()
                    or search in (info["first_name"] + " " + info["last_name"]).lower()
                    or search in str(user_id)
                ):
                    filtered[user_id] = info
            users = filtered

        if users:
            w = [len("Email"), len("First + Last Name"), 36, 19]

            # Calculate the needed column widths
            for user_id in users.keys():
                info = users[user_id]
                w[0] = max(w[0], len(info["email"]))
                w[1] = max(
                    w[1],
                    len((info["first_name"] + " " + info["last_name"]).strip()),
                )
                for t in info["teams"]:
                    w[3] = max(w[3], len(t["name"]) + 10)

            # Print header
            if "--details" in flags:
                print(
                    f"{'Email'.ljust(w[0])}  {'First + Last Name'.ljust(w[1])}  {'User ID'.ljust(w[2])}  Team Name (* if owner)"
                )
                print(f"{'-'*w[0]}  {'-'*w[1]}  {'-'*w[2]}  {'-'*w[3]}")
            else:
                print(
                    f"{'Email'.ljust(w[0])}  {'First + Last Name'.ljust(w[1])}  {'User ID'}"
                )
                print(f"{'-'*w[0]}  {'-'*w[1]}  {'-'*w[2]}")

            # Print values
            for user_id in users.keys():
                # Get the key of this single-item dict
                info = users[user_id]
                if "--details" in flags:
                    print(
                        f"{info['email'].ljust(w[0])}  {(info['first_name'] + ' ' + info['last_name']).strip().ljust(w[1])}  {str(user_id).ljust(w[2])}",
                        end="",
                    )
                    indent = 1
                    for t in info["teams"]:
                        print(
                            " " * indent,
                            "*" if t["owner"] else " ",
                            f"{t['name'].ljust(w[3]-6)}",
                        )
                        indent = w[0] + w[1] + w[2] + 5
                else:
                    print(
                        f"{info['email'].ljust(w[0])}  {(info['first_name'] + ' ' + info['last_name']).strip().ljust(w[1])}  {str(user_id).ljust(w[2])}"
                    )
        else:
            print("No users found.")

    def team_list(team_name: str, all=False):
        if all:
            try:
                teams = tb.Team.get_all_org()
            except TripleblindAPIError as e:
                perror(f"{e}", exit=True)
            mine = tb.Team.get_all()
            longest = max(max([len(t["name"]) for t in teams]), 10)
            if not teams:
                print("No teams defined for your organization.")
                raise SystemExit(1)
            print(f"{'Team ID':7}   {'Team Name'.ljust(longest)}   {'Member?':7}")
            print(f"{'-'*7}   {'-'*longest}   {'-'*7}")
        else:
            teams = tb.Team.get_all()
            longest = max(max([len(t["name"]) for t in teams]), 10)
            if not teams:
                print("Not a member of any team.")
                raise SystemExit(1)
            print(f"{'Team ID':7}   {'Team Name'.ljust(longest)}")
            print(f"{'-'*7}   {'-'*longest}")

        any = False
        for t in teams:
            if team_name and not team_name.lower() in t["name"].lower():
                continue
            any = True
            active = "*" if t["id"] == config.active_team else " "
            if all:
                in_it = "Yes" if t["name"] in [x["name"] for x in mine] else "No"
                print(f"{t['id']:7}  {active}{t['name'].ljust(longest)}   {in_it}")
            else:
                print(f"{t['id']:7}  {active}{t['name']}")
        if not any:
            if team_name:
                print(f"No teams containing '{team_name}'.")
            else:
                print("** No teams found. **")

    def team_create(params: List[str]):
        if not params:
            perror("No team name specified.", exit=True)

        session = get_default_session()
        team_name = params[0]
        owner = params[1] if len(params) > 1 else None
        owner_email = owner if owner else session.user_email

        try:
            teams = tb.Team.get_all_org()
        except TripleblindAPIError as e:
            perror(f"{e}", exit=True)
        for t in teams:
            if team_name.lower() == t["name"].lower():
                raise SystemExit(f"Team '{t['name']}' already exists.")

        print(f"Creating team '{team_name}' owned by {owner_email}...")
        try:
            res = tb.Team.create(team_name, owner)
            print(f"Team '{team_name}' created, id={res['id']}.")
        except TripleblindAPIError as e:
            perror(f"Failed to create team.\n{e}", exit=True)

    def team_set_owner(owner):
        # Support for "tb team set-owner USER [TEAM]"
        if not owner:
            perror("No new owner specified.", exit=True)
        team = get_arg()
        if get_arg():
            perror("Too many parameters.", exit=True)

        # Use the "@" symbol to detect email address parameter
        if not team:
            team = get_default_team_id()[1]
        if not "@" in owner and "@" in str(team):
            owner, team = team, owner
        team_id, team_name = team_lookup(team)

        if not team_id:
            perror(f"Unknown team '{team}'", exit=True)

        print(f"Setting owner of team '{team_name}' ({team_id}) to '{owner}'...")
        try:
            tb.Team.set_owner(team_id, owner)
            print(f"Owner of team changed successfully.")
        except TripleblindAPIError as e:
            perror(f"Failed to change owner of team.\n{e}", exit=True)

    ##########################################################################
    legal_actions = {
        "asset": [
            "create",
            "delete",
            "retrieve",
            "list",
            "remove",
            "set",
            "mask",
            "unmask",
            "preproc",
        ],
        "process": ["list", "retrieve", "cancel", "kill", "connect"],
        "requests": [None, "reply", "list"],
        "team": [
            None,
            "info",
            "members",
            "list",
            "activate",
            "add",
            "remove",
            "set-owner",
        ],
        "admin": [
            {"team": ["create", "list"]},
            {"owner": ["add", "remove", "list"]},
            {"user": ["list"]},
        ],
        "validate": [None],
        "version": [None],
    }

    simple_flags = [
        "--help",
        "--compact",
        "--details",
        "--quiet",
        "--delete",
        "--exclude",
        "--mine",
        "--owned",
        "--yes",
        "--all",
        "--name",
        "--desc",
        "--public",
        "--overwrite",
        "--auto_rename",
        "--show_traffic",  # handled at lower level, just ignore
        "--version",
        "--active",
        "--waiting",
        "--exact",
        "--history",
        "--reply",
        "--raw",
    ]

    since = None
    before = None
    order = "name"
    max_results = -1  # use default (500)

    args = []
    flags = []
    for arg in sys.argv:
        if arg.startswith("--"):
            flags.append(arg)
            if arg not in simple_flags:
                if arg.startswith("--token="):
                    tb.initialize(api_token=arg[8:])
                elif arg == "--org1":
                    # Undocumented parameter to act as org1
                    tb.initialize(api_token=tb.config.example_user1["token"])
                elif arg == "--org2":
                    # Undocumented parameter to act as org2
                    tb.initialize(api_token=tb.config.example_user2["token"])
                elif arg == "--org3":
                    # Undocumented parameter to act as org3
                    tb.initialize(api_token=tb.config.example_user3["token"])
                elif arg.startswith("--since="):
                    since = parse_date(arg[8:])
                    if since > to_local(datetime.now()):
                        raise SystemExit("Date for 'since' is in the future: ", since)
                elif arg.startswith("--before="):
                    before = parse_date(arg[9:])
                elif arg.startswith("--max="):
                    max_results = arg[6:]
                    if not max_results:
                        max_results = None
                    else:
                        max_results = int(max_results)
                elif arg.startswith("--order="):
                    order = arg[8:]
                    if not order in ["name", "date", "owner"]:
                        print(
                            f"Unknown order '{order}'.  Must be 'name', 'date' or 'owner'"
                        )
                        raise SystemExit(2)
                else:
                    print("Unrecognized flag:", arg)
                    raise SystemExit(2)
        else:
            args.append(arg)

    if peek_arg() == "install":
        # Rather than altering paths or system dependent things like bashrc, we
        # are just giving the user instructions on how to to this for themselves.
        install_alias()
        raise SystemExit(0)

    if peek_arg() == "version" or "--version" in flags:  # --version is deprecated
        sdk_version = tb.__version__

        # Get info about current session to check AP version
        session = get_default_session()
        try:
            ap_version = session.get_access_point_version()
        except Exception as e:
            ap_version = None
        if not ap_version or ap_version == "UNKNOWN":
            ap_version = "Unable to connect to Access Point"

        # Get latest versions of SDK and AP
        try:
            version_response = session.get(f"{config.gui_url}/version/version.json")
            json_version = json.loads(version_response.data)  # convert str to dict
            latest_version_SDK = f"{json_version['major']}.{json_version['minor']}.{json_version['patch_api']}"
            latest_version_AP = f"{json_version['major']}.{json_version['minor']}.{json_version['patch_provider']}"
            endpoint_version = f"{json_version['major']}.{json_version['minor']}.{json_version['patch_marketplace']}"
        except:
            version_response = "Unable to connect to API Endpoint."
            latest_version_SDK = latest_version_AP = endpoint_version = version_response

        print(f"SDK Version:           {sdk_version}")
        print(f"Access Point Version:  {ap_version}")
        print(f"Endpoint Version:      {endpoint_version}")
        print(f"Endpoint:              {config.gui_url}")
        print(f"Configuration File:    {config.config_file}")
        print()
        if session.user_email is None:
            print("WARNING: Unable to reach the Router for user information.")
            raise SystemExit(0)

        print(f"User info:")
        print(f"  Account email:       {session.user_email}")
        print(f"  User ID:             {session.user_id}")
        print(f"  Real name:           {session.user_name}")
        print(f"  Username:            {session.user_username}")

        team_id, _ = team_get_id(None)
        teams = tb.Team.get_all()  # list of Teams
        print()
        print("Your Teams: (* is active)")
        for team in teams:
            active = "*" if team["id"] == team_id else " "
            print(f"  {active}{team['name']}   ({team['id']})")
        print()
        print(f"Your Organization:")
        print(f"  Name:                {session.organization_name}")
        print(f"  Organization ID:     {session.organization_id}")

        if sdk_version != latest_version_SDK or ap_version != latest_version_AP:
            print("\nUpdates available!")
            if sdk_version != latest_version_SDK:
                print(f"    Latest available SDK version:       {latest_version_SDK}")
            if ap_version != latest_version_AP:
                print(f"    Latest available AP version:        {latest_version_AP}")
        raise SystemExit(0)

    if len(args) < 2 or "--help" in flags or "-h" in flags or "-?" in flags:
        show_help()
        raise SystemExit(0)

    arg1 = get_arg()  # asset, process/ps, or requests/req
    if arg1:
        arg1 = arg1.lower()
    # Expand with synonyms like "req" to "requests"
    arg1 = subcmd_alias[arg1] if arg1 in subcmd_alias else arg1

    group = None
    action = ""

    if arg1 == "download":  # DEPRECATED: Remove at v2?
        arg1 = "retrieve"

    if arg1 == "validate":
        # Handle validation here to preserve the case of any argument
        import warnings

        from tripleblind.util.model_checker import run_report

        model = get_arg()
        if not model:
            model = Path.cwd()

        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            run_report(model)
        raise SystemExit(0)

    if not arg1 in legal_actions and arg1 in legal_actions["asset"]:
        # User didn't pass a group but did pass a valid action, assume
        # "asset" and make the group the action.
        action = arg1
        group = "asset"
    elif arg1 in legal_actions:
        group = arg1
        action = get_arg() or ""
        action = action.lower()
        if action == "download":  # DEPRECATED: Remove at v2?
            action = "retrieve"

    if not group or (action if action else None) not in legal_actions[group]:

        # Check if it is a subgroup, like "admin team create"
        # Support plurals, e.g. "team" or "teams"
        a = action.rstrip("s")
        if group == "requests" and action == "":
            pass
        elif (
            group
            and type(legal_actions[group]) == list
            and a
            in [
                list(subgroup)[0] if subgroup else None
                for subgroup in legal_actions[group]
            ]
        ):
            # Valid subgroup, e.g. for "admin" actions
            pass
        else:

            if action:
                print(f"Unrecognized action '{action}'")
            show_help()
            raise SystemExit(1)

    # TODO: This is starting to get a bit long, consider breaking into
    #       tb-asset.py, tb-process.py and tb-requests.py scripts that we
    #       import and invoke here.
    #  if group == "asset":
    #     import .tb-asset
    #     tb-asset.handle(action, flags)
    #  elif group == ...

    if group == "asset":
        if action == "delete":
            action = "remove"
            flags.append("--delete")

        if action in ["mask", "unmask"] and "--owned" not in flags:
            flags.append("--owned")

        if action == "preproc":
            # can be overridden later
            stub_filename = "preproc.py"
            data_filename = "preproc.csv"
            param1 = get_arg()
            if param1 not in ["create", "test"]:
                if param1:
                    print(
                        f"Unrecognized preproc command '{param1}', must be 'create' or 'test'"
                    )
                else:
                    print("Must specify preproc command 'create' or 'test'")
                # show_help()
                raise SystemExit(1)

        if action == "create":
            handle_create()
            raise SystemExit(0)

        if action == "set":
            param1 = get_arg()
            if param1 not in ["name", "desc", "filename", "discoverable"]:
                if param1:
                    print(f"Unrecognized property '{param1}'")
                else:
                    print("Missing property to set")
                show_help()
                raise SystemExit(1)

            param2 = get_arg()
            if not param2:
                print(f"Missing new value for {param1}")
                show_help()
                raise SystemExit(1)

        asset_type = get_arg()
        if asset_type in ["data", "alg"]:
            search = get_arg()
        else:
            search = asset_type
            asset_type = None

        if action == "retrieve":
            param1 = get_arg()

        if search and search.startswith("/") and not search[1:].endswith("/"):
            # A regex search was unquoted and had a space in it, so it got broken into
            # several arg entries.
            while True:
                arg = get_arg()
                if arg == None:
                    raise SystemExit(
                        "Error: Unterminated regex search value.  To use a literal /, enclose search in quotes."
                    )
                search += " " + arg
                if search.endswith("/"):
                    break

        if not search:
            search = ""
        elif search.startswith("/") and search.endswith("/"):
            # Search string is a regex, compile it
            if "--exclude" in flags:
                # This beast produces a regex for patterns that don't exist.
                search = re.compile(f"^((?!{search[1:-1]}).)*$")
            else:
                search = re.compile(search[1:-1])
        elif "--exact" in flags:
            # Wrap the simple search string in /^SEARCH$/ to make exact match
            search = re.compile(f"^{search}$")

        if "--mine" in flags:
            namespace = tb.asset.NAMESPACE_DEFAULT_USER
        else:
            namespace = None  # return all assets, regardless of owner
        owned = "--owned" in flags or action == "remove"

        limit = 500 if max_results == -1 else max_results
        if asset_type == "alg":
            res = tb.Asset._find_algorithm(
                search, namespace=namespace, max=limit, owned=owned
            )
        elif asset_type == "data":
            res = tb.Asset._find_dataset(
                search, namespace=namespace, max=limit, owned=owned
            )
        else:
            res = tb.Asset._find_asset(
                "", search, search_namespace=namespace, max=limit, owned=owned
            )
        if res and (since or before):
            # Apply filter
            filtered_list = []
            for a in res:
                if since and a.activate_date < since:
                    continue
                if before and a.activate_date > before:
                    continue
                filtered_list.append(a)
            res = filtered_list

        if not res:
            if action == "preproc" and param1 == "test":
                # Very special case.  No asset name required.
                args.append(search)
                res = [None]
            else:
                if not "--quiet" in flags:
                    print("No matches found for:", search)
                raise SystemExit(0)

        if order and len(res) > 1:
            if order == "date":
                res.sort(key=lambda a: a.activate_date)
            elif order == "owner":
                res.sort(key=lambda a: (a.team, a.name))
            elif order == "name":
                res.sort(key=lambda a: a.name)

        if (action == "list" or len(res) > 1) and "--all" not in flags:
            idx = 1
            show_details = "--details" in flags
            l = []
            for a in res:
                a_metadata = None
                if show_details:
                    print()
                    # print(f"{idx:2})         Asset: {a.uuid}")
                    if not action == "list":
                        print(f"{idx}) ", end="")
                    print(a.name)
                    print(f"         asset id: {a.uuid}")
                    if a.is_active:
                        print(f"          created: {to_local(a.activate_date)}")
                    else:
                        print(f"          created: ** deactivated **")
                    print(f"         filename: {a.filename}")
                    print(f"             desc: {a.desc}")
                    print(f"        namespace: {a.namespace}")
                    print(f"    discoverable?: {a.is_discoverable}")
                    print(f"             team: {a.team} ({a.team_id})")
                    if a.metadata and "columns" in a.metadata:
                        if isinstance(a.metadata, str):  # a.metadata not always a str
                            a_metadata = json.loads(a.metadata)
                        if a_metadata and a_metadata["columns"]:
                            print(f"          columns: ", end="")
                            tb.util.print_in_columns(
                                a.metadata["columns"], indent=19, initial_indent=0
                            )
                else:
                    if not action == "list":
                        l.append(idx)
                    l.append(a.name)
                    if "--compact" not in flags:
                        l.append(a.uuid)
                        if a.is_active:
                            l.append(
                                to_local(a.activate_date).strftime("%m-%d-%Y %H:%M")
                            )
                        else:
                            l.append("** deactivated **")
                        l.append(a.team)
                idx += 1
            if res and not show_details:
                cols = 1
                if not action == "list":
                    l.insert(0, "")
                    cols += 1
                l.insert(cols - 1, "Name")
                if "--compact" not in flags:
                    l.insert(cols, "Asset ID")
                    l.insert(cols + 1, "Date Created")
                    l.insert(cols + 2, "Owner")
                    cols += 3
                tb.util.print_in_columns(l, fixed_columns=cols, align_right=False)
                if action == "list":
                    print(f"  Found {idx-1} assets")
            if action == "list":
                # Nothing else left to do!  Just quit.
                raise SystemExit(0)

            while True:
                print(f"Enter an index (1-{len(res)}) or 'all', ENTER to abort:")
                idx = input("> ").strip()
                if idx == "":
                    raise SystemExit(0)
                if idx == "all":
                    break
                try:
                    idx = int(idx) - 1
                    if idx >= 0 and idx < len(res):
                        res = [res[idx]]
                        break
                except:
                    pass

        # At this point res[] has all the Assets to be deal with
        columns = []
        for a in res:
            if action == "set" and param1 == "name":
                if not is_valid_name(param2):
                    raise SystemExit("Name must be alpha-numeric")
                a.name = param2
                print("Name updated.")
            elif action == "set" and param1.startswith("desc"):  # allow "description"
                a.desc = param2
                print("Description updated.")
            elif action == "set" and param1 == "filename":
                if not is_valid_name(param2):
                    raise SystemExit("Filename must be alpha-numeric")
                a.filename = param2
                print("Filename updated.")
            elif action == "set" and param1.startswith("disc"):  # allow "discoverable"
                a.is_discoverable = param2.lower() in ["y", "yes", "t", "true", "1"]
                print("Discoverable set to: ", a.is_discoverable)
            elif action == "preproc":
                x = get_arg()
                while x:
                    if x.endswith(".py"):
                        stub_filename = x
                    elif x.endswith(".csv"):
                        data_filename = x
                    else:
                        raise SystemExit(f"Unknown argument: '{x}'")
                    x = get_arg()

                # TODO: If there is no data, retrieve it?
                # if data_filename == "preproc.csv" and search.endswith(".csv"):
                #    data_filename = search
                # else:
                #    # TODO: Retrieve the dataset from search term?
                #    pass

                if param1 == "create":
                    preproc_create(a, stub_filename, data_filename)
                elif param1 == "test":
                    preproc_test(a, stub_filename, data_filename)
            elif action == "remove":
                remote_delete = "--delete" in flags
                if "--yes" not in flags:
                    if remote_delete:
                        ans = confirm_or_exit(
                            f"Delete '{a.name}' completely?  This cannot be undone."
                        )
                    else:
                        ans = confirm_or_exit(f"Delete '{a.name}'?")
                    if ans == "all":
                        flags.append("--yes")  # don't ask again
                    if ans == "skip":
                        print("...skipping")
                        continue
                if not a.archive(remote_delete=remote_delete):
                    print(f"Delete of '{a.name}' failed.")
                    raise SystemExit(1)

                if remote_delete:
                    print(f"Removed '{a.name}' from index and deleted.")
                else:
                    print(f"Removed '{a.name}' from index.")
            elif action == "retrieve":
                clobber = "--overwrite" in flags
                try:
                    if param1:
                        a.retrieve(
                            save_as=param1, show_progress=True, overwrite=clobber
                        )
                    else:
                        if a.filename:
                            a.retrieve(show_progress=True, overwrite=clobber)
                        else:
                            print(
                                "No default filename for this asset, saving as 'download.out'"
                            )
                            param1 = "download.out"
                            a.retrieve(
                                save_as=param1,
                                show_progress=True,
                                overwrite=clobber,
                            )
                    print(f"Retrieved as '{param1 or a.filename}'")
                except TripleblindAssetError as e:
                    perror(f"Unable to retrieve {a.team} asset.")
                    perror(
                        "Only assets owned by your team may be retrieved. (403)",
                        exit=True,
                    )
                except TripleblindPermissionError as e:
                    perror("Retrieve Asset permission is required. (403)", exit=True)
                except Exception as e:
                    perror(e, exit=True)
            elif action == "mask" or action == "unmask":
                try:
                    table = tb.TableAsset.cast(a)
                    if not columns:
                        # Build the list the first time
                        while True:
                            x = get_arg()
                            if x:
                                columns.append(x)
                            else:
                                break

                    available_columns = table.get_column_names()
                    if not available_columns:
                        print("No columns found, is this a tabular dataset?")
                        raise SystemExit(-1)

                    if len(columns) == 0:
                        print("You must provide column names to change.")
                        print("Available columns:\n    ", available_columns)
                        raise SystemExit(-1)

                    invalid = []
                    for col in columns:
                        if col not in available_columns:
                            invalid.append(col)
                    if invalid:
                        print("Column name(s) not valid:")
                        print(f"   {invalid}")
                        print("Available columns:\n    ", available_columns)
                        raise SystemExit(-1)

                    if action == "mask":
                        res = table.mask_columns(col_names=columns)
                    else:
                        res = table.unmask_columns(col_names=columns)
                    if not res:
                        print(f"Failed to {action}.")
                    else:
                        print(f"Successfully {action}ed.")
                except Exception as e:
                    perror("Error changing masking settings:")
                    perror(e, exit=True)
    elif group == "process":
        idx = 0
        today = to_local(datetime.now())
        show_compact = "--compact" in flags
        show_details = "--details" in flags
        status = ""
        is_uuid = False
        style = ""
        search = get_arg()

        if search is not None:
            if search.startswith("--"):  # if param is a flag, get the next arg
                search = get_arg()
            try:
                test_uuid = UUID(search)  # throws an exception if not a UUID
                is_uuid = True
            except:
                is_uuid = False

        if action == "retrieve":
            # Only completed jobs can be retrieved
            status = "history"
        elif "--active" in flags:
            status = "active"
        elif "--waiting" in flags:
            status = "waiting"
        elif "--history" in flags:
            status = "history"
        else:
            status = None  # default to None, which prints all statuses

        if show_compact:
            style = "compact"
        elif show_details:
            style = "details"
        else:
            style = "standard"

        limit = 20 if max_results == -1 else max_results
        if is_uuid:
            jobs = tb.Job._find(job_id=search, job_status=status, max=limit)
        elif action in ["cancel", "kill", "connect"]:
            # These might switch from "waiting" to "active" at any moment.  So
            # get both lists and merge.
            jobs_waiting = tb.Job._find(job_name=search, job_status="active", max=limit)
            jobs = tb.Job._find(job_name=search, job_status="waiting", max=limit)
            if jobs:
                if jobs_waiting:
                    jobs.extend(jobs_waiting)
                jobs.sort(key=lambda j: j.created)
                if max_results != -1:
                    # Apply the limit to the merged list
                    jobs = jobs[-max_results:]
            else:
                jobs = jobs_waiting

            if not jobs:
                print("No active processes found.")
        else:
            jobs = tb.Job._find(job_name=search, job_status=status, max=limit)

        if not jobs:
            raise SystemExit(0)  # "No processes found" was already printed

        if action == "list":
            if jobs:
                # if not since and max_results == -1:
                #    # Default to jobs run within the last 48 hours if no other
                #    # limit provided.
                #    since = today - timedelta(hours=48)
                filtered_list = []
                for job in jobs:
                    date = parse(job.created)
                    if since and to_local(date) < since:  # since specified, filter
                        continue
                    filtered_list.append(job)
                if not filtered_list and not since:
                    # Show at least some the most recent (if any exist)
                    for job in jobs:
                        filtered_list.append(job)
                jobs = filtered_list

                if jobs:  # list might be empty
                    l = []
                    l_full = []
                    for job in jobs:
                        idx += 1
                        l = prep_job_for_print(
                            job, status, style, None
                        )  # return list for print
                        for i in l:
                            l_full.append(i)  # combine into single list for print
                    print_jobs(status, l_full, style, False)
                else:
                    print(f"No process started since {nice_date(since)}.")
                    print("Use --since='last week' or similar to see older processes.")

        elif action == "retrieve":
            param1 = get_arg()

            if len(jobs) > 1:
                print("More than one process found:")
                l = []
                l_full = []
                for job in jobs:
                    idx += 1
                    l = prep_job_for_print(
                        job, status, style, idx
                    )  # return list for print
                    for i in l:
                        l_full.append(i)  # combine into single list for print
                print_jobs(status, l_full, style, True)

                while True:
                    print(f"Enter an index (1-{len(jobs)}), ENTER to abort:")
                    idx = input("> ").strip()  # user input
                    if idx == "":  # user hit ENTER, so exit
                        raise SystemExit(0)
                    try:
                        idx = int(idx) - 1
                        if idx >= 0 and idx < len(jobs):
                            jobs = [jobs[idx]]  # list of 1 job at user selected index
                            break
                    except:
                        pass

            clobber = "--overwrite" in flags
            job = jobs[0]
            res_asset = tb.Asset(job._result)
            if res_asset.is_valid:  # if job isn't done, there won't be a result asset
                try:
                    if param1:
                        res_asset.retrieve(
                            save_as=param1, show_progress=True, overwrite=clobber
                        )
                    else:
                        if res_asset.filename:
                            res_asset.retrieve(show_progress=True, overwrite=clobber)
                        else:
                            print(
                                "No default filename for this asset, saving as 'download.zip'"
                            )
                            param1 = "download.zip"
                            res_asset.retrieve(
                                save_as=param1,
                                show_progress=True,
                                overwrite=clobber,
                            )
                        print(f"Retrieved as '{param1 or res_asset.filename}'")
                except Exception as e:
                    perror(e, exit=True)
            else:
                print("Chosen process has not completed. No result asset available.")
                raise SystemExit(0)
        elif action in ["cancel", "kill", "connect"]:
            if len(jobs) > 1 and "--all" not in flags:
                print("More than one process found:")
                l = []
                l_full = []
                for job in jobs:
                    idx += 1
                    l = prep_job_for_print(
                        job, status, style, idx
                    )  # return list for print
                    for i in l:
                        l_full.append(i)  # combine into single list for print
                print_jobs(status, l_full, style, True)

                while True:
                    if action == "connect":
                        print(f"Enter an index (1-{len(jobs)}), or ENTER to abort:")
                    else:
                        print(
                            f"Enter an index (1-{len(jobs)}), 'all', or ENTER to abort:"
                        )
                    idx = input("> ").strip()  # user input
                    if idx == "":  # user hit ENTER, so exit
                        raise SystemExit(0)
                    if idx == "all" and action in ["cancel", "kill"]:
                        break
                    else:
                        try:
                            idx = int(idx) - 1
                            if idx >= 0 and idx < len(jobs):
                                jobs = [
                                    jobs[idx]
                                ]  # list of 1 job at user selected index
                                break
                        except:
                            pass

            if jobs:
                if action == "connect":
                    job = jobs[0]
                    output_stream = job.get_status_stream()
                    generator = output_stream.status()
                    try:
                        status = next(generator)
                        while status:
                            if status == "waiting on permission":
                                pctl("Waiting on job to start...", end=False)
                            elif status == "starting":
                                pctl("\rConnected" + " " * 20)
                            elif "--raw" in flags:
                                print(status, flush=True)
                            else:
                                # Ignore the "waiting on permission" and such messages
                                (
                                    output,
                                    spinner_msg,
                                ) = output_stream.format_status_message(status)
                                for line in output:
                                    print(line)
                                print(spinner_msg)
                            status = next(generator)
                    except StopIteration as e:
                        # The job stopped running on the Access Point for some reason -- could
                        # have crashed, or been terminated by an admin.
                        pctl("Process ended")
                        print(e)
                    except KeyboardInterrupt:
                        # The local user hit Ctrl+C.  User the standard Job keyboard interrupt
                        # handler, which prompts the user to cancel still-running jobs and does
                        # appropriate cleanup.
                        job.handle_keyboard_interrupt()
                else:
                    for job in jobs:
                        # Get confirmation
                        if "--yes" not in flags:
                            ans = confirm_or_exit(
                                f"Are you sure you want to terminate '{job.job_name}'?",
                                show_skip_all=len(jobs) > 1,
                            )
                            if ans == "skip":
                                continue
                            elif ans == "all":
                                flags.append("--yes")  # don't ask again

                        try:
                            # The same API will both de-que and kill
                            job.kill()
                            print(f"Successfully cancelled '{job.job_name}' ({job.id})")
                            continue
                        except Exception as e:
                            perror(e)
                            perror("Unable to cancel process.", exit=True)
            else:
                print("No matching process found.")

    elif group == "requests":
        reqs = tb.Request.get_all(all_assets=False)  # list of Requests

        search = get_arg()  # search term for search by name of asset
        if "--all" in flags:
            matches = reqs
        else:
            matches = list_requests(reqs, search)
        if not matches:
            print("No pending requests found.")
            raise SystemExit(0)  # Done!
        if action == "list":
            print(f"{len(matches)} pending requests found.")
            raise SystemExit(0)  # Done!

        ###########################################
        # Replying to the requests
        if len(matches) == 1:
            flags.append("--all")  # only 1 match, no need to ask "Which?"

        while True:
            if not "--all" in flags:
                print(
                    f"Enter an index (1-{len(matches)}), All or Quit]:",
                    end="",
                )
                sys.stdout.flush()
                response = sys.stdin.readline()

                if response.lower().startswith("q"):  # Quit
                    raise SystemExit(0)
                elif response.lower().startswith("a"):  # All
                    flags.append("--all")
                else:  # User entered index
                    try:
                        idx = int(response)
                        if idx < 1 or idx > len(matches):
                            raise Exception("bad index")
                    except:
                        continue

            if "--accept" in flags:
                response = "accept"
            elif "--reject" in flags:
                response = "reject"
            else:
                response = ""

            reply_to = matches if "--all" in flags else [matches[idx - 1]]

            while True:
                if response.lower().startswith("a"):  # accept
                    for req in reply_to:
                        for asset in req.asset:  # loop through assets to accept all
                            tb.Request.accept(asset, req.job_id)
                    raise SystemExit(0)
                elif response.lower().startswith("d"):  # deny
                    for req in reply_to:
                        for asset in req.asset:
                            tb.Request.deny(asset, req.job_id)
                    raise SystemExit(0)
                elif response.lower().startswith("i"):  # info
                    show_access_requests_detailed(reply_to)
                elif response.lower().startswith("q"):  # quit
                    raise SystemExit(0)
                print(
                    "Accept or Deny access, show more Info about this request, or Quit? [A/D/I/Q] ",
                    end="",
                )
                sys.stdout.flush()
                response = sys.stdin.readline()
    elif group == "team":
        if action == "":
            _, team_id = get_default_team_id()
            print("Your default team is:", team_lookup(team_id))
        elif action == "list":
            team_list(get_arg())
        elif action == "activate":
            team_activate(get_arg())
        elif action == "info":
            team_info(get_arg())
        elif action == "members":
            team_list_members()  # other parameters read inside function
        elif action == "add":
            team_add(get_arg())  # other parameters read inside function
        elif action == "remove":
            team_remove(get_arg())  # other parameters read inside function
        elif action == "set-owner":
            team_set_owner(get_arg())  # other parameters read inside function
    elif group == "admin":
        admin_action = get_arg()
        param = None
        param_list = []
        while True:
            arg = get_arg()
            if not arg:
                break
            if param:
                param += " " + arg
            else:
                param = arg
            param_list.append(arg)

        if action == "team" or action == "teams":
            if admin_action == "create":
                team_create(param_list)
            elif admin_action == "list":
                team_list(param, all=True)
            else:
                perror(
                    f"Unrecognized administrative action '{admin_action}' for 'team'.",
                    exit=True,
                )
        elif action == "owner" or action == "owners":
            if admin_action == "list":
                owners = tb.Owner.get_all()
                if owners:
                    for owner in owners:
                        user = owner["user"]
                        print(
                            f"{user['first_name']} {user['last_name']} ({user['email']})",
                        )
                else:
                    print("No owners found.")
                pass
            elif admin_action == "add":
                new_owner = param
                if not new_owner:
                    print(f"You must specify an existing user account to add.")
                    raise SystemExit(1)
                print(f"Adding owner '{new_owner}'...")
                try:
                    res = tb.Owner.add(new_owner)
                    print(f"Owner '{res['user']['email']}' added.")
                except TripleblindAPIError as e:
                    perror(f"{e.args[0]}", exit=True)
                pass
            elif admin_action == "remove":
                owner = param
                if not owner:
                    print("No owner specified.")
                    raise SystemExit(1)
                print(f"Removing owner '{owner}'...")
                try:
                    tb.Owner.remove(owner)
                    print(f"Owner '{owner}' removed.")
                except TripleblindAPIError as e:
                    perror(f"{e.args[0]}", exit=True)
            else:
                raise SystemExit(
                    f"Unrecognized administrative action '{admin_action}' for 'owner'.",
                    exit=True,
                )
        elif action == "user" or action == "users":
            if admin_action == "list":
                users = tb.User.get_all()
                search = param
                if search and users:
                    search = search.lower()
                    filtered = {}
                    for user_id in users.keys():
                        info = users[user_id]
                        if (
                            search in info["email"].lower()
                            or search
                            in (info["first_name"] + " " + info["last_name"]).lower()
                        ):
                            filtered[user_id] = info
                    users = filtered

                if users:
                    w = [len("Email"), len("First + Last Name"), 36]

                    # Calculate the needed column widths
                    for user_id in users.keys():
                        user = users[user_id]
                        w[0] = max(w[0], len(user["email"]))
                        w[1] = max(
                            w[1],
                            len((user["first_name"] + " " + user["last_name"]).strip()),
                        )

                    # Print header
                    print(
                        f"{'Email'.ljust(w[0])}  {'First + Last Name'.ljust(w[1])}  {'User ID'}"
                    )
                    print(f"{'-'*w[0]}  {'-'*w[1]}  {'-'*w[2]}")

                    # Print values
                    for user_id in users.keys():
                        user = users[user_id]
                        print(
                            f"{user['email'].ljust(w[0])}  {(user['first_name'] + ' ' + user['last_name']).strip().ljust(w[1])}  {str(user_id).ljust(w[2])}"
                        )
                else:
                    print("No users found.")
                pass
            else:
                perror(
                    f"Unrecognized administrative action '{admin_action}' for 'user'.",
                    exit=True,
                )
        else:
            perror(f"Unrecognized admin action '{action}'.", exit=True)


##########################################################################
# Parser assist

args = []
arg_idx = 1


def peek_arg() -> str:
    return args[arg_idx] if len(args) > arg_idx else None


def get_arg() -> str:
    global arg_idx
    idx = arg_idx
    arg_idx += 1
    return args[idx] if len(args) > idx else None


##########################################################################
# Main program entrypoint

try:
    if __name__ == "__main__":
        main()
except KeyboardInterrupt:
    print()  # newline looks cleaner after a Ctrl+C exit
    pass
