#!/usr/bin/python3
# vim:set shiftwidth=4 expandtab:


'''

NCurses ssh Connection Manager (nccm)
=====================================

![](images/program_screenshot.png)

Copyright (C) Kenneth Aaron.

flyingrhino AT orcon DOT net DOT nz

Freedom makes a better world: released under GNU GPLv3.

[https://www.gnu.org/licenses/gpl-3.0.en.html](https://www.gnu.org/licenses/gpl-3.0.en.html)

This software can be used by anyone at no cost, however,
if you like using my software and can support - please
donate money to a children's hospital of your choice.

This program is free software: you can redistribute it
and/or modify it under the terms of the GNU General Public
License as published by the Free Software Foundation:
GNU GPLv3. You must include this entire text with your
distribution.

This program is distributed in the hope that it will be
useful, but WITHOUT ANY WARRANTY; without even the implied
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
PURPOSE.
See the GNU General Public License for more details.



About nccm
==========

* Simple yet powerful ncurses ssh connection manager.
* No unnecessary features or bloatware - do one thing and
    do it well.
* Intuitive filtering by all text you supply.
* Well documented.
* Secure.
* Logs are in English - you don't need to be a developer to
    read the majority of the logs.



Who is nccm for?
================

* You have dozens or thousands of boxes to manage via ssh.
* Too hard to remember server hostnames or IP addresses
    and prefer descriptive names?
* You use ssh connection arguments on the command line and
    it's too much effort to type every time you connect.
* View all your devices at once and filter easily so that
    you know who to connect to.
* Want to focus more on your job and less on the overhead
    of connecting to your devices?
* Prefer to work from the command line, don't have a GUI,
    or simply prefer to work more efficiently?
* Need a simple tool to do its job reliably and securely
    without unnecessary features to slow you down?
* If you answered 'yes' to any of the above -
    nccm is for YOU!



Manual install instructions
===========================

This is the easiest way to install nccm, you can of course
install and use nccm in any way you wish.

* Clone the project from the git repository:
  `git clone https://github.com/flyingrhinonz/nccm nccm.git`
* `cd nccm.git/nccm/`
* `sudo install -m 755 nccm -t /usr/local/bin/`


The ssh connections/config file `nccm.yml` should be
copied to any one of the following locations, and is
loaded from the first location found in the following order:
- `~/.config/nccm/nccm.yml`
- `~/.nccm.yml`
- `~/nccm.yml`
- `/etc/nccm.yml`


Tips on nccm.yml location - if you're a single user then
placement of nccm.yml doesn't matter and it's probably
easiest to place it in one of the home dir locations
( which also saves you the effort of using sudo to edit
files in  /etc/ ).
In a multiuser system, placing nccm.yml in each user's
home dir will allow each user to use their personalized
settings if nccm.yml is present, and if not present then
fallback to default settings from /etc/nccm.yml .
It is also possible to put your real nccm.yml anywhere
you wish and make a symlink to it from one of the paths
mentioned above.
Also refer to  `nccm_config_importnccmd` setting which
allows you to import and merge connection details from
/etc/nccm.d/ .


nccm requires Python3 to be installed on your machine,
which should already be present on most Linux boxes.
Most Python library dependencies are already present
as part of Python3 however the following may not be
present in which case you need to install them manually.


On Debian or similar use apt:
If you want to only use the distro's packages you can do:
* `sudo apt install python3-yaml yamllint`

Or if you prefer to install PyYAML from pip3:
* `sudo apt install python3-pip yamllint`
* `pip3 install --user PyYAML`

On Fedora or similar use dnf:
* `sudo dnf install python3-pip yamllint`
* `pip3 install --user PyYAML`

Before starting, edit the `nccm.yml` file and add your
own ssh connections. Formatting YAML is easy and the file
you downloaded from the project page is well documented
and has examples of every supported scenario.
Follow the structure in the file - provide the connection
name at the beginning of a line and sub config items
indented by two spaces. Every subsequent nesting level
gets a further indent of two spaces. Don't forget the
colons - these are part of the YAML language.
Avoid using tabs because on the screen they expand and
look like 8 spaces but are actually one character from a
yaml perspective and will break your config.

Don't worry about ordering your SSH session blocks in any
specific way (unless you're keeping the file tidy for
editing purposes) because nccm gives you "sort by" options
within the program as well as filtering.
Actually nccm is designed around filtering and this is the
most efficient way to find your connection - I never
bother sorting - I just use the filter.

Once you've finished editing, check your work with yamllint:
* `yamllint nccm.yml`

If no errors are returned, then you've formatted your file
correctly, and it's safe to continue.

If nccm is accessible from your path and is executable,
typing nccm is all that's required to launch the TUI
(terminal user interface).
If you see Python 3 exceptions, check whether you have
satisfied the dependencies. Any exceptions should mention
any package that's missing. Tip - normally the most useful
information in a python exception is at the end of the
output.



nccm.yml settings
=================

This file is mostly used for ssh connection details, but
also supports program settings described in this chapter.
nccm fully respects your privacy and security - and all
defaults are set to values that protect your privacy
and security. It is your choice to change them as you
see fit.

These are global settings, affecting all sessions.


`nccm_config_controlmode`:
--------------------------

Controls the cursor movement mode. Two modes are supported:
- std:    Cursor keys move the marker as expected.
- focus:  Marker and list move independently.
          The list is designed to move while the marker
          remains fixed unless it is moved manually.


`nccm_config_loglevel`:
-----------------------

Controls log level of messages sent by nccm to syslog.
If you are using systemd it usually captures syslog
messages which you can read in `journalctl`. I will use
the word syslog in this documentation as referral to both.
Use this for debugging. Default level is info.
Supported levels: debug, info, warning, error, critical .


`nccm_config_logprivateinfo`:
-----------------------------

Controls whether you want syslog/journal to include private
information such as usernames & hostnames. By default this
is set to `false` which results in the data being replaced
with `CENSORED` in the logs. Note - you will still see
`CENSORED` items for all lines that are logged before this
setting has been read from nccm.yml .
You can also bypass censorship temporarily by supplying the
command line argument:  `--logprivateinfo` .
This also solves the problem of censored logs that occur
before the `nccm_config_logprivateinfo` setting is loaded.
When this is enabled you will see:  `LogPrv` in red in the
help line at the bottom of the screen.


`nccm_config_keepalive`:
------------------------

Sends a message through the encrypted channel every
n seconds (0 to disable) to prevent idle sessions from
being disconnected.
You can customize this on a per-connection basis by using
the setting `keepalive: n` (optional).


`nccm_default_ssh_port`:
------------------------

Works alongside the setting:  `nccm_force_default_ssh_port`
and if:  `nccm_force_default_ssh_port == true`  , then the
value of:  `nccm_default_ssh_port`  will be forced upon
connections that don't have their own:  `port: SERVER_PORT`
setting (which is always respected).
If:  `nccm_force_default_ssh_port == false`  , then the
value of:  `nccm_default_ssh_port`  will have no effect.


`nccm_force_default_ssh_port`:
------------------------------

Works alongside:  `nccm_default_ssh_port` and explained
above.


`nccm_config_identity`:
-----------------------

For public key authentication, normally ssh will load your
private key from the default locations. You can force ssh
to use your own file by putting it's path here. Or set to
`false` to let ssh do it's own thing.
You can customize this on a per-connection basis by using
the setting `identity: path` (optional).


`nccm_config_sshprogram`:
-------------------------

By default nccm will use the ssh program as found in your
path. If you want to explicitly set the path to ssh, or
you want to use a different program - set it here.
This is a global setting that affects all your connections.


`nccm_config_promptuser`:
-------------------------

By default set to `false` and nccm will connect immediately
to the selected server. Set this value to `true`
if you want nccm to prompt the user to press Enter before
a connection is made and once again before returning to
nccm.
If using pre/post connection scripts (disabled by default)
the prompt is shown before the preconnection script is run
and once again after the post connection script is run.


`nccm_config_importnccmd`:
--------------------------

This setting defines whether nccm should try to import any
yml files it finds in /etc/nccm.d/ . Useful in a multiuser
env where each user can have their own nccm.yml as well as
shared connection details files. These filenames should end
in .yml and can only contain connection details without any
program settings.
As files are imported - older data will be updated with
newer data/values.
Note - nccm.yml from one of the supported directories is
loaded first, then `/etc/nccm.d/*.yml` are imported.


`nccm_config_logpath`:
-----------------------
If you want nccm to save a copy of ssh terminal output
using `tee` - set this to the logfile path.
By default this is set to:  `false`  meaning no logging.
If this dir is missing, nccm will log an error and exit -
either fix the logging or disable it. The reasoning is that
you have logging on for a reason (either checking it later
or audit, etc) and it's better to know that logging is not
working now rather than doing your work and later finding
out that you don't have a log file.
The log filename format is:
`{DATE}_{TIME}_{USER}_AT_{SERVER}_{SCREENWIDTH}x{SCREENHEIGHT}.xxxxxx.nccm.log` .
Note - the screen dimensions are those when nccm started
the connection - they might have changed later on during
your session.
The:  `xxxxxx`  are random chars to ensure a unique log
filename (could be needed if nccm is deployed on a jump
host with multiple people using it).
When tee logging is enabled you will see:  `LogTee`  in
red in the help line at the bottom of the screen.
If permitted by:  `nccm_config_logprivateinfo` - the log
file will also include operator info and hostname.
Note - EVERYTHING displayed on the screen is logged (unless
the terminal hides stuff like password entry - because it
is not echoed to the screen). This includes text you edit
and delete - the edits are logged too, cursor movement,
etc - it is all logged. Great for auditing and
troubleshooting, not great for privacy.

To view the resulting file I recommend using `catstep`
which can replay the file slowly and also let you step
through it at your own pace - it is available on my
github page: [https://github.com/flyingrhinonz/catstep](https://github.com/flyingrhinonz/catstep).
You can also use the regular Linux `cat` program but the
output will fly by really fast.


`nccm_config_prompt_on_unknown_user`:
-------------------------------------

If there is no user specified for a connection, instead of
inferring the user from the currently logged in user,
provide a prompt right before connecting to ask for the
user name you would like to log in with.
This can be useful for testing various logins for a
connection or if you do not want to provide usernames in
the server list.
By default set to false and takes the username from the
currently logged in user.


`nccm_loop_nccm`:
-----------------

Run nccm in a loop - when you exit out of your ssh session
nccm menu will reappear. You are allowed to resize your
window outside nccm and the new window size will apply when
nccm menu reappears after you exit from your ssh session.


`nccm_config_preconnect_script`:
--------------------------------

Path to an executable that nccm will run prior to making a
connection. Useful if you want to do anything immediately
before making a connection.
It passes the connection string (eg:  `user@host`) as `$1`
arg to the script.
Supported values: false (default) or any valid path to
an executable script/program.

Example using this to copy:  `kenmode.sh`  to the managed
box - set this value similar to:
`nccm_config_preconnect_script: /usr/local/bin/nccm_copy_kenmode.sh`
and then create the target script similar to:

```
#!/bin/bash

scp /usr/local/lib/kenmode.sh $1:/tmp/
ssh $1 chmod 664 /tmp/kenmode.sh

#rsync --perms --chmod=u+rw,g+rw,o+r /usr/local/lib/kenmode.sh $1:/tmp/
    # ^ Works better than the two-command scp/ssh above, but fails when
    #       rsync not found on the target machine.
    #   Therefore the first option is more reliable.
```

Once connected to the target box you can activate kenmode
by sourcing it:  `. /tmp/kenmode.sh`


`nccm_config_postconnect_script`:
---------------------------------

Same as above but run after the connection exits.
Useful for stuff like tidyups, etc.


`nccm_keybindings`:
-------------------

nccm is configured for US keyboard mapping as entered into
a standard linux xterm. If you have something else and
certain keys don't behave as you'd expect - change their
codes here.
I have experienced putty/kitty sending Home / End / Fn keys
differently to xterm - and other programs may have similar
behavior. You have the option of fixing your terminal
program or modifying the key codes within nccm.
Tip - in putty/kitty you can adjust this here:
Terminal -> Keyboard .
Each of the keyboard codes is a list (even if it contains
only one item), you can map a keypress to as many codes as
you wish by adding more codes to it.
If you want to figure out what code results from a
keypress - run `nccm -d` , press a key and look for:
`Keyboard entry: UserKey == nnn`  in syslog/journal.
You can even map other keys to nccm keys - for example
instead of F1 you want to use F12 - just put the code for
F12 in the F1 key position.



Controls
========

In nccm_config_controlmode == std mode:

- Up/Down arrows:     Move the marker the traditional way
- Home/End:           Jump marker to list first/last entry
- PgUp/PgDn:          Page up/down in the list

In nccm_config_controlmode == focus mode:

- Up/Down arrows:     Scroll the list up/down
- Home/End:           Jump to list first/last entry
- PgUp/PgDn:          Page up/down in the list
- Shift Up/Down:      Move the marker up/down
- Shift Left/Right:   Move the marker to display top/bottom

In both modes:

- Left/Right arrows:  Scroll the list horizontally
- Tab:                Switch between text boxes
- Enter or Ctrl-m:    Connect to the selected entry
- Ctrl-h:             Display this help menu
- Ctrl-k:             Toggle cursor mode std <--> focus
- Ctrl-q or
- Ctrl-c or
- Ctrl-d:             Quit the program
- Ctrl-u:             Clear the current textbox text
- F1-F5 or !@#$% :    Sort by respective column (1-5)



Usage
=====

`Conn` textbox:
---------------

Accepts integer values only (and:  `!@#$%`  for sorting).
Pressing Enter here will connect to this connection ID,
as corresponding to a valid value in the full
unfiltered list (even if that particular connection
is hidden by unmatching text in the `Filter` textbox),
ignoring everything else (Filter textbox, highlighted
line) - even if they don't match.
If this textbox is empty, it will connect to the
connection marked by the highlighted line.


`Filter` textbox:
-----------------

Type any filter text here.
Filtering occurs by searching text present in all visible
columns (does not search in any of the non-visble
settings you made in nccm.yml for example identity or
customargs).
Accepts any printable character and space.
Text is forced to lowercase, and the resulting filtering
is case insensitive.
Pressing Enter will connect to the connection highlighed
in red. This also works if you're in the `Conn` textbox
and it's empty.
In cycle mode  `( nccm_loop_nccm == true )` - the value
of this field is stored for the next cycle.

Textboxes accept backspace to delete one char backwards,
inline editing not supported.

Displayed connection list is filtered by the combined
contents of all the fields as you type in real time.
Spaces delimit filters if typed into `Filter` textbox and
all filter entries are AND'ed.
A count of filtered lines will appear as:  `Hits=n`  in the
help line at the bottom of the screen.



Command line arguments
======================

* Supply initial filtering text. These are considered part
    of the Filter field and are AND'ed. Examples:
      `nccm abc xyz`
      `nccm -d ab cd ef`
    If there is only one match - nccm will connect to it
    immediately.
    In cycle mode  `( nccm_loop_nccm == true )` - these args
    are stored in the  `Filter`  textbox for the next cycle.

* -h  or --help :
    Display the help message.

* -d  or --debug :
    Force debug verbosity logging, ignoring any other
    logging settings everywhere else.

* --logprivateinfo :
    Force nccm to expose private information in syslog
    (secure by default - logs `CENSORED` instead).

* -m  or --man :
    Display the man page.

* -v or --version :
    Display nccm version.



Sorting
=======

`F1-F5` keys sort by the respective fields 1-5.
The display shows 4 visible columms but we treat
username and server address as separate columns for
sorting purposes.
The Fn keys may be captured by certain GUIs or some
terminals send the Fn keys using different codes -
so we have an alternative - when focused on `Conn`
window, press Shift-1 through 5 (`!@#$%`) to toggle
sorting by the respective field number. Pressing the same
key again reverses the sort order. If you type these special
characters in the `Filter` textbox they become standard
filters just like any printable character.

```
Column #  Column name       Sort    Alternate sort
--------  -----------       ----    --------------
1         List serial #     F1      !
2         Friendly name     F2      @
3         User name         F3      #
4         Server address    F4      $
5         Comment           F5      %
```



Help text
=========

From within nccm: use `Ctrl-h` to display the help text.
From the command line: use `nccm -h` or `nccm --help`.
There isn't a man page yet so `man nccm` won't work.



Limitations
===========

Will not store passwords. Please don't request this
feature because it won't be added.
Either use ssh passwordless login (by placing your
public key on the server in `.ssh/authorized_keys` - tip:
look up `ssh-keygen` and `ssh-copy-id`) or store your
password in a password manager and paste it when prompted.

Does not like window resizing and exits gracefully
displaying an error message.
It's safe to resize the window once connection
establishment is in progress or after connected to
your server.
If you run nccm in loop mode and resize your terminal
after a connection is made - nccm will accept your
newly resized terminal when it returns.

Does not support highlighting filter keywords in search
results because this results in a messy and confusing
display once more than a couple keywords are used.
This is not even required because you can use filtering
to narrow down the search results to what you need.

Text entry is limited to the length of the textboxes
which in turn are dictated by the width of your window.
This should be enough for most use cases though.



Troubleshooting
===============

Starting nccm:
--------------

The most common problem for existing installations is
user errors in the nccm.yml file.
Try `yamllint nccm.yml`. If yamllint passes and
nccm still fails: run as `nccm -d` and check syslog for
errors - you may see a message about the connection item
line that fails or at least the last line that succeeded.

The most common problem for new installations is
missing Python3 dependencies or if you didn't copy nccm.yml
into one of the designated locations.
Run nccm and read the exception message - it will tell
you what's missing.

The second most common problem is different nccm and
nccm.yml versions. This usually happens if you download
a newer nccm version and use your existing and older
nccm.yml file although the reverse is true too. The error
will normally be in the `Load` method and the resulting
exception will resemble something like this (the line
number will be different):
`File "/usr/local/bin/nccm", line 481, in Load`.
If this happens, best is to backup your nccm.yml then
download both nccm and nccm.yml, verify that nccm now
works properly, then update the newly downloaded nccm.yml
from your backup copy.


Logging:
--------

Look at your syslog file for nccm entries. Depending upon
the verbosity level set in the config file you may not see
much if at all anything.
By default the production level of the script logs INFO
and above which is not much.
Different syslog implementations have their own tolerance
for line length, and to handle all scenarios - very long
log lines are split into multiple lines, with wrapped
lines being marked with:
`    ....!!LINEWRAPPED!!`.

Increase logging verbosity level to `debug` using the
`-d` or `--debug` command line arguments.
This is by far the easiest way to debug and covers most
scenarios except for faults that occur before the code
actually reads the `-d` command line argument.
Tip - to extract recent logs from the system use:
`journalctl -t "nccm" --since -10min >> /tmp/nccm.log`

To permanently increase logging verbosity change this line
in the `nccm.yml` config file to debug:
`nccm_config_loglevel: debug`
This only comes into effect after the config file has
successfully loaded (does not change the log level for
code that runs before loading the config file).

And to log stuff that happens before the config file is
loaded and before the argument parser sets the debug level,
change this line inside the nccm code:
`LogLevel = logging.DEBUG`
Extra logging controls can be found in the code under the
`Variables that control logging` section.

Also - more debugging calls exist but are commented out in
the code due to too much logging. Enable them as required.

To completely disable logging - uncomment this line:
`logging.disable(level=logging.CRITICAL)`  .

By default nccm protects your privacy and security by
replacing items such as username and hostname with
`CENSORED` in syslog/journal. Supply the argument
`--logprivateinfo` if you wish to expose private
information to these logs.
You can also enable this permanently via the nccm.yml
config file (disabled by default).
Warning - any user who has access to the log files will
be able to see this information.
When this is enabled you will see:  `LogPrv` in red in the
help line at the bottom of the screen.

When you use ssh (either directly from the shell or wrapped
by nccm) - ssh always exits with an exit code. Exit code 0
means normal exit and non zero for other scenarios.
If you're getting messages from nccm saying ssh exited
non-zero - try running ssh directly from the shell and
immediately after it exits type `echo $?` - this will
display the error code. Remember - nccm doesn't cause ssh
to exit non-zero, all it does is expose this fact to the
user.

If you find bugs please update to the latest version of
nccm first (this may include updating your yaml file in
case the format changed). If the bug persists please report
it through the `issues` tab in github.
I use nccm on Linux Mint and RHEL. I use it infrequently
on Fedora too. I can easily fix any bugs that I can
recreate on platforms that I use, but I know that nccm is
used on many other platforms too - not all of which are
pure Linux. If you encounter bugs that I can't recreate -
try posting as much debugging information as possible and
I'll try to help, or one of the users may have the same
platform as you and may be of assistance.
If you encounter and fix any issues - please post your
problem and solution for all to benefit from.



Hacking nccm
============

Take something good and make it better!
The code is heavily commented, with the hope that it will
make life easier for modders and forkers.

The config file is simple yaml. If you already have a
collection of logins elsewhere in an accessible format -
writing a script to convert and append fields to nccm.yml
is easy.



Misc
====

This program aims to do one thing well - lets you make SSH
connections from an ncurses based manager with minimum
distraction. Feature requests that keep nccm on focus will
be considered.



Credits
=======

Big thanks to everyone who reported bugs, submitted feature
requests and improvements that made nccm what it is today.

'''

# Standard library imports:
import argparse
import collections
import curses
import datetime
import getpass
import itertools
import logging
import logging.handlers
import operator
import os
import pathlib
import pydoc
import random
import shlex
import socket
import string
import subprocess
import sys
import textwrap
import time

# Pay attention to these imports:
import yaml         # Requires: 'pip3 install --user PyYAML'
                    #   On a Debian based system you can also do:
                    #       sudo apt-get install python3-yaml
                    #   On Fedora you can do:
                    #       dnf install python3-pyyaml


# Beginning of logging setup section:

# Variables that control logging:
LogLevel = logging.INFO
    # ^ Initial log level of this program.
    #       Edit this if you want to change the initial log level.
    #       Supported levels: DEBUG, INFO, WARNING, ERROR, CRITICAL .
MaxLogLineLength = 700
    # ^ Wrap log lines longer than this many chars.
    #   Keep a sensible and usable limit.
SysLogProgName = 'nccm'
    # ^ This is how our program is identified in syslog.
    #       Use:  journalctl -f -t 'nccm'  to watch its logs.
Indent = 8
    # ^ Wrapped lines are indented by n spaces to make
    #       logging easier to read.
    #   This field is optional.
IndentChar = '.'
    # ^ What sub character to use for indenting.
    #   This field is optional.
EnhancedLogging = True
    # ^ Use the fancy log line splitting (set to True).
    #   This includes forced splitting the supplied text at
    #       any \n newline marks.
    #   Send log line as-is to syslog (set to False) -
    #       you are responsible for line length constraints.
SecureLogging = True
    # ^ LogWrite supports safe logging if called in a particular way.
    #       This allows you to code two versions of your log line and
    #       depending upon this variable - one of the two will be logged.
    #       For example - the 'unsafe' version is used during debugging and
    #       the 'safe' version is used during production.
    #   To use this feature - call LogWrite with a dict:
    #       LogWrite.info( { 'safe': 'This message is CENSORED',
    #           'unsafe': 'Sensitive information here' } )
    #   If SecureLogging == True - the 'safe' value is logged.
    #   If SecureLogging == False - the 'unsafe' value is logged.
    #
    #   If you want simple logging - call LogWrite with a string:
    #       LogWrite.info('This will be logged as is')
    #
    #   You can also supply the dict:  'tee': "text to print"
    #       which saves you writing two lines (one for print and one for logging)
    #       per how you supply the dict (see the examples in the code later on):
    #   LogWrite.info( { 'tee': 'The same line gets printed to display and logged.'} )
    #   LogWrite.info( { 'tee': 'User line to display', 'safe': 'CENSORED line gets logged' } )


# Program identification strings:
__version__     = '1.7.6'
VersionDate     = '2024-04-28'
ProgramName     = 'NCurses ssh Connection Manager'
AuthorName      = 'Kenneth Aaron'
AuthorEmail     = 'flyingrhino AT orcon DOT net DOT nz'
License         = 'GPLv3'


# This block handles logging to syslog:
class CustomHandler(logging.handlers.SysLogHandler):
    ''' Subclass for our custom log handler '''

    def __init__(self):
        super(CustomHandler, self).__init__(address = '/dev/log')
            # ^ Very important to send the address bit to SysLogHandler
            #   else you won't get logging in syslog!


    def emit(self, record):
        ''' Method for returning log lines to SysLogHandler.
            Here is where we split long lines into smaller slices and
            each slice gets logged with the appropriate syslog formatting,
            as well as the identifiers we add that clearly state where
            wrapping occurred.
        '''

        # This block deals with safe/unsafe/ignore LogWrite calls:
        if isinstance(record.msg, str):
            # ^ LogWrite was called with a basic string - ignore the SecureLogging setting
            #       and log the line as is.
            pass

        elif isinstance(record.msg, dict):
            # ^ LogWrite was called with a dict - log either safe/unsafe
            #       per SecureLogging setting.

            if 'tee' in record.msg:
                # ^ Dict key == tee means print the message to screen, and later choose
                #       which version gets logged.
                print(record.msg['tee'])

            if SecureLogging:
                # ^ In:  SecureLogging == True  mode - try to log the:  safe  value if found,
                #       else log the:  tee  value:
                if 'safe' in record.msg:
                    record.msg = record.msg['safe']
                else:
                    record.msg = record.msg['tee']
                    #record.msg = "No safe message was supplied."
                        # ^ At the moment we are logging the:  tee  value if no:  safe  value is supplied,
                        #       but a safer option could be to force log a notification string as in the
                        #       commented out line - you choose which better suits you.

            else:
                # ^ In:  SecureLogging == False  mode - try to log the:  unsafe  value if found,
                #       else log the:  tee  value:
                if 'unsafe' in record.msg:
                    record.msg = record.msg['unsafe']
                else:
                    record.msg = record.msg['tee']

        if EnhancedLogging:
            # ^ We will split the supplied log line (record.msg) into multiple lines.
            #   First - split the message at whatver \n newline chars were supplied
            #   by the caller (even before our own fancy splitting is done):

            RecordMsgSplitNL = record.msg.splitlines()
                # ^ If the log message supplied contains new lines we will split
                #   it at the newline mark - each split logged as a separate line.
                #   The splitlines() method creates RecordMsgSplitNL as a list,
                #   even if there was only one line in the original log message.
                #   Note - lines split because of \n will not get the !!LINEWRAPPED!!
                #   text prepended/appended at the split points.

            SplitLinesMessage = []
                # ^ Final version of line splitting

            for LineLooper in RecordMsgSplitNL:
                if len(LineLooper) < MaxLogLineLength:
                    # ^ Normal line length detected
                    SplitLinesMessage.append(LineLooper)

                else:
                    # ^ Long line detected, need to split
                    TempTextWrapLines = (textwrap.wrap(
                        LineLooper,
                        width=(MaxLogLineLength - 15),
                        subsequent_indent='!!LINEWRAPPED!!',
                        drop_whitespace=False))
                        # ^ If line to log is longer than MaxLogLineLength -
                        #   split it into multiple lines and prepend !!LINEWRAPPED!!
                        #   to the subsequent lines created by the split.

                        # ^ Note - We subtract 15 because we're adding !!LINEWRAPPED!!
                        #   at the end of lines, and we don't want the total length
                        #   of the log line to exceed MaxLogLineLength .

                        # ^ Note - textwrap.wrap doesn't know how to append text
                        #   to wrapped lines, so we must do it manually later.

                        # ^ Note - textwrap.wrap returns a list.

                    #   If we needed to wrap long lines let's append the !!LINEWRAPPED!!
                    #   text to the end of all lines except the last one:
                    if len(TempTextWrapLines) > 1:
                        for Looper in range(len(TempTextWrapLines)-1):
                            TempTextWrapLines[Looper] = ( TempTextWrapLines[Looper] +
                                '!!LINEWRAPPED!!' )

                    SplitLinesMessage.extend(TempTextWrapLines)

            # Finally, return the lines to the class,
            # adding the indent to lines #2 and above if required:
            for Counter, Looper in enumerate(SplitLinesMessage):
                if Counter > 0:
                    Looper = ( ((Indent - 4) * ' ') +
                        (IndentChar * 4) +
                        Looper )
                        # ^ This adds the indent and .... to all subsequent lines
                        #   after the first line - and applies to ALL LINES from
                        #   the second onwards, both for lines split on newline
                        #   and lines split on length!
                        #   Don't be confused if some lines don't have the
                        #   !!LINEWRAPPED!! text in them - there could be \n in
                        #   the string passed, and we made new lines from that.
                record.msg = Looper
                super(CustomHandler, self).emit(record)

        else:
            super(CustomHandler, self).emit(record)
                # ^ Pass it through as-is


#logging.disable(level=logging.CRITICAL)
    # ^ Uncomment this if you want to completely disable logging regardless of any
    #   logging settings made anywhere else.

LogWrite = logging.getLogger(SysLogProgName)
LogWrite.setLevel(LogLevel)
    # ^ Set this to logging.DEBUG or logging.WARNING for your INITIAL desired log level.
    #   Config file (nccm.yml) log level takes over from when it is loaded,
    #   This value controls logging verbosity until then so if you really want
    #       debug level logging throughout your session, use 'nccm -d' .
    #   If you need to see verbose logging BEFORE the nccm.yml is loaded (which
    #       overrides this setting), make it DEBUG here. Well - don't actually edit it
    #       here, but edit it in the earlier section 'Variables that control logging'
    #       where the variable 'LogLevel' is configured.
    #   This will also let you troubleshoot problems that occur in the initial stages
    #       of nccm run - before the nccm.yml file is loaded.

LogWrite.propagate = False
    # ^ Prevents duplicate logging by ancestor loggers (if any)

LogHandler = CustomHandler()
LogWrite.addHandler(LogHandler)

#LogWriteFormatter = logging.Formatter(
#    '{}[%(process)d]: <%(levelname)s> '
#    '(%(asctime)s , PN: %(processName)s , MN: %(module)s , '
#    'FN: %(funcName)s , '
#    'LI: %(lineno)d , TN: %(threadName)s):    '
#    '%(message)s'
#        .format(SysLogProgName))
    # ^ Works but uses comma milliseconds and I prefer dot milliseconds.

LogWriteFormatter = logging.Formatter(
    fmt = '{}[%(process)d]: <%(levelname)s> '
        '(%(asctime)s.%(msecs)03d , PN: %(processName)s , MN: %(module)s , '
        'FN: %(funcName)s , '
        'LI: %(lineno)d , TN: %(threadName)s):    '
        '%(message)s'.format(SysLogProgName),
    datefmt = '%Y-%m-%d %H:%M:%S' )
    # ^ Select the attributes to include in the log lines
    #   Documented here: https://docs.python.org/3/library/logging.html
    #     (LogRecord attributes)
    #
    #   Note: On Python 3.6+ we can get millisecond date using:
    #       datetime.datetime.now().isoformat(sep=' ', timespec='milliseconds')
    #
    #   Note: Any variables passed in the:  format(...)  section are fixed for the
    #       duration of the run - don't use it to pass in a timestamp because it will
    #       not change - ever.
    #   This version gives dot milliseconds rather than the default comma as in
    #       the builtin python function.
    #       See here: https://stackoverflow.com/questions/6290739/python-logging-use-milliseconds-in-time-format

    # Fields explained:
    #   PN: Process Name
    #   MN: Module Name (Also the file name of the first py file that is run
    #       or the name of the symlink that ran it)
    #   FN: Function Name
    #   LI: LIne number
    #   TN: Thread Name
    #
    #       LN: Logger Name (This is the contents of variable: SysLogProgName)
    #           I'm not using it because it's already used in the first {} of:
    #               {}[%(process)d]:

    # Example:
    #   Dec 29 14:35:29 asus303 nccm[31470]: <DEBUG> (2020-12-29 14:35:29.048 ,
    #   PN: MainProcess , MN: cm , FN: SetupWindows , LI: 1268 , TN: MainThread):
    #   ConnectionsList window built

LogHandler.setFormatter(LogWriteFormatter)
LogWrite.debug('nccm started with log level == {} as set by '
    'LogWrite.setLevel (hardcoded in the script)'
        .format(LogWrite.getEffectiveLevel()))
    # ^ Note - this only gets logged if debug level is set in the script
    #   using the LogLevel variable.

# ^ End of logging setup section


# Beginning of global variables section:

class GlobalConfig:
    ''' This is used as a simple global variable storage because GlobalConfig only
        exists in the global scope, all references to it will escalate here. '''

    LogUsingTee = False
        # ^ This var is set after reading the nccm_config_logpath setting
        #   from nccm so that I can check it later in HelpTextDisplay due
        #   to it being globally accessible.
    PreconnectScript = False
        # ^ Run a script prior to connection
    PostconnectScript = False
        # ^ Run a script after disconnection
    PromptToContinue = False
        # ^ Prompt user to press Enter before connecting
    LoopNccm = False
        # ^ Run nccm in a loop after exiting from ssh?
    FilterTextArg = ''
        # ^ Filter text supplied at the command line
    AutoConnect = True
        # ^ If at the first nccm cycle there is only one match to the filter text
        #       nccm will automatically connect to that item. But we must disable
        #       this for subsequent cycles otherwise it will keep on connecting to
        #       that item rather than giving the GUI back to the user.
    ForceDebugLogging = False
        # ^ Force debug logging - will be set properly shortly
    ScreenHeight = ScreenWidth = 0
        # ^ The terminal window dimensions
    DisplayObjects = []
        # ^ A list of window objects that display info on
        #   the screen (no user input)
    TextboxObjects = []
        # ^ A list of editable textbox windows (user input)

    PromptOnUnknownUser = False
        # ^ If a connection does not have a specific user set, do not infer
        #       user from the currently logged in user and instead
        #       request a username before prompting to continue.

    # Setup the supported directories/pathnames for the connections file.
    # If you want to store nccm.yml in a directory that's not already supported
    # or change the filename, do it here:
    ConfigFile = 'nccm.yml'
    ConfigFileIncDir = '/etc/nccm.d/'
        # ^ For use by nccm_config_importnccmd
    ConfigFilePath = []
        # ^ Try to load nccm.yml from these paths whichever is found first
    ConfigFilePath.append(str(pathlib.Path.home()) + '/.config/nccm/' + ConfigFile)
        # ^ ~/.config/nccm/nccm.yml
    ConfigFilePath.append(str(pathlib.Path.home()) + '/.' + ConfigFile)
        # ^ ~/.nccm.yml
    ConfigFilePath.append(str(pathlib.Path.home()) + '/' + ConfigFile)
        # ^ ~/nccm.yml
    ConfigFilePath.append('/etc/' + ConfigFile)
        # ^ /etc/nccm.yml

    # These vars remember the marker position after disconnecting from ssh
    #   so that the marker can be placed at the same position that was
    #   used to make the connection. After returning to nccm the Connections
    #   object is recreated so we need persistent storage for these vars.
    #   They still belong in the Connections object from a logical perspective
    #   so we'll copy them from here when the object is instanciated:
    LoopYOffset = 0
    LoopMarkerLine = 0

    HitCount = 0
        # ^ Number of connections matching the filter text


# If the log level was set to debug in the variable we set earlier -
#   LogLevel = logging.DEBUG - then enforce debug level logging
# regardless of whether the debug switch (-d) was supplied or not,
# and ignore nccm.yml nccm_config_loglevel setting:
if LogWrite.getEffectiveLevel() <= 10:
    GlobalConfig.ForceDebugLogging = True
        # ^ Force debug logging
else:
    GlobalConfig.ForceDebugLogging = False
        # ^ Let future functions determine log level

# ^ End of global variables section


class HelpTextDisplay:
    ''' This manages window objects for help lines '''

    def __init__(self, LinesCount=None, ColsCount=None,
        BeginY=None, BeginX=None):

        LogWrite.debug('class: HelpTextDisplay , method: __init__ started with: '
            'LinesCount == {} , ColsCount == {} , BeginY == {} , BeginX == {}'
                .format(LinesCount, ColsCount, BeginY, BeginX))
        self.Window = curses.newwin(LinesCount, ColsCount, BeginY, BeginX)

        # This is the list of items displayed in the help menu:
        self.HelpMenuItems = collections.OrderedDict((
            ( 'ctrl-h',     'Help' ),
            ( 'ctrl-q',     'Quit' ),
        ))


    def Display(self):

        LogWrite.debug('class: HelpTextDisplay , method: Display started')
        self.Window.erase()

        for Looper in self.HelpMenuItems:
            self.Window.addstr('  '+Looper, curses.color_pair(1))
            self.Window.addstr('-'+self.HelpMenuItems[Looper], curses.color_pair(2))

        # This block shows 'LogPrv' (Logging Private data) in red background
        #   if this option has been enabled:
        if not SecureLogging:
            LogWrite.debug('SecureLogging == False - setting up '
                'to display the red LogPrv in the help window')
            self.Window.addstr('  ', curses.color_pair(1))
            self.Window.addstr('LogPrv', curses.color_pair(5))

        if GlobalConfig.LogUsingTee:
            LogWrite.debug('GlobalConfig.LogUsingTee is True - setting up '
                'to display the white LogTee in the help window')
            self.Window.addstr('  ', curses.color_pair(1))
            self.Window.addstr('LogTee', curses.color_pair(5))

        LogWrite.debug('Setting up Hits == {} (from: GlobalConfig.HitCount) ...'
            .format(GlobalConfig.HitCount))
        self.Window.addstr('  ', curses.color_pair(1))
        self.Window.addstr('Hits={}'.format(GlobalConfig.HitCount), curses.color_pair(6))

        self.Window.noutrefresh()


class BasicTextDisplay:
    ''' This manages window objects to display simple text '''

    def __init__(self, LinesCount=None, ColsCount=None,
        BeginY=None, BeginX=None, Text='', ColorPair=0, Name=False):

        self.Name = Name
        self.Window = curses.newwin(LinesCount, ColsCount, BeginY, BeginX)
        self.Window.addstr(Text, curses.color_pair(ColorPair))


    def Display(self):
        LogWrite.debug('class: BasicTextDisplay, instance: {} - method Display completed'
            .format(self.Name))
        self.Window.noutrefresh()


class ServerXS:
    def __init__(self):
        self.ServerXSNum = 0
        self.FriendlyName = ""
        self.UserName = ""
        self.Address = ""
        self.Port = ""
        self.UserAddr = ""
        self.Comment = ""
        self.KeepAlive = ""
        self.Identity = ""
        self.CustomArgs = []


class Connections:
    ''' This handles the connections list.

        TextFilters are the objects that hold user input (the textboxes) so that we can
            later filter the display with text from them (Display method).
        '''

    def __init__(self, LinesCount=None, ColsCount=None, BeginY=None,
        BeginX=None, ColorPair=0, TextFilters=None, stdscr=None, Name=False):

        LogWrite.debug('class: Connections , method: __init__ started with: '
            'Name == {} , LinesCount == {} , ColsCount == {} , '
            'BeginY == {} , BeginX == {} , ColorPair == {} , '
            'TextFilters == {}, stdscr == {}'
                .format(Name, LinesCount , ColsCount, BeginY, BeginX,
                    ColorPair, TextFilters, stdscr))

        # Window parameters:
        self.Name = Name
        self.SshProgram = 'ssh'     # Path to ssh command - updated from nccm.yml
        self.stdscr = stdscr
        self.ColorPair = ColorPair
        self.TextFilters = TextFilters
        self.MaxTextLines = LinesCount-1
        self.MaxTextWidth = ColsCount-2

        # Storage components:
        self.LoadedServersDict = {}
            # ^ The yaml connections file loaded into a dict. Apart from conversion to
            #   internal formats, this dict won't be used again.
        self.FullServersList = collections.defaultdict(ServerXS)
            # ^ Full list as imported from the loaded object
            #   This is the source list that we reuse during the program.
            #   It is also sorted in place if the user chooses sorting by column.
        self.MaxLenServerXSNum = 0          # Length of the longest serial number field
        self.MaxLenFriendlyName = 0         # Length of the longest friendly name
        self.MaxLenUserAddr = 0             # Length of the longest connection string command
                                            #   (root@server)
        self.MaxLenComment = 0              # Length of the longest comment column

        self.SortOrder = True               # Column sort order (flips between True/False)
        self.YOffset = GlobalConfig.LoopYOffset
            # ^ Y offset for scrolling vertically
        self.XOffset = 0                    # X offset for scrolling horizontally
        self.MaxLineLength = 0
            # ^ Result of formatting the text to fit the max column length
        self.MarkerLine = GlobalConfig.LoopMarkerLine
            # ^ Highlighted line for connection selection
        self.SelectedEntry = None           # The connection number matching MarkerLine
        self.SelectedEntryVal = None        # Connection number value of the highlighted line
        self.DisplayResultsCount = 0        # Number of displayed results after filtering

        self.Window = curses.newwin(LinesCount, ColsCount, BeginY, BeginX)


    def Load(self):
        ''' Loads the connections file '''

        global SecureLogging
        ImportNccmD = False     # Whether to import yml files from /etc/nccm.d/

        for FileLooper in GlobalConfig.ConfigFilePath:
            if pathlib.Path(FileLooper).exists():
                break
        LogWrite.debug('Found config file: FileLooper == {}'.format(FileLooper))

        try:
            with open (FileLooper, 'r') as ConfigFileToRead:
                self.LoadedServersDict = yaml.safe_load(ConfigFileToRead)
                    # ^ yaml.load is not safe
                # ^ Now we have the nccm.yml file loaded into self.LoadedServersDict.
                #   This is both the ssh connections details as well as the
                #   nccm_config_* items - all in the same dict.

        except OSError as Exc:
            # ^ This exception should be matched for all failures. The more generic one is backup.
            curses.endwin()     # De-initialize and return terminal to normal status
            print('\nError loading:  nccm.yml  from any of the following paths:\n{}\n\n'
                'Tip: check if file exists in one of these locations, permissions, selinux context, etc.\n\n'
                'Reraising exception for further debugging info and to initiate screen restore...\n\n'
                .format(GlobalConfig.ConfigFilePath))
            raise Exc

        except:
            curses.endwin()     # De-initialize and return terminal to normal status
            print('Error loading nccm.yml\n\n')
            raise Exc

        LogWrite.debug( {
            'unsafe':   'self.LoadedServersDict (contains prog settings + conn details) == {}'
                .format(self.LoadedServersDict),
            'safe':     'self.LoadedServersDict (contains prog settings + conn details) == {}'
                .format('CENSORED') } )
            # ^ This dump will have program config + connection config details.
            #   Soon we will pop out the program config items.

        # First we extract config items from self.LoadedServersDict
        # to prevent them from being read as conns later on.
        # After this code block self.LoadedServersDict will be left with only
        # the ssh connection details.

        LogWrite.debug('Extracting program configs from self.LoadedServersDict ...')

        # Handle loading of the control mode setting:
        self.ControlMode = self.LoadedServersDict.pop(
            'nccm_config_controlmode', 'std').lower()
                # ^ Controls how arrow keys move the display & the marker.
                #   Options are std, focus
        LogWrite.debug('Loaded {} config item:  self.ControlMode == {}'
            .format(GlobalConfig.ConfigFile, self.ControlMode))

        # Handle loading of the loglevel setting:
        if GlobalConfig.ForceDebugLogging:
            LogWrite.debug('nccm was forced to run in debug mode. '
            'Ignoring nccm_config_loglevel from nccm.yml ...')
            self.LoadedServersDict.pop('nccm_config_loglevel', 'WARNING')
                # ^ Just to remove this setting from the yml config so we won't read
                #   it and override the user's desire for debug logging...

        else:
            LogWrite.setLevel(getattr(logging, self.LoadedServersDict.pop
                ('nccm_config_loglevel', 'WARNING').upper()))
            LogWrite.debug('Loaded {} config item:  log level. Log level now == {}'
                .format(GlobalConfig.ConfigFile, LogWrite.getEffectiveLevel()))
                # ^ Values documented here:
                #   https://docs.python.org/3/library/logging.html#levels

        # Handle loading of the default keepalive value from nccm_config_keepalive:
        self.DefaultKeepAlive = int(self.LoadedServersDict.pop(
            'nccm_config_keepalive', '0'))
                # ^ Controls the keep alive message frequency
        LogWrite.debug('Loaded {} config item:  self.DefaultKeepAlive == {}'
            .format(GlobalConfig.ConfigFile, self.DefaultKeepAlive))

        # Handle loading of the default port value from nccm_default_ssh_port:
        self.DefaultSshPort = int(self.LoadedServersDict.pop(
            'nccm_default_ssh_port', '22'))
                # ^ Forces the default port if none is supplied in the per-connection
                #       setting, and if the ForceDefaultSshPort  directive allows it.
        LogWrite.debug('Loaded {} config item:  self.DefaultSshPort == {}'
            .format(GlobalConfig.ConfigFile, self.DefaultSshPort))

        # Handle loading of the ForceDefaultSshPort value from nccm_force_default_ssh_port.
        #   Works together with the setting:  DefaultSshPort :
        self.ForceDefaultSshPort = self.LoadedServersDict.pop(
            'nccm_force_default_ssh_port', True)
        LogWrite.debug('Loaded {} config item:  self.ForceDefaultSshPort == {}'
            .format(GlobalConfig.ConfigFile, self.ForceDefaultSshPort))

        # Handle loading of the default identity value from nccm_config_identity:
        self.Identity = self.LoadedServersDict.pop(
            'nccm_config_identity', '')
            # This is the -i arg sent to ssh
        LogWrite.debug('Loaded {} config item:  self.Identity == {}'
            .format(GlobalConfig.ConfigFile, self.Identity))

        # Handle loading of the default ssh program from nccm_config_sshprogram:
        self.SshProgram = self.LoadedServersDict.pop(
            'nccm_config_sshprogram', 'ssh')
        LogWrite.debug('Loaded {} config item:  self.SshProgram == {}'
            .format(GlobalConfig.ConfigFile, self.SshProgram))

        # Handle loading of the GlobalConfig.PromptToContinue value from nccm_config_promptuser:
        GlobalConfig.PromptToContinue = self.LoadedServersDict.pop(
            'nccm_config_promptuser', False)
        LogWrite.debug('Loaded {} config item:  GlobalConfig.PromptToContinue == {}'
            .format(GlobalConfig.ConfigFile, GlobalConfig.PromptToContinue))

        # Handle updating the SecureLogging value from nccm_config_logprivateinfo:
        if not SecureLogging:
            # ^ It was forced to insecure logging via command line arg so ignore whatever
            #       setting is present in nccm.yml (user's desire overrides config).
            #   This also triggers if it is forced via nccm.yml and nccm is running
            #       in loop mode and this is not cycle #1 (so the setting was already made).
            LogWrite.warning('SecureLogging (was already) == {}  . '
                'Ignoring nccm.yml config file setting (if at all present)'
                    .format(SecureLogging))
            self.LoadedServersDict.pop('nccm_config_logprivateinfo', False)
                # ^ Need to clear it otherwise it remains there and gets read
                #   as a connection type entry.
        else:
            SecureLogging = not(self.LoadedServersDict.pop(
                'nccm_config_logprivateinfo', False))
                # ^ Need to toggle the true/false nature of it because:
                #       nccm_config_logprivateinfo == True  means:  SecureLogging == False .
                #       To avoid changing settings names (and breaking nccm for many
                #       people, I use the same name but need to reverse the logic).
            LogWrite.debug('Loaded nccm.yml config item:  nccm_config_logprivateinfo  '
                'into:  SecureLogging == {}  '
                '(or defaulted it to:  True  if not found in nccm.yml)'
                    .format(SecureLogging))
                # ^ Note - all lines logged BEFORE this import will still have CENSORED
                #       in them. Use command line arg:  --logprivateinfo  if you want
                #       everything to be logged insecurely.

            if not SecureLogging:
                LogWrite.warning('Attention:  SecureLogging == False  -->  '
                    'stuff like usernames and hostnames will be logged to syslog/journal !')

        # Handle loading of the importnccmd value from nccm_config_importnccmd:
        ImportNccmD = self.LoadedServersDict.pop(
            'nccm_config_importnccmd', False)
        LogWrite.debug('Loaded {} config item:  ImportNccmD == {}'
            .format(GlobalConfig.ConfigFile, ImportNccmD))

        # Handle loading of the GlobalConfig.LoopNccm value from nccm_loop_nccm:
        GlobalConfig.LoopNccm = self.LoadedServersDict.pop(
            'nccm_loop_nccm', False)
        LogWrite.debug('Loaded {} config item:  GlobalConfig.LoopNccm == {}'
            .format(GlobalConfig.ConfigFile, GlobalConfig.LoopNccm))

        # Handle loading of GlobalConfig.PromptOnUnknownUser
        GlobalConfig.PromptOnUnknownUser = self.LoadedServersDict.pop(
            'nccm_config_prompt_on_unknown_user', False)
        LogWrite.debug('Loaded {} config item:  GlobalConfig.PromptOnUnknownUser == {}'
            .format(GlobalConfig.ConfigFile, GlobalConfig.PromptOnUnknownUser))

        # Handle loading of the LogPath value from nccm_config_logpath:
        self.LogPath = self.LoadedServersDict.pop(
            'nccm_config_logpath', False)
        LogWrite.debug('Loaded {} config item:  self.LogPath == {}'
            .format(GlobalConfig.ConfigFile, self.LogPath))

        if self.LogPath:        # If supplied, validate this is a legitimate dir:
            if not pathlib.Path(self.LogPath).is_dir():
                LogWrite.error('nccm_config_logpath == {} not found. '
                    'nccm will not run without logging. Either fix the '
                    'log path or disable logging'.format(self.LogPath))
                raise Exception('Log path configured but actual path not found on filesystem')
                    # ^ If you configured logging - you meant for it to work, and rely
                    #   upon it for later checkups or audit. Therefore if
                    #   it doesn't work it should be fixed or disabled.

        if self.LogPath:
            # ^ If terminal logging path is valid, log window dimensions:
            LogWrite.info('GlobalConfig.ScreenHeight == {} , GlobalConfig.ScreenWidth == {} . '
                'You might need these dimensions if you are replaying this '
                'log with catstep'
                    .format(GlobalConfig.ScreenHeight, GlobalConfig.ScreenWidth))
                # ^ I am logging the window dimensions in case the user needs to
                #   replay the log with catstep and needs to know the minimum
                #   window dimensions. Note - if the user changed window size
                #   later - we won't know of it.

            GlobalConfig.LogUsingTee = True
                # ^ And set this global variable too.

        # Handle loading of the GlobalConfig.PreconnectScript value from nccm_config_preconnect_script:
        GlobalConfig.PreconnectScript = self.LoadedServersDict.pop(
            'nccm_config_preconnect_script', False)
        LogWrite.debug('Loaded {} config item:  GlobalConfig.PreconnectScript == {}'
            .format(GlobalConfig.ConfigFile, GlobalConfig.PreconnectScript))

        if GlobalConfig.PreconnectScript:
            # ^ If supplied, validate this is an executable file
            if not os.access(GlobalConfig.PreconnectScript, os.X_OK):
                LogWrite.error('nccm_config_preconnect_script == {} not found or not executable'
                    .format(GlobalConfig.PreconnectScript))
                raise Exception('Preconnect script not found or not executable. '
                    'Check the value of:  nccm_config_preconnect_script  in nccm.yml')
                    # ^ If you configured this item - you meant for it to work.
                    #       Therefore if it doesn't work it should be fixed or disabled.

        # Handle loading of the GlobalConfig.PostconnectScript value from nccm_config_postconnect_script:
        GlobalConfig.PostconnectScript = self.LoadedServersDict.pop(
            'nccm_config_postconnect_script', False)
        LogWrite.debug('Loaded {} config item:  GlobalConfig.PostconnectScript == {}'
            .format(GlobalConfig.ConfigFile, GlobalConfig.PostconnectScript))

        if GlobalConfig.PostconnectScript:
            # ^ If supplied, validate this is an executable file
            if not os.access(GlobalConfig.PostconnectScript, os.X_OK):
                LogWrite.error('nccm_config_postconnect_script == {} not found or not executable'
                    .format(GlobalConfig.PostconnectScript))
                raise Exception('Postconnect script not found or not executable. '
                    'Check the value of:  nccm_config_postconnect_script  in nccm.yml')
                    # ^ If you configured this item - you meant for it to work.
                    #       Therefore if it doesn't work it should be fixed or disabled.

        # Handle loading of the keyboard bindings:
        self.KeyboardBindings = self.LoadedServersDict.pop(
            'nccm_keybindings', False)
        LogWrite.debug('Loaded {} config item:  self.KeyboardBindings == {}'
            .format(GlobalConfig.ConfigFile, self.KeyboardBindings))

        # Check if self.KeyboardBindings exists. If it's False we can assume that
        # the user is running an older version of nccm.yml and needs to update it.
        # Now that nccm supports keyboard bindings the code expects these to be
        # present and is not backward compatible with older nccm.yml versions:
        if not self.KeyboardBindings:
            LogWrite.error('Cannot find nccm_keybindings: self.KeyboardBindings == {} . '
                'nccm.yml is an older version than supported. '
                'Please update it to latest format.'
                    .format(self.KeyboardBindings))
            raise Exception('Cannot find nccm_keybindings settings. '
                'nccm.yml is an older version than supported. '
                'Please update it to the latest format.')

        # ^ All the config items nccm_config_* have been removed with pop from
        # self.LoadedServersDict , leaving us with only the ssh connection
        # details in it.

        LogWrite.debug('Done extracting program configs from self.LoadedServersDict. '
            'Next we import additional connections from {} if configured ...'
                .format(GlobalConfig.ConfigFileIncDir))

        # If user wants to import /etc/nccm.d/* then do it here:
        if ImportNccmD:
            if pathlib.Path(GlobalConfig.ConfigFileIncDir).exists():
                LogWrite.debug('Path: {} found. Proceeding to import yml files from it ...'
                    .format(GlobalConfig.ConfigFileIncDir))
                NccmdFiles = sorted(pathlib.Path(GlobalConfig.ConfigFileIncDir).glob('*.yml'))
                LogWrite.debug('NccmdFiles == {}'.format(NccmdFiles))

                for NccmdLooper in NccmdFiles:
                    LogWrite.debug('Importing NccmdLooper == {} ...'
                        .format(NccmdLooper))
                    with open (NccmdLooper, 'r') as NccmdFileToRead:
                        self.LoadedServersDict.update(yaml.safe_load(NccmdFileToRead))

            else:
                LogWrite.debug('Path: {} not found. Skipping import feature'
                    .format(GlobalConfig.ConfigFileIncDir))

        else:
            LogWrite.debug('ImportNccmD == {} - not attempting to import connection details '
                'from {}'
                    .format(ImportNccmD, GlobalConfig.ConfigFileIncDir))

        # This code is needed because FriendlyName connections without any
        # config items have a value of None. I need to replace it with {} for
        # the dict lookups to work later on:
        for Looper in self.LoadedServersDict:
            if self.LoadedServersDict[Looper] == None:
                self.LoadedServersDict[Looper] = {}

        LogWrite.debug( {
            'unsafe':   'self.LoadedServersDict (by now it contains only conn details) == {}'
                .format(self.LoadedServersDict),
            'safe':     'self.LoadedServersDict (by now it contains only conn details) == {}'
                .format('CENSORED') } )
            # ^ Now we have all the imported connection details too, and the
            #   program configs are gone because we pop'ed them out earlier.

        LogWrite.debug('Next we load the connections details ...')

        # Populate the FullServersList (list of config items as lists,
        # even unconfigured items):
        try:
            for ServerXSNum, FriendlyName in enumerate(sorted(
                self.LoadedServersDict.keys(), key=str.lower)):
                # whilst ServerXSNum is being used as the dict key here do not
                # assume that it'll be the ServerXSNum beyond this point. Always
                # use FSL.ServerXSNum if you want it.

                ModifyUsernameDisplay = False
                    # ^ Did we recover UserName from login user?
                FSL = self.FullServersList[ServerXSNum] # too much typing. :)
                FSL.ServerXSNum = ServerXSNum
                FSL.FriendlyName = FriendlyName
                LogWrite.debug( {
                    'unsafe':   'Processing FSL == {} , FriendlyName == {} , ServerXSNum == {}'
                        .format(FSL, FriendlyName, ServerXSNum),
                    'safe':     'Processing FSL == {} , FriendlyName == {} , ServerXSNum == {}'
                        .format(FSL, 'CENSORED', ServerXSNum) } )

                # The following supports extracting UserName & Address from FriendlyName
                # if UserName & Address are not supplied:

                # Get FSL.UserName:
                FSL.UserName = self.LoadedServersDict[FriendlyName].get('user', False)
                    # ^ I could merge the "user not present" section below into the
                    #   code above replacing "False", but keeping the code separate
                    #   is clearer, allows debug logging, as well as allows for future
                    #   expansion if a new feature is required.
                    #   Same comment for FSL.Address .

                LogWrite.debug( {
                    'unsafe':   'FSL.UserName == {}'.format(FSL.UserName),
                    'safe':     'FSL.UserName == {}'.format('CENSORED') } )

                # 'user:' not present, extract username from friendly name:
                if not FSL.UserName:
                    LogWrite.debug('FSL.UserName was False. Trying to decode it '
                        'from FriendlyName (user@address) ...')
                    #FSL.UserName = FriendlyName.split(sep='@', maxsplit=1)[0]
                        # ^ Old code, prior to adding support for no username
                    TempSplit = FriendlyName.split(sep='@', maxsplit=1)
                    LogWrite.debug( {
                        'unsafe':   'TempSplit == {}'.format(TempSplit),
                        'safe':     'TempSplit == {}'.format('CENSORED') } )

                    if len(TempSplit) == 2:
                        FSL.UserName = TempSplit[0]
                        LogWrite.debug('FSL.UserName successfully decoded from FriendlyName)')

                    elif len(TempSplit) == 1:

                        if GlobalConfig.PromptOnUnknownUser:
                            # ^ We had to fallback to taking the username from the shell login name.
                            #       At the same time the admin wants to prompt for login name if a
                            #       login name was not explicitly configured in nccm.yml .
                            #       Therefore setup the:  FSL.UserName  as:  <user>  so that at connection
                            #       time we can prompt for the real login username.
                            FSL.UserName = '<user>'

                        else:
                            # ^ Just take the username from the shell login name.
                            FSL.UserName = getpass.getuser()
                            LogWrite.debug('FriendlyName contains only one field - assuming '
                                'this is server Address. '
                                'FSL.UserName taken from currently logged in user name')

                        ModifyUsernameDisplay = True

                    else:
                        LogWrite.warning('Something wrong with config: '
                            'TempSplit == {}'.format(TempSplit))
                            # ^ Probably will crash soon...

                    LogWrite.debug( {
                        'unsafe':   'FSL.UserName == {}'.format(FSL.UserName),
                        'safe':     'FSL.UserName == {}'.format('CENSORED') } )

                # Get FSL.Address:
                FSL.Address = self.LoadedServersDict[FriendlyName].get('address', False)

                LogWrite.debug( {
                    'unsafe':   'FSL.Address == {}'.format(FSL.Address),
                    'safe':     'FSL.Address == {}'.format('CENSORED') } )

                # 'address:' not present, extract address from friendly name:
                if not FSL.Address:
                    LogWrite.debug('FSL.Address was False. Trying to decode it '
                        'from FriendlyName (user@address) ...')
                    FSL.Address = FriendlyName.split(sep='@', maxsplit=1)[-1]
                        # ^ Assuming the last element is always the address component.
                        #   When user@ is not supplied this will work too - because
                        #   we will have a list length of 1.

                    LogWrite.debug( {
                        'unsafe':   'FSL.Address == {}'.format(FSL.Address),
                        'safe':     'FSL.Address == {}'.format('CENSORED') } )

                # Get server port if supplied:
                FSL.Port = self.LoadedServersDict[FriendlyName].get('port', False)
                LogWrite.debug( {
                    'unsafe':   'FSL.Port == {}'.format(FSL.Port),
                    'safe':     'FSL.Port == {}'.format('CENSORED') } )

                if not FSL.Port and self.ForceDefaultSshPort:
                    FSL.Port = self.DefaultSshPort
                        # ^ No port supplied, fallback to using default port per var.
                        #   Only do this if the:  nccm.yaml  var:  nccm_force_default_ssh_port == true .
                    LogWrite.debug( {
                        'unsafe':   'No specific port supplied, forcing default port {}'
                            .format(self.DefaultSshPort),
                        'safe':     'CENSORED, not logging port info' } )

                FSL.UserAddr = '{}@{}'.format(FSL.UserName, FSL.Address) # for convenience
                FSL.Comment = self.LoadedServersDict[FriendlyName].get('comment', '')
                    # ^ .get because item my be empty

                # If we recovered the ssh login user name from the currently logged in
                # username - add this text to the comment so the user will know where
                # their user name is coming from:
                if ModifyUsernameDisplay:
                    if FSL.Comment:
                        FSL.Comment += ' '
                            # ^ Pretty formtting, nothing else...

                    if GlobalConfig.PromptOnUnknownUser:
                        FSL.Comment += '[prompt for <user> at login time]'
                    else:
                        FSL.Comment += '[{}@ taken from login user]'.format(FSL.UserName)

                FSL.KeepAlive = self.LoadedServersDict[FriendlyName].get(
                    'keepalive', self.DefaultKeepAlive)
                        # ^ If custom keepalive supplied use that,
                        #   else use the default value

                # Individually configured 'identity' values per connection
                # will be respected and Identity will become '-i <value>' .
                # Otherwise if the value of nccm_config_identity in nccm.yml
                # is configured then we assume it's a path to the new
                # default key and Identity will take it from there (prepending
                # the '-i ' part to it.
                # Otherwise it should be set to '' which will have no effect
                # upon the ssh command line:
                FSL.Identity = self.LoadedServersDict[FriendlyName].get(
                    'identity', self.Identity)
                    # ^ Note that per-connection "identity" may not be supplied
                    #   and also the default nccm_config_identity may be False.

                FSL.CustomArgs = self.LoadedServersDict[FriendlyName].get(
                    'customargs', '')
                if isinstance(FSL.CustomArgs, str):
                    FSL.CustomArgs = shlex.split(FSL.CustomArgs)

                LogWrite.debug( {
                    'unsafe':   'ServerXSNum == {} , FriendlyName == {} , UserName == {} , '
                        'Address == {} , UserAddr == {} , Comment == {} , KeepAlive == {} , '
                        'Identity == {} , CustomArgs == {}'
                            .format(FSL.ServerXSNum, FSL.FriendlyName,
                                FSL.UserName, FSL.Address, FSL.UserAddr,
                                FSL.Comment,
                                FSL.KeepAlive, FSL.Identity, ' '.join(FSL.CustomArgs)),
                    'safe':     'ServerXSNum == {} , FriendlyName == {} , UserName == {} , '
                        'Address == {} , UserAddr == {} , Comment == {} , KeepAlive == {} , '
                        'Identity == {} , CustomArgs == {}'
                            .format(FSL.ServerXSNum, 'CENSORED',
                                'CENSORED', 'CENSORED', 'CENSORED',
                                'CENSORED',
                                FSL.KeepAlive, 'CENSORED', ' '.join(FSL.CustomArgs)) } )

                GlobalConfig.HitCount = ServerXSNum + 1
                    # ^ Enumerate starts at 0, so our hit count in human terms will be +1

            LogWrite.debug('Completed loading all connections successfully. GlobalConfig.HitCount == {}'
                .format(GlobalConfig.HitCount))

        except Exception as Exc:
            LogWrite.error('Error loading connection. ServerXSNum == {} , FriendlyName == {}'
                .format(ServerXSNum, FriendlyName))
            raise Exc

        LogWrite.debug('Setting up the HelpBox next ...')
        # Since we loaded SecureLogging from nccm.yml (or got it earlier
        #   from the command line arg:  --logprivateinfo)  and also the
        #   nccm_config_logpath setting, we need to build
        #   the help text display window and include the red 'LogPrv' in it.
        # In earier nccm versions before we had SecureLogging - this window
        #   was built along with the other windows in the function SetupWindows,
        #   but we can't built it so early anymore :
        # I moved this block down here because of the recently added GlobalConfig.HitCount .
        HelpBox = HelpTextDisplay(LinesCount=1, ColsCount=GlobalConfig.ScreenWidth,
            BeginY=GlobalConfig.ScreenHeight-1, BeginX=0)
        GlobalConfig.DisplayObjects.append(HelpBox)
            # ^ Add this window to the displayable windows
        LogWrite.debug('HelpBox window built (the line that displays help text)')

        # Calculate the max length of each column:
        for Column in qw('ServerXSNum FriendlyName UserAddr Comment'):
            setattr(self, 'MaxLen{}'.format(Column),
                max(len(str(getattr(i, Column)))
                    for i in self.FullServersList.values()))


    def Sort(self, Column):
        ''' Sorts the internal-use FullServersList list per the requested column '''

        LogWrite.debug('Sort by Column = {}'.format(Column))

        self.FullServersList = dict(enumerate(sorted(
            self.FullServersList.values(),
            key=operator.attrgetter(Column),
            reverse=self.SortOrder)))

        self.SortOrder = not(self.SortOrder)
            # ^ Reverse True to False, False to True so that next time this function
            #   is called the sort will be reversed.


    def Save(self):
        ''' Saves the connections file '''
        # Nothing here. Might add save feature if I allow user to add connection details


    def GetCommandLine(self, ServerXSNum):
        ''' Get link to connect to '''

        ServerXSNum = int(ServerXSNum)
        PromptedUser = ''

        # Get the item line of the connection we want from the full connections list.
        # (This is the unmodified original list as it was loaded):
        for Server in self.FullServersList.values():
            if Server.ServerXSNum == ServerXSNum:
                SelectedEntry = Server
                break

        LogWrite.debug('SelectedEntry = {}'.format(SelectedEntry))

        # Remember that the connection line is a list of items.
        # Pull the correct items out of
        # the list to build the ssh connection line:

        CommandLine = []
            # ^ This is the command line that we use to make the ssh connection.

        CommandInfo = {}
            # This dict contains the above CommandLine list as well as other useful
            #   information we can pass on to scripts.

        CommandLine += (self.SshProgram, )

        if '<user>@' in SelectedEntry.UserAddr:
            PromptedUser = PromptForUserLogin("Enter username to use for <username>@server : ")
            #CommandLine += ('{}@{}'.format(PromptedUser, SelectedEntry.Address), )
            CommandLine += (SelectedEntry.UserAddr.replace('<user>', PromptedUser), )
        else:
            CommandLine += (SelectedEntry.UserAddr, )

        if SelectedEntry.Port:
            # ^ This works regardless of the value of:  nccm.yaml  -->  nccm_force_default_ssh_port
            CommandLine += ('-p', '{}'.format(SelectedEntry.Port))

        if SelectedEntry.KeepAlive:
            CommandLine += ('-o', 'ServerAliveInterval={}'
                .format(SelectedEntry.KeepAlive))

        if SelectedEntry.Identity:
            CommandLine += ('-i', SelectedEntry.Identity)

        if SelectedEntry.CustomArgs:
            CommandLine += SelectedEntry.CustomArgs

        if self.LogPath:    # Log file path is set:
            self.LogFileName = pathlib.PurePath(self.LogPath).joinpath('{}_{}_{}x{}.{}{}'
                .format(datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S'),
                    SelectedEntry.UserAddr.replace('@', '_AT_').replace('<user>', PromptedUser),
                    GlobalConfig.ScreenWidth,
                    GlobalConfig.ScreenHeight,
                    ''.join(random.choice(string.ascii_lowercase) for i in range(6)),
                    '.nccm.log'))

            LogWrite.debug( {
                'unsafe':   'self.LogFileName == {} , of type: {}'
                    .format(self.LogFileName, type(self.LogFileName)),
                'safe':     'self.LogFileName == {} , of type: {}'
                    .format('CENSORED', type(self.LogFileName)) } )

            CommandLine += ('|', 'tee', '--append', str(self.LogFileName))
            self.WriteLogFileHeader()   # Write info intro to the log file

        CommandLine = ' '.join(CommandLine)
        # ^ CommandLine was a list. Let's make it a string. Now looks like:
        # ssh user@192.168.1.1 -o ServerAliveInterval=40 | tee /logs/2021-03-07_16:45:18_user_AT_192.168.1.1

        LogWrite.debug( {
            'unsafe':   'ServerXSNum (this is the connection list '
                'line number) == {} , CommandLine == {}'
                    .format(ServerXSNum, CommandLine),
            'safe': 'ServerXSNum (this is the connection list '
                'line number) == {} , CommandLine == {}'
                    .format(ServerXSNum, 'CENSORED') } )

        CommandInfo['CommandLine'] = CommandLine
        CommandInfo['UserAddr'] = SelectedEntry.UserAddr
        CommandInfo['Identity'] = SelectedEntry.Identity
        return CommandInfo


    def WriteLogFileHeader(self):
        ''' Write a few lines to the beginning of self.LogFileName
            to identify it as created by nccm.
            Unsuspecting users might open this log file with 'less' or 'vim' and see
            a lot of unreadable chars; these info lines inform them how to view
            the log file properly.
        '''

        LogWrite.debug('class: Connections, method: WriteLogFileHeader started')

        if SecureLogging:
            RunningUsername = 'CENSORED'
            RunningUID = 'CENSORED'
            RunningHostname = 'CENSORED'
        else:
            RunningUsername = getpass.getuser()
            RunningUID = os.getuid()
            RunningHostname = socket.getfqdn()

        IntroText = ( '\n##### Info #####\n\n'
            'This log file was created by nccm:\n'
            '  {} v{} , {}\n'
            '  by: {} ( {} )\n'
            '  https://github.com/flyingrhinonz/nccm\n\n'
            'This log file is best viewed using catstep:\n'
            '  https://github.com/flyingrhinonz/catstep\n\n'
            'Operator identifiers (details of person using nccm):\n'
            '  Name:    {}\n'
            '  UID:     {}\n'
            '  From:    {}\n'
            '  At:      {}\n\n'
            '##### Your connection log follows #####\n'
                .format(ProgramName, __version__, VersionDate, AuthorName, AuthorEmail, RunningUsername,
                    RunningUID, RunningHostname, datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')))

        LogFile = pathlib.Path(self.LogFileName)
        LogFile.write_text(IntroText)


    def Display(self):

        LogWrite.debug('class: Connections, method: Display started')

        # Check if the window was resized since it was started:
        NewScreenHeight, NewScreenWidth = self.stdscr.getmaxyx()
        LogWrite.debug('class Connections, method: Display: '
            'NewScreenHeight == {} , NewScreenWidth == {} '
                .format(NewScreenHeight, NewScreenWidth))

        if NewScreenHeight != GlobalConfig.ScreenHeight or NewScreenWidth != GlobalConfig.ScreenWidth:
            LogWrite.warning( { 'tee': '\nnccm: Resizing the window during menu display is not supported.\nYou may resize the window during or after connection initiation.\nIf nccm returns after you disconnect - it will use the new window size.\n' } )
            sys.exit(1)

        FilterTextList = set()          # Unique text strings for filtering.
        self.ResultsList = []           # This is the full list of lines that
                                        #   matched filtering of the text entered by
                                        #   the user.

        self.Window.erase()

        # Merge the users text input into a set whose uniqueness
        # improves search performance:
        for Looper in self.TextFilters:
            LogWrite.debug( {
                'unsafe':   'Looper.Text == {}'.format(Looper.Text),
                'safe':     'Looper.Text == {}'.format('CENSORED') } )
                    # ^ Remember that the object has a self.Text attribute so
                    #   we need to read the filter text from self.Text .
            FilterTextList.update(set(Looper.Text.split(' ')))

        LogWrite.debug( {
            'unsafe':   'FilterTextList == {}'.format(FilterTextList),
            'safe':     'FilterTextList == {}'.format('CENSORED') } )
                # ^ Now the combined text fields from both objects stored in
                #   self.TextFilters .

        # Populate self.ResultsList with all line that match the above filter:
        for FSL in self.FullServersList.values():
            Line = ('{:<{ServerXSNumWidth}}    {:<{FriendlyNameWidth}}    '
                '{:<{UserAddrWidth}}    {:<{CommentWidth}}'
                 .format(
                    FSL.ServerXSNum,
                    FSL.FriendlyName,
                    FSL.UserAddr,
                    FSL.Comment,
                    ServerXSNumWidth = self.MaxLenServerXSNum,
                    FriendlyNameWidth = self.MaxLenFriendlyName,
                    UserAddrWidth = self.MaxLenUserAddr,
                    CommentWidth = self.MaxLenComment))

            if len(Line) > self.MaxLineLength:
                self.MaxLineLength = len(Line)

            # Here's the actual search where we check if each of the filter
            # fields are IN the Line we got from above:
            if sorted(FilterTextList) == sorted(
                x for x in FilterTextList if x in Line.lower()):
                    self.ResultsList.append(Line)

                    LogWrite.debug( {
                        'unsafe':   'Added Line == "{}" to self.ResultsList'
                            .format(Line),
                        'safe': 'Added Line == "{}" to self.ResultsList'
                            .format('CENSORED') } )

        self.ResultsList = [x.ljust(self.MaxLineLength, ' ') for x in self.ResultsList]
        self.DisplayResultsCount = 0

        if self.MarkerLine > (len(self.ResultsList)-1):
            self.MarkerLine = (len(self.ResultsList)-1)
        if self.MarkerLine < 0:
            self.MarkerLine = 0
        self.SelectedEntry = None

        for LineNumber, Looper in enumerate(
            self.ResultsList[self.YOffset:(self.YOffset+self.MaxTextLines)]):
            self.DisplayResultsCount += 1   # Number of lines displayed
            PaddedString = Looper[self.XOffset:(self.XOffset+self.MaxTextWidth)]

            if LineNumber == self.MarkerLine:
                self.Window.addstr(PaddedString, curses.color_pair(4))
                self.SelectedEntry = Looper
                self.SelectedEntryVal = int(self.SelectedEntry.lstrip().split()[0])
            else:
                self.Window.addstr(PaddedString)

            self.Window.addstr('\n')

        self.Window.noutrefresh()
        GlobalConfig.HitCount = len(self.ResultsList)
        LogWrite.debug('class: Connections, instance: {} method Display completed. '
            'Number of results (GlobalConfig.HitCount) == {} , number of displayed results == {}'
            .format(self.Name, GlobalConfig.HitCount, self.DisplayResultsCount))


    def LogPositions(self, Comment=None):
        ''' Logs marker positioning to follow the highlighted line '''

        LogWrite.debug( {
            'unsafe':   'Comment == {} , self.Name = {} , self.YOffset == {} , self.XOffset == {} , '
                        'self.MarkerLine == {} , self.SelectedEntryVal == {} , '
                        'self.DisplayResultsCount == {} , self.SelectedEntry == "{}"'
                            .format( Comment, self.Name, self.YOffset, self.XOffset, self.MarkerLine,
                                self.SelectedEntryVal, self.DisplayResultsCount, self.SelectedEntry ),
            'safe':     'Comment == {} , self.Name = {} , self.YOffset == {} , self.XOffset == {} , '
                        'self.MarkerLine == {} , self.SelectedEntryVal == {} , '
                        'self.DisplayResultsCount == {} , self.SelectedEntry == CENSORED'
                            .format( Comment, self.Name, self.YOffset, self.XOffset, self.MarkerLine,
                                self.SelectedEntryVal, self.DisplayResultsCount ) } )


class TextBox:
    ''' This manages objects that handle text input '''

    def __init__(self, LinesCount=None, ColsCount=None,
        BeginY=None, BeginX=None, ColorPair=0, FilterText='',
        Name=False):

        LogWrite.debug( {
            'unsafe':   'class TextBox, method __init__ started with: '
                'Name == {} , LinesCount == {} , ColsCount == {} BeginY == {} , '
                'BeginX == {} , ColorPair == {} , FilterText == {}'
                    .format(Name, LinesCount, ColsCount, BeginY, BeginX,
                        ColorPair, FilterText),
            'safe':     'class TextBox, method __init__ started with: '
                'Name == {} , LinesCount == {} , ColsCount == {} BeginY == {} , '
                'BeginX == {} , ColorPair == {} , FilterText == CENSORED'
                    .format(Name, LinesCount, ColsCount, BeginY, BeginX,
                        ColorPair) } )

        self.Name = Name
        self.Text = FilterText
            # ^ This is the text that the user enters.
            #   We pass in FilterText in case we need to load it from
            #   the command line --filter arg
        self.MaxText = ColsCount-1
        self.Window = curses.newwin(LinesCount, ColsCount, BeginY, BeginX)
        self.Window.keypad(True)
            # ^ Get special keys support for this window.
            #   Calling 'stdscr.keypad(True)' is different and doesn't reflect
            #   in windows - it must be called separately.
            #   Very important - otherwise stuff like getch return stuff like arrow keys
            #   in multiple bytes (one per call) and it's screwed up.
        self.Window.bkgd(' ', curses.color_pair(ColorPair))
        self.Window.addstr(0, 0, self.Text)


    def Display(self):
        self.Window.noutrefresh()
        LogWrite.debug('class: TextBox, instance: {} - method Display completed'
            .format(self.Name))


    def ZeroCursor(self):
        ''' Position the cursor at 0, x in this window.
            Call with:
                ConnBox.ZeroCursor()
            Takes into account the length of self.Text in case it was preloaded
            from the command line --filter arg.
        '''

        self.Window.move(0, len(self.Text))
            # ^ Position cursor at 0, x (axes are: y,x)


    def ActivateWindow(self):
        ''' Brings this window to the front.
            Call with:
                ConnBox.ActivateWindow()
        '''

        LogWrite.debug('class: TextBox, method: ActivateWindow started')
        self.Window.noutrefresh()
        curses.doupdate()
        LogWrite.debug('Active window is now: {} , {}'
            .format(self.Name, self))


    def ReadKey(self):
        UserKey = self.Window.getch()    # Read a key and return a code
        return UserKey


    def ProcessKey(self, Key, KeyboardBindings):

        ''' We're receiving ConnectionsList.KeyboardBindings as KeyboardBindings
                so that we can use the keyboard codes from:  nccm.yml  instead
                of hardcoding values here.
            Note - if key codes are missing this function will log the entry
                point debug message and crash on the line that's missing the
                code (will not appear in the logs but will show the crash line
                in the on-screen message). See issue #11 for an example of this.
        '''

        LogWrite.debug('class: TextBox, method: ProcessKey started')
        #LogWrite.debug('self == {} , Key == {} , KeyboardBindings == {}'
        #    .format(self, Key, KeyboardBindings))
            # ^ Too much logging

        if Key == curses.KEY_BACKSPACE or Key in KeyboardBindings['nccm_key_backspace']:
            # ^ Backspace key
            #LogWrite.debug('Process backspace key...')
                # ^ Too much logging
            if len(self.Text) > 0:
                self.Text = self.Text[:-1]

        elif Key in KeyboardBindings['nccm_key_ctrl_u']:
            # ^ Clear textbox text
            #   Note - crashes nccm if the newly added ctrl-u feature key code
            #       is missing in nccm.yml . See issue #11 for details.
            #LogWrite.debug('Process ctrl-u key...')
                # ^ Too much logging
            self.Text = ''

        elif len(self.Text) < self.MaxText:
            #LogWrite.debug('Process printable key...')
                # ^ Too much logging
            self.Text += chr(Key).lower()
                # ^ Force to lower and search is case insensitive

        if self.Name == 'FilterBox':
            #LogWrite.debug('We have:  self.Name == FilterBox  so copy:  self.Text  to:  '
            #    'GlobalConfig.FilterTextArg  for the next cycle...')
                # ^ Too much logging
            GlobalConfig.FilterTextArg = self.Text
                # ^ Keep the globally accessible variable up to date with the filter text
                #       so that the filter field can be populated at next cycle.
                #       But only for text supplied in FilterBox (not ConnBox).

        LogWrite.debug( {
            'unsafe':   'self.Name == {} , self.Text == "{}" , GlobalConfig.FilterTextArg == "{}"'
                .format(self.Name, self.Text, GlobalConfig.FilterTextArg),
            'safe':     'self.Name == {} , self.Text == "{}" , GlobalConfig.FilterTextArg == "{}"'
                .format(self.Name, 'CENSORED', 'CENSORED') } )
        self.Window.clear()
        self.Window.addstr(0, 0, self.Text)


def LogGlobalConfig():
    ''' Log all the GlobalConfig vars for debugging purposes '''

    LogWrite.debug( {
        'unsafe':   'All variables of class GlobalConfig:\n'
            'GlobalConfig.LogUsingTee == {}\n'
            'GlobalConfig.PreconnectScript == {}\n'
            'GlobalConfig.PostconnectScript == {}\n'
            'GlobalConfig.PromptToContinue == {}\n'
            'GlobalConfig.PromptOnUnknownUser == {}\n'
            'GlobalConfig.LoopNccm == {}\n'
            'GlobalConfig.FilterTextArg == {}\n'
            'GlobalConfig.AutoConnect == {}\n'
            'GlobalConfig.ForceDebugLogging == {}\n'
            'GlobalConfig.ScreenHeight == {}\n'
            'GlobalConfig.ScreenWidth == {}\n'
            'GlobalConfig.DisplayObjects: total == {} , details == {}\n'
            'GlobalConfig.TextboxObjects: total == {} , details == {}\n'
            'GlobalConfig.LoopYOffset == {}\n'
            'GlobalConfig.LoopMarkerLine == {}\n'
            'GlobalConfig.HitCount == {}\n'
                .format(
                    GlobalConfig.LogUsingTee,
                    GlobalConfig.PreconnectScript,
                    GlobalConfig.PostconnectScript,
                    GlobalConfig.PromptToContinue,
                    GlobalConfig.PromptOnUnknownUser,
                    GlobalConfig.LoopNccm,
                    GlobalConfig.FilterTextArg,
                    GlobalConfig.AutoConnect,
                    GlobalConfig.ForceDebugLogging,
                    GlobalConfig.ScreenHeight,
                    GlobalConfig.ScreenWidth,
                    len(GlobalConfig.DisplayObjects), GlobalConfig.DisplayObjects,
                    len(GlobalConfig.TextboxObjects), GlobalConfig.TextboxObjects,
                    GlobalConfig.LoopYOffset,
                    GlobalConfig.LoopMarkerLine,
                    GlobalConfig.HitCount),
        'safe':   'All variables of class GlobalConfig:\n'
            'GlobalConfig.LogUsingTee == {}\n'
            'GlobalConfig.PreconnectScript == {}\n'
            'GlobalConfig.PostconnectScript == {}\n'
            'GlobalConfig.PromptToContinue == {}\n'
            'GlobalConfig.PromptOnUnknownUser == {}\n'
            'GlobalConfig.LoopNccm == {}\n'
            'GlobalConfig.FilterTextArg == {}\n'
            'GlobalConfig.AutoConnect == {}\n'
            'GlobalConfig.ForceDebugLogging == {}\n'
            'GlobalConfig.ScreenHeight == {}\n'
            'GlobalConfig.ScreenWidth == {}\n'
            'GlobalConfig.DisplayObjects: total == {} , details == {}\n'
            'GlobalConfig.TextboxObjects: total == {} , details == {}\n'
            'GlobalConfig.LoopYOffset == {}\n'
            'GlobalConfig.LoopMarkerLine == {}\n'
            'GlobalConfig.HitCount == {}\n'
                .format(
                    GlobalConfig.LogUsingTee,
                    GlobalConfig.PreconnectScript,
                    GlobalConfig.PostconnectScript,
                    GlobalConfig.PromptToContinue,
                    GlobalConfig.PromptOnUnknownUser,
                    GlobalConfig.LoopNccm,
                    'CENSORED',
                    'CENSORED',
                    GlobalConfig.AutoConnect,
                    GlobalConfig.ForceDebugLogging,
                    GlobalConfig.ScreenHeight,
                    GlobalConfig.ScreenWidth,
                    len(GlobalConfig.DisplayObjects), GlobalConfig.DisplayObjects,
                    len(GlobalConfig.TextboxObjects), GlobalConfig.TextboxObjects,
                    GlobalConfig.LoopYOffset,
                    GlobalConfig.LoopMarkerLine,
                    GlobalConfig.HitCount ) } )


def qw(liststr: str):
    ''' analogous to Perl's qw() - split on whitespace and return list '''

    if liststr is None:
        return None
    else:
        return liststr.split()


def PressEnterToCont(PromptText):
    ''' A simple wait until Enter key pressed '''

    if GlobalConfig.PromptToContinue:
        LogWrite.debug('Waiting for user to press Enter...')

        while True:
            try:
                Ans = input(PromptText)
                break
            except EOFError as Exception:
                LogWrite.debug('CTRL-D ignored')
                print('\n')
    else:
        LogWrite.debug('Not waiting for user to press Enter. Returning processing to program now')

    return


def PromptForUserLogin(PromptText):
    ''' A simple prompt asking for login information from the user. '''

    if GlobalConfig.PromptOnUnknownUser:
        LogWrite.debug('Waiting for user to enter login information and press Enter...')

    Ans = None
    while True:
        try:
            Ans = input(PromptText)
            if Ans != '':
                break
            else:
                print("No username was detected, please retry...")
        except EOFError as Exception:
            LogWrite.debug('CTRL-D ignored')
            print('\n')
    return Ans.rstrip()


def UpdateDisplay():
    ''' The window objects were added to GlobalConfig.DisplayObjects list,
        so here we need to use the Display method of those objects. '''

    LogWrite.debug('Function UpdateDisplay started')

    # The next two blocks draw the various window objects. Note - we must display the
    #   help menu (HelpTextDisplay) last because the:  HitCount  var is set by
    #   class: Connections, method: Display  - which is also present in the loop.
    #   Therefore we need to ensure that:  HitCount  is set before the help menu
    #   is displayed:
    for Looper in GlobalConfig.DisplayObjects:
        if 'HelpTextDisplay' in str(Looper):
            LogWrite.debug('Skipping Looper == {} . Redrawing of:  HelpTextDisplay  must be done last'.format(Looper))
            continue
        LogWrite.debug('Calling Looper == {} Display method ...'.format(Looper))
        Looper.Display()

    for Looper in GlobalConfig.DisplayObjects:
        if 'HelpTextDisplay' in str(Looper):
            LogWrite.debug('Calling Looper == {} Display method ...'.format(Looper))
            Looper.Display()

    LogWrite.debug('Calling curses update screen (actually draw the changes we wrote '
        'to the window buffers) ...')
    curses.doupdate()
        # ^ Speed up display and avoid flickering by using 'window.noutrefresh'

    LogWrite.debug('Function UpdateDisplay ended')


def SetupCurses(stdscr):
    ''' Put all pre-run tests here. Any failure will exit the program. '''

    LogWrite.debug('Function SetupCurses started')
    curses.curs_set(1)          # Enable the blinking cursor
    curses.noecho()             # Disable echo to the terminal
    stdscr.keypad(True)
        # ^ Get special keys support in stdscr. Per window setting also required
    curses.raw()                # Disable control codes

    # Set up the vars for use later by:  curses.color_pair :
    curses.init_pair(1, curses.COLOR_YELLOW, curses.COLOR_BLACK)
        # ^ Used by the help menu shortcut key display
    curses.init_pair(2, curses.COLOR_CYAN, curses.COLOR_BLACK)
        # ^ Used by the help menu shortcut key help test
    curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_BLUE)
        # Used by the text box as supplied to:  class TextBox
    curses.init_pair(4, curses.COLOR_WHITE, curses.COLOR_RED)
        # ^ Used by the marker line controlled by the arrows
    curses.init_pair(5, curses.COLOR_WHITE, curses.COLOR_RED)
        # ^ Used for setting alerts such as 'LogPrv'
    curses.init_pair(6, curses.COLOR_WHITE, curses.COLOR_BLACK)
        # ^ Used for setting alerts such as 'LogTee'

    if not curses.has_colors(): # Color not supported
        LogWrite.warning( { 'tee': '\nnccm: This program requires color support\n' } )
        return False

    GlobalConfig.ScreenHeight, GlobalConfig.ScreenWidth = stdscr.getmaxyx()
    LogWrite.debug('GlobalConfig.ScreenHeight == {} , GlobalConfig.ScreenWidth == {}'
        .format(GlobalConfig.ScreenHeight, GlobalConfig.ScreenWidth))

    if GlobalConfig.ScreenWidth < 60 or GlobalConfig.ScreenHeight < 15:
        LogWrite.warning( { 'tee': '\nnccm: Window must be at least 60x15. GlobalConfig.ScreenHeight == {} , GlobalConfig.ScreenWidth == {}\n'
            .format(GlobalConfig.ScreenHeight, GlobalConfig.ScreenWidth) } )
        return False

    if len(GlobalConfig.FilterTextArg) > GlobalConfig.ScreenWidth-32:
        LogWrite.warning( { 'tee': '\nnccm: Filter text too long ({} chars). For your window size enter no more than {} chars\n'
            .format(len(GlobalConfig.FilterTextArg), GlobalConfig.ScreenWidth-32) } )
        return False

    LogWrite.debug('Function SetupCurses should have completed successfully')


def SetupWindows(stdscr):
    ''' Create the various windows '''

    LogWrite.debug('Function SetupWindows started - create the various windows')

    GlobalConfig.DisplayObjects = []
        # ^ Need to reset this otherwise we'll end up with multiple windows
        #   being drawn due to the line GlobalConfig.DisplayObjects.append(...)
        #   You will see the bad result of this if you run nccm in a loop and
        #   make the screen smaller after connection establishment.
        #   It's visible in function LogGlobalConfig where the
        #   GlobalConfig.DisplayObjects list keeps growing.

    GlobalConfig.TextboxObjects = []
        # ^ Same comment as above in regards to the list needing to be reset...

    ## Help text display window:
    ##   This window is special - it contains status of stuff like
    ##   logging private data and at this stage we don't know if this setting
    ##   was loaded from the config file. In earlier nccm we setup this
    ##   window here, but since we now support SecureLogging we will setup
    ##   this window after we load nccm.yml .
    #HelpBox = HelpTextDisplay(LinesCount=1, ColsCount=GlobalConfig.ScreenWidth,
    #    BeginY=GlobalConfig.ScreenHeight-1, BeginX=0)
    #GlobalConfig.DisplayObjects.append(HelpBox)
    #    # ^ Add this window to the displayable windows
    #LogWrite.debug('HelpBox window built (the line that displays help text)')

    # Connection text display window:
    ConnText = BasicTextDisplay(LinesCount=1, ColsCount=10,
        BeginY=GlobalConfig.ScreenHeight-3, BeginX=2, Text='Conn #', ColorPair=1,
        Name='ConnText')
    GlobalConfig.DisplayObjects.append(ConnText)
    LogWrite.debug('ConnText window built (the text that displays "Conn #")')

    # Connection text input window:
    ConnBox = TextBox(LinesCount=1, ColsCount=8,
        BeginY=GlobalConfig.ScreenHeight-3, BeginX=10, ColorPair=3,
        FilterText='', Name='ConnBox')
    LogWrite.debug('ConnBox == {}  (this is the conn number entry textbox)'
        .format(ConnBox))

    GlobalConfig.DisplayObjects.append(ConnBox)
        # ^ Add this window to the displayable windows
    GlobalConfig.TextboxObjects.append(ConnBox)
        # ^ Add this window to the windows that store user input.
        #   We do this because later we filter the ConnectionsList
        #   display output based upon the text here.
    LogWrite.debug('ConnBox window built (the textbox that accepts user input)')

    # Filter text display window:
    FilterText = BasicTextDisplay(LinesCount=1, ColsCount=10,
        BeginY=GlobalConfig.ScreenHeight-3, BeginX=20, Text='Filter:', ColorPair=1,
        Name='FilterText')
    GlobalConfig.DisplayObjects.append(FilterText)
    LogWrite.debug('FilterText window built (the text that displays "Filter:")')

    # Filter text input window:
    FilterBox = TextBox(LinesCount=1, ColsCount=GlobalConfig.ScreenWidth-31,
        BeginY=GlobalConfig.ScreenHeight-3, BeginX=29, ColorPair=3,
        FilterText=GlobalConfig.FilterTextArg, Name='FilterBox')
            # ^ Preload it with whatever was supplied as --filter command line arg
    LogWrite.debug('FilterBox == {}  (this is the filter text entry textbox)'
        .format(FilterBox))

    GlobalConfig.DisplayObjects.append(FilterBox)
    GlobalConfig.TextboxObjects.append(FilterBox)
    LogWrite.debug('FilterBox window built (the textbox that accepts user input)')

    # Connections list text display window:
    ConnectionsList = Connections(LinesCount=GlobalConfig.ScreenHeight-3,
        ColsCount=GlobalConfig.ScreenWidth, BeginY=0, BeginX=0, ColorPair=0,
        TextFilters=[FilterBox, ConnBox] , stdscr=stdscr,
        Name='ConnectionsList')
            # ^ We send TextFilters=[FilterBox, ConnBox] and not FilterBox.Text
            #   because we are referencing the object and when the Text changes
            #   within the object - the changes reflect in Connections.Display
            #   method.
    ConnectionsList.Load()
        # ^ Load the connections list config file into this object (normal operation)
    GlobalConfig.DisplayObjects.append(ConnectionsList)
    LogWrite.debug('ConnectionsList window built (the window with the list of servers)')

    CyclicTextboxObjects = itertools.cycle(GlobalConfig.TextboxObjects)
        # ^ Call this after adding all the textbox windows
        #   We will use it as a cyclic list iterator so that every press of TAB
        #   will give us the next textbox object in the list.

    return ConnectionsList, ConnBox, FilterBox, CyclicTextboxObjects


def DisplayHelp():
    ''' Display the help text '''

    LogWrite.debug('Displaying help...')
    pydoc.pipepager(__doc__, cmd='less -i --dumb --no-init')
        # ^ Print the text in through the less pager
        #   so we can get paging, search, etc


def MainCursesFunction(stdscr):

    LogWrite.debug('Function MainCursesFunction started')
    CommandInfo = {}

    if SetupCurses(stdscr) == False:
        # ^ Something failed in setup or tests
        LogWrite.error('SetupCurses(stdscr) failed')
        return

    LogWrite.debug('Setting up the ConnectionsList, ConnBox, FilterBox, '
        'CyclicTextboxObjects windows ...')
    ConnectionsList, ConnBox, FilterBox, CyclicTextboxObjects = SetupWindows(stdscr)
    UpdateDisplay()
    ActiveWindow = FilterBox    # The first window to read user input from
    ActiveWindow.ZeroCursor()
        # ^ We have no input text so activate ConnBox window and zero the cursor.
        #   Failure to do this will position the cursor at the last window drawn.

    # This block loops in the CyclicTextboxObjects until it's positioned at ActiveWindow.
    # We need this because the initial starting point may not be correct and the first
    # time we press TAB may do nothing:
    for Looper in CyclicTextboxObjects:
        if Looper == ActiveWindow:
            break

    LogWrite.debug('Window setup complete. The following "LogGlobalConfig" output '
        'is at this point...')
    LogGlobalConfig()

    if GlobalConfig.FilterTextArg and ConnectionsList.DisplayResultsCount == 1 and GlobalConfig.AutoConnect:
        # ^ User entered command line filter and there was only one match and we are on
        #       the first cycle - connect to it immediately.
        LogWrite.debug('GlobalConfig.FilterTextArg supplied and '
            'ConnectionsList.DisplayResultsCount == 1 and '
            'GlobalConfig.AutoConnect == True . '
            'Automatically connect to this server ...')
        LogWrite.debug('Clearing:  GlobalConfig.FilterTextArg')
        CommandInfo = ConnectionsList.GetCommandLine(
            ConnectionsList.SelectedEntryVal)
        return CommandInfo

    LogWrite.debug('Active window is now: ActiveWindow == {}  (the user input textbox that is active right now)'
        .format(ActiveWindow))
    LogWrite.debug('Starting keyboard read loop...')

    while True:
        ConnectionsList.LogPositions(Comment='Top of loop')
        UserKey = ActiveWindow.ReadKey()
        LogWrite.debug('Keyboard entry: UserKey == {}'.format(UserKey))
            # ^ This is the code of the key the user pressed

# This block is not required any more since I handle it better in
#   Connections.Display() method:
#        if UserKey == -1:       # Happens when window is resized
#            # ^ If I don't capture this, window resize causes a curses exception
#            LogWrite.debug('Detected window resize')
#            pass

        if UserKey in ConnectionsList.KeyboardBindings['nccm_key_tab']:
            # ^ Tab key (move between text boxes)
            LogWrite.debug('Handle Tab key ...')
            ActiveWindow = next(CyclicTextboxObjects)
                # ^ See the 'for Looper in CyclicTextboxObjects:' comment above
                #   We set ActiveWindow to the next item in the iterable but curses also
                #       needs to be updated that this window is active. We'll do that next.
            LogWrite.debug('Active window is now: ActiveWindow == {}  (the user input textbox that is active right now)'
                .format(ActiveWindow))
            ActiveWindow.ActivateWindow()
                # ^ Run it's ActivateWindow method so that curses knows of this change.
                #   This is effectively the next textbox window object.

        # Handle text entry in FilterBox:
        elif ( (chr(UserKey) in string.digits
            or chr(UserKey) in string.ascii_letters
            or chr(UserKey) in string.punctuation
            or UserKey in ConnectionsList.KeyboardBindings['nccm_key_space']    # Space
            or UserKey in ConnectionsList.KeyboardBindings['nccm_key_backspace']
                # ^ Backspace key
            or UserKey in ConnectionsList.KeyboardBindings['nccm_key_ctrl_u'] )
                # ^ Clear filterbox text
            and ActiveWindow == FilterBox ):
            # ^ Filterbox accepts any printable character or backspace
            #   Using string.punctuation crashes on ctrl-j input so I explictly
            #   identify the characters accepted.
                LogWrite.debug('Handle text entry in FilterBox ...')
                ConnectionsList.YOffset = 0
                ConnectionsList.XOffset = 0
                    # ^ Keypress received so reset the display to the beginning of the list
                ActiveWindow.ProcessKey(UserKey, ConnectionsList.KeyboardBindings)
                    # ^ Pass the keyboard bindings to the method because it doesn't
                    #       have access to ConnectionsList.KeyboardBindings.

        # Handle text entry in ConnBox:
        elif ( (chr(UserKey) in string.digits
            or UserKey in ConnectionsList.KeyboardBindings['nccm_key_backspace']
                # ^ Backspace key
            or UserKey in ConnectionsList.KeyboardBindings['nccm_key_ctrl_u'] )
                # ^ Clear filterbox text
            and ActiveWindow == ConnBox ):
            # ^ Filterbox accepts any digit or backspace
                LogWrite.debug('Handle text entry in ConnBox ...')
                ActiveWindow.ProcessKey(UserKey, ConnectionsList.KeyboardBindings)

        elif ( UserKey in ConnectionsList.KeyboardBindings['nccm_key_exc']
            # ^ ! key (sort by column #1)
            and ActiveWindow == ConnBox ):
                ConnectionsList.Sort('ServerXSNum')

        elif ( UserKey in ConnectionsList.KeyboardBindings['nccm_key_at']
            # ^ @ key (sort by column #2)
            and ActiveWindow == ConnBox ):
                ConnectionsList.Sort('FriendlyName')

        elif ( UserKey in ConnectionsList.KeyboardBindings['nccm_key_hash']
            # ^ # key (sort by column #3)
            and ActiveWindow == ConnBox ):
                ConnectionsList.Sort('UserName')

        elif ( UserKey in ConnectionsList.KeyboardBindings['nccm_key_dollar']
            # ^ $ key (sort by column #4)
            and ActiveWindow == ConnBox ):
                ConnectionsList.Sort('Address')

        elif ( UserKey in ConnectionsList.KeyboardBindings['nccm_key_percent']
            # ^ % key (sort by column #5)
            and ActiveWindow == ConnBox ):
                ConnectionsList.Sort('Comment')

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_shift_up']:
            # ^ Shift up arrow
            if ConnectionsList.ControlMode == 'focus':
                if ConnectionsList.MarkerLine > 0:
                    ConnectionsList.MarkerLine -= 1

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_shift_down']:
            # ^ Shift down arrow
            if ConnectionsList.ControlMode == 'focus':
                if ConnectionsList.MarkerLine < (ConnectionsList.DisplayResultsCount-1):
                    ConnectionsList.MarkerLine += 1

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_shift_left']:
            # ^ Shift left arrow
            if ConnectionsList.ControlMode == 'focus':
                ConnectionsList.MarkerLine = 0

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_shift_right']:
            # ^ Shift right arrow
            if ConnectionsList.ControlMode == 'focus':
                ConnectionsList.MarkerLine = (ConnectionsList.DisplayResultsCount-1)

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_up']:
            # ^ Up arrow
            if ConnectionsList.ControlMode == 'focus':
                if ConnectionsList.YOffset > 0:
                    ConnectionsList.YOffset -= 1
            elif ConnectionsList.ControlMode == 'std':
                if ConnectionsList.MarkerLine > 0:
                    ConnectionsList.MarkerLine -= 1
                elif len(ConnectionsList.ResultsList) - ConnectionsList.YOffset > 0:
                    if ConnectionsList.YOffset >1:
                        ConnectionsList.YOffset -= 1
                    else:
                        ConnectionsList.YOffset = 0
            #ConnectionsList.LogPositions(Comment='Up arrow')
                # ^ Too much logging

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_down']:
            # ^ Down arrow
            if ConnectionsList.ControlMode == 'focus':
                if (len(ConnectionsList.ResultsList) -
                    ConnectionsList.YOffset > ConnectionsList.MaxTextLines):
                    ConnectionsList.YOffset += 1
            elif ConnectionsList.ControlMode == 'std':
                if ConnectionsList.MarkerLine < (ConnectionsList.DisplayResultsCount-1):
                    ConnectionsList.MarkerLine += 1
                elif (len(ConnectionsList.ResultsList) -
                    ConnectionsList.YOffset > ConnectionsList.MaxTextLines):
                    ConnectionsList.YOffset += 1
            #ConnectionsList.LogPositions(Comment="Down arrow")
                # ^ Too much logging

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_home']:
            # ^ Home key
            if ConnectionsList.ControlMode == 'focus':
                ConnectionsList.YOffset = 0
            elif ConnectionsList.ControlMode == 'std':
                ConnectionsList.YOffset = 0
                ConnectionsList.MarkerLine = 0

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_end']:
            # ^ End key
            if ConnectionsList.ControlMode == 'focus':
                if len(ConnectionsList.ResultsList) > ConnectionsList.MaxTextLines:
                    ConnectionsList.YOffset = (len(ConnectionsList.ResultsList) -
                        ConnectionsList.MaxTextLines)
            elif ConnectionsList.ControlMode == 'std':
                if len(ConnectionsList.ResultsList) > ConnectionsList.MaxTextLines:
                    ConnectionsList.YOffset = (len(ConnectionsList.ResultsList) -
                        ConnectionsList.MaxTextLines)
                ConnectionsList.MarkerLine = (ConnectionsList.DisplayResultsCount-1)

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_pgup']:
            # ^ PageUp key
            ConnectionsList.YOffset -= ConnectionsList.MaxTextLines
            if ConnectionsList.YOffset < 0:
                ConnectionsList.YOffset = 0

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_pgdn']:
            # ^ PageDown key
            ConnectionsList.YOffset += ConnectionsList.MaxTextLines
            if (ConnectionsList.YOffset > (len(ConnectionsList.ResultsList) -
                ConnectionsList.MaxTextLines)):
                ConnectionsList.YOffset = (len(ConnectionsList.ResultsList) -
                    ConnectionsList.MaxTextLines)

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_left']:
            # ^ Left arrow (scroll left)
            ConnectionsList.XOffset -= (ConnectionsList.MaxTextWidth //3)
            if ConnectionsList.XOffset < 0:
                ConnectionsList.XOffset = 0

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_right']:
            # ^ Right arrow (scroll right)
            ConnectionsList.XOffset += (ConnectionsList.MaxTextWidth //3)
            if (ConnectionsList.XOffset > (ConnectionsList.MaxLineLength -
                ConnectionsList.MaxTextWidth)):
                ConnectionsList.XOffset = (ConnectionsList.MaxLineLength -
                    ConnectionsList.MaxTextWidth)

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_f1']:
            # ^ F1 key (sort by column #1)
            ConnectionsList.Sort('ServerXSNum')

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_f2']:
            # ^ F2 key (sort by column #2)
            ConnectionsList.Sort('FriendlyName')

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_f3']:
            # ^ F3 key (sort by column #3)
            ConnectionsList.Sort('UserName')

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_f4']:
            # ^ F4 key (sort by column #4)
            ConnectionsList.Sort('Address')

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_f5']:
            # ^ F5 key (sort by column #5)
            ConnectionsList.Sort('Comment')

        # Enter key in ConnBox:
        elif ((UserKey in ConnectionsList.KeyboardBindings['nccm_key_enter'])
            and ActiveWindow == ConnBox
            and ConnBox.Text):
                LogWrite.debug('Handle Enter key in ConnBox ...')
                if (int(ConnBox.Text) >= 0
                    and int(ConnBox.Text) <= (len(ConnectionsList.FullServersList) -1)):
                        CommandInfo = ConnectionsList.GetCommandLine(ConnBox.Text)
                        break

        # Enter key in FilterBox:
        elif ((UserKey in ConnectionsList.KeyboardBindings['nccm_key_enter'])
            and ConnectionsList.SelectedEntry):
                LogWrite.debug('Handle Enter key in FilterBox ...')
                    # ^ Well, it has to be FilterBox because we explicitly checked
                    #   for ConnBox earlier...
                LogWrite.debug('Enter key pressed. ConnectionsList.SelectedEntryVal == {} , ConnectionsList.SelectedEntry == {}'
                    .format(ConnectionsList.SelectedEntryVal, ConnectionsList.SelectedEntry))
                ConnectionsList.LogPositions(Comment="Enter key")

                # Save these vars so that we can display the marker line at the same
                #   position after connection ends:
                GlobalConfig.LoopYOffset = ConnectionsList.YOffset
                GlobalConfig.LoopMarkerLine = ConnectionsList.MarkerLine

                CommandInfo = ConnectionsList.GetCommandLine(
                    ConnectionsList.SelectedEntryVal)
                break

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_ctrl_h']:
            # ^ Ctrl-h key (help)
            LogWrite.debug('User asked for help')
            curses.endwin()     # De-initialize and return terminal to normal status
            DisplayHelp()

        elif UserKey in ConnectionsList.KeyboardBindings['nccm_key_ctrl_k']:
            # ^ Ctrl-k key (toggle cursor mode)
            if ConnectionsList.ControlMode == 'std':
                ConnectionsList.ControlMode = 'focus'
            else:
                ConnectionsList.ControlMode = 'std'

        elif ( UserKey in ConnectionsList.KeyboardBindings['nccm_key_ctrl_q']
            or UserKey in ConnectionsList.KeyboardBindings['nccm_key_ctrl_c']
            or UserKey in ConnectionsList.KeyboardBindings['nccm_key_ctrl_d'] ):
            # ^ ctrl-q / ctrl-c / ctrl-d
            LogWrite.debug('User wants to quit. '
                'Setting: GlobalConfig.LoopNccm = False  so that we '
                'can exit properly...')
            GlobalConfig.LoopNccm = False
                # ^ Otherwise we will be stuck in the loop if nccm was started
                #   with config file containing -  nccm_loop_nccm: true
            break

        UpdateDisplay()
        ActiveWindow.ActivateWindow()

    return CommandInfo


def InitialLogging():
    ''' Put any useful logging here - such as startup env details.
        This will require DEBUG level logging, so start nccm
        in debug mode with the -d command line arg.

        Or set the line as follows:
            LogLevel = logging.DEBUG
    '''

    IntroLine = ('{} v{} , {} , by: {} ( {} )'
        .format(ProgramName, __version__, VersionDate, AuthorName, AuthorEmail))

    LogWrite.info(IntroLine)
    LogWrite.info( {
        'unsafe': 'Invoked commandline: {CmdLine} , from directory: {Dir} , '
            'by user: {User} , UID: {UID} , PPID: {PPID} , log level: {LogLevel}'
                .format(
                    CmdLine = sys.argv,
                    Dir = os.getcwd(),
                    User = getpass.getuser(),
                    UID = os.getuid(),
                    PPID = os.getppid(),
                    LogLevel = LogLevel ),
        'safe':   'Invoked commandline: CENSORED , from directory: CENSORED , '
        'by user: CENSORED , UID: CENSORED , PPID: CENSORED , log level: {LogLevel}'
            .format(LogLevel = LogLevel) } )


    LogWrite.info('Fields explained: PN: Process Name , MN: Module Name , '
        'FN: Function Name , LI: LIne number , '
        'TN: Thread Name')

    if not SecureLogging:
        LogWrite.warning('Attention:  SecureLogging == False  -->  '
            'sensitive information will be logged to syslog/journal !')


def ParseArgs():
    ''' Handle any command line arguments supplied '''

    global SecureLogging

    parser = argparse.ArgumentParser(
        description = 'NCurses Connection Manager by Kenneth Aaron',
        epilog = 'Thank you for using nccm',
        #add_help = False )
            # ^ Bypass the built in argparse help generator and release
            #   the -h and --help args for our own use.
        )

    parser.add_argument('-d', '--debug',
        help='force debug verbosity logging, ignore other logging settings',
        action='store_true' )

    parser.add_argument('-m', '--man',
        help='display nccm man page and exit',
        action='store_true' )

    parser.add_argument('-v', '--version',
        help='show version and exit',
        action='store_true' )

    parser.add_argument('--logprivateinfo',
        help='log uncensored usernames, hostnames, etc',
        action='store_true' )

    parser.add_argument('filter_text',
        nargs='*',
        help="supply initial filter text, example: nccm -d xyz abc . "
            "If there is only one match - connect to it immediately" )

    args = parser.parse_args()
    #LogWrite.debug('args object = {}'.format(args))
        # ^ Exposes private info before we get a chance to check the command
        #       line args whether the user allows private info logging.

    if args.logprivateinfo:
        SecureLogging = False
        LogWrite.warning('--logprivateinfo command line argument supplied - '
            'stuff like usernames and hostnames will be logged to syslog/journal !')

    if args.debug:
        LogWrite.setLevel(logging.DEBUG)
        GlobalConfig.ForceDebugLogging = True
        LogWrite.debug('Received command line arg --debug forcing nccm '
            'to run in debug mode')

    LogWrite.debug('parser object = {}'.format(parser))
    LogWrite.debug( {
        'unsafe':   'args object == {}'.format(args),
        'safe':     'args object == CENSORED' } )
        # ^ Huh? I set:  nccm_config_logprivateinfo: true  in nccm.yml but I am still
        #       seeing CENSORED in the logs! That's because this setting hasn't loaded yet.
        #   Force it with command line arg:  --logprivateinfo

    if args.filter_text:
        GlobalConfig.FilterTextArg = args.filter_text
        LogWrite.debug( {
            'unsafe':   'GlobalConfig.FilterTextArg (user-supplied filtering text '
                'at the command line) == {}'
                    .format(GlobalConfig.FilterTextArg),
            'safe':     'GlobalConfig.FilterTextArg (user-supplied filtering text '
                'at the command line) == CENSORED' } )
            # ^ Comes in as a list
        GlobalConfig.FilterTextArg = ' '.join(GlobalConfig.FilterTextArg).lower()
            # ^ It is used within class TextBox.Text as a space delimited string
            #       so we must convert it here to a string too.
        LogWrite.debug( {
            'unsafe':   'Lowercased and converted to a string: GlobalConfig.FilterTextArg == "{}"'
                .format(GlobalConfig.FilterTextArg),
            'safe':     'Lowercased and converted to a string: GlobalConfig.FilterTextArg == CENSORED' } )

    if args.man:
        DisplayHelp()
        sys.exit(0)

    if args.version:
        print(__version__)
        sys.exit(0)


def main(*args):

    ParseArgs()     # Command line args processing happens here
    InitialLogging()
        # ^ Moved it after ParseArgs so that we have debugging info logged if
        #   started using 'nccm -d' .

    while True:
        CommandInfo = curses.wrapper(MainCursesFunction)

        if CommandInfo:

            LogWrite.debug( {
                'unsafe':   'CommandInfo as dict == {}'
                    .format(CommandInfo),
                'safe':     'CommandInfo == {}'
                    .format('CENSORED') } )
                # ^ Note - even though we're logging the entire CommandInfo dict here,
                #       we won't pass all of it to the script.

            # PreconnectScript block:
            if GlobalConfig.PreconnectScript:
                print('\nnccm: About to run script:\n{}\n'.format(GlobalConfig.PreconnectScript))
                PressEnterToCont('Press Enter to continue (before running script) ...')

                LogWrite.info( {
                    'unsafe':   'About to subprocess.run ExtCommand == {} {} ...'
                        .format(GlobalConfig.PreconnectScript, CommandInfo['UserAddr']),
                    'safe':     'About to subprocess.run ExtCommand == {} ...'
                        .format('CENSORED') } )

                try:
                    subprocess.run( [ GlobalConfig.PreconnectScript, CommandInfo['UserAddr'] ], check=True, shell=False )
                        # ^ Run the PreconnectScript only supplying the connection info.

                except subprocess.CalledProcessError as CPE:
                    print('\n***** PreconnectScript exit code == {} *****'
                        .format(CPE.returncode))
                    print('Further debug details: {}'.format(CPE))
                    LogWrite.warning('PreconnectScript exit code == {}'.format(CPE.returncode))
                    LogWrite.warning('Further debug details: {}'.format(CPE))

            else:
                LogWrite.debug('GlobalConfig.PreconnectScript code was not run')

            # ssh connection block:
            print('\nnccm: About to ssh using:\n{}\n'.format(CommandInfo['CommandLine']))

            if not GlobalConfig.PreconnectScript:
                PressEnterToCont('Press Enter to continue (before connecting) ...')

            print('Connecting...')

            LogWrite.info( {
                'unsafe':   'About to subprocess.run ExtCommand == {} ...'
                    .format(CommandInfo['CommandLine']),
                'safe':     'About to subprocess.run ExtCommand == {} ...'
                    .format('CENSORED') } )

            StartTime = time.time()

            try:
                subprocess.run(CommandInfo['CommandLine'], check=True, shell=True)
                    # ^ Connect to the ssh server

            except subprocess.CalledProcessError as CPE:
                print('\n***** ssh exit code == {} *****'
                    .format(CPE.returncode))
                print('Further debug details: {}'.format(CPE))
                LogWrite.warning('ssh exit code == {}'.format(CPE.returncode))
                LogWrite.warning('Further debug details: {}'.format(CPE))

            finally:
                EndTime = time.time()
                SessionTime = round(EndTime - StartTime)
                print('\nnccm: Completed ssh using:\n{}\nSession time was: '
                    '{} ({} seconds)\n'
                        .format(CommandInfo['CommandLine'], datetime.timedelta(seconds=SessionTime),
                            SessionTime))

                LogWrite.info( {
                    'unsafe':   'Completed ssh using: {} . SessionTime was: {} seconds'
                        .format(CommandInfo['CommandLine'], SessionTime),
                    'safe':     'Completed ssh using: {} . SessionTime was: {} seconds'
                        .format('CENSORED', SessionTime) } )

            # PostconnectScript block:
            if GlobalConfig.PostconnectScript:
                print('\nnccm: About to run script:\n{}\n'.format(GlobalConfig.PostconnectScript))

                LogWrite.info( {
                    'unsafe':   'About to subprocess.run ExtCommand == {} {} ...'
                        .format(GlobalConfig.PostconnectScript, CommandInfo['UserAddr']),
                    'safe':     'About to subprocess.run ExtCommand == {} ...'
                        .format('CENSORED') } )

                try:
                    subprocess.run( [ GlobalConfig.PostconnectScript, CommandInfo['UserAddr'] ], check=True, shell=False )
                        # ^ Run the PostconnectScript only supplying the connection info.

                except subprocess.CalledProcessError as CPE:
                    print('\n***** PostconnectScript exit code == {} *****'
                        .format(CPE.returncode))
                    print('Further debug details: {}'.format(CPE))
                    LogWrite.warning('PostconnectScript exit code == {}'.format(CPE.returncode))
                    LogWrite.warning('Further debug details: {}'.format(CPE))

            else:
                LogWrite.debug('GlobalConfig.PostconnectScript code was not run')

        if GlobalConfig.LoopNccm:
            PressEnterToCont('Press Enter to continue (before returning to nccm) ...')
            LogWrite.debug('GlobalConfig.LoopNccm == {} - we are looping nccm instead of exiting at this point'
                .format(GlobalConfig.LoopNccm))

            GlobalConfig.AutoConnect = False
                # ^ Disable this to prevent automatic loop connection to the same
                #       server if only one match in list.

        else:
            LogWrite.debug('GlobalConfig.LoopNccm == {} - exiting nccm'
                .format(GlobalConfig.LoopNccm))
            break


if __name__ == '__main__':
    ScriptStartTime = time.time()
    main()

    LogWrite.info('{} exiting. Program run time: {:.3f} seconds'
        .format(ProgramName, time.time() - ScriptStartTime))


